Compare commits
4 Commits
f81a8e41ef
...
5a2efe5da6
| Author | SHA1 | Date |
|---|---|---|
|
|
5a2efe5da6 | |
|
|
3bf8bc4eaa | |
|
|
b5f2801076 | |
|
|
e205194758 |
25
DECISIONS.md
25
DECISIONS.md
|
|
@ -963,3 +963,28 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **The upgrade runs in the player's locked transaction** (`withPlayer` locks the company row, Step 6.14), so the funds check and the charge cannot interleave with another purchase: of two simultaneous upgrades the player can afford one of, exactly one succeeds and the balance never goes negative (tested).
|
||||
- **The level is raised at purchase** (decision 0.5.66). Effective stats change at once, but the ship can't fly until in-shop-until, so there is nothing to finish later.
|
||||
- **Server messages for upgrade refusals** live with the other validation texts (`UPGRADE_MESSAGES`); an unknown upgrade id is `404 not-found`.
|
||||
|
||||
## Step 10I.6
|
||||
|
||||
- **The detail view is a sheet opened from an "Upgrades" button on each Fleet card**, so the card keeps its quick actions (Find work, Deadhead) and the shop is one tap away.
|
||||
- **The client decides what's enabled with the sim's `validateUpgrade`**, using "a bay where the ship is" for at-HQ, exactly as the server does.
|
||||
- **"In the shop" is decided by the clock** (`inShopUntil` vs now), so the countdown and the card's actions return the moment the work is done, without a refetch.
|
||||
- **Handling time reads in game minutes per tonne** (1.20 → 1.02 min/t) and overhead in hours, so small changes stay visible.
|
||||
|
||||
## Step 10I.7
|
||||
|
||||
- **The client computes effective stats with the sim** (`shipStatsOf`) from the ship's model and upgrade levels, rather than reading the API's `stats`, so predictions run on the exact functions the server uses. `stats` stays in the API for display and other clients.
|
||||
- **"Market ship comparison for owned ships"**: the Market had no owned-ship comparison, so each model card now lists the player's upgraded ships of that model with their effective speed, capacity and range under the base specs.
|
||||
- **Map reachability** keeps listing purchasable models (base stats) and adds the player's upgraded ships that reach only because of their upgrades, e.g. "your Dusty Mule (upgraded)" for a Courier with Fuel Tanks 3 near Mars's closest approach.
|
||||
|
||||
## Step 10I.8
|
||||
|
||||
- **"Finished since the last visit" means finished and not yet acknowledged**: a ship keeps its shop job after the shop time passes until the player dismisses the modal, so an upgrade that finished while the app was closed is reported once, like a delivery.
|
||||
- **Acknowledging only clears finished jobs**; a ship still in the shop keeps its job even if its id is sent.
|
||||
- **The modal can open for shop news alone** (no deliveries), and the acknowledged count includes cleared upgrades.
|
||||
- **The HQ card only appears while a ship is in the shop**, unlike the always-on pilot rest card: the shop is an occasional action, rest is a rhythm.
|
||||
|
||||
## Step 10I.9
|
||||
|
||||
- **GDD §8 is left to the designer**, as the CR says; it doesn't mention upgrades yet, so the "confirm it matches" check is flagged for the user rather than written here.
|
||||
- **The manual check was scripted** (`cr009-smoke.mjs`). It found that with 10-minute shop times and 10-minute boards, the heavy job you spot before upgrading always expires before the ship leaves the shop; the script finds a fresh 21–23 t job afterwards. Whether to keep that tension is a Phase 12 call.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { RealTime } from "@deadhead/sim";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm";
|
||||
import type { Queryable } from "../db/client";
|
||||
import { dispatches } from "../db/schema";
|
||||
import { dispatches, ships } from "../db/schema";
|
||||
|
||||
/**
|
||||
* Marks "while you were away" summaries as seen (Step 6.13). Only this
|
||||
|
|
@ -28,3 +28,29 @@ export async function acknowledgeSummaries(
|
|||
.returning({ id: dispatches.id });
|
||||
return updated.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears finished upgrades the player has now seen ("out of the shop", CR-009).
|
||||
* Ships still in the shop keep their job.
|
||||
*/
|
||||
export async function acknowledgeFinishedUpgrades(
|
||||
tx: Queryable,
|
||||
playerId: string,
|
||||
shipIds: string[],
|
||||
now: RealTime,
|
||||
): Promise<number> {
|
||||
if (shipIds.length === 0) return 0;
|
||||
const cleared = await tx
|
||||
.update(ships)
|
||||
.set({ shopUpgradeId: null, shopLevel: null })
|
||||
.where(
|
||||
and(
|
||||
eq(ships.playerId, playerId),
|
||||
inArray(ships.id, shipIds),
|
||||
isNotNull(ships.shopUpgradeId),
|
||||
lte(ships.inShopUntil, now),
|
||||
),
|
||||
)
|
||||
.returning({ id: ships.id });
|
||||
return cleared.length;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { claimGoal } from "../game/goals";
|
|||
import { repayLoan } from "../game/loan";
|
||||
import { createPlayer } from "../game/new-player";
|
||||
import { loadPlayerState } from "../game/player-state";
|
||||
import { acknowledgeSummaries } from "../game/summaries";
|
||||
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
||||
import { ApiError } from "../http/errors";
|
||||
import { registerRoute } from "../http/route";
|
||||
import type { AppContext } from "./context";
|
||||
|
|
@ -49,7 +49,9 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
|
|||
|
||||
registerRoute(app, routes.acknowledgeSummaries, options, async (input, { playerId }) =>
|
||||
withPlayer(ctx, playerId, async (tx, now) => ({
|
||||
acknowledged: await acknowledgeSummaries(tx, playerId, input.dispatchIds, now),
|
||||
acknowledged:
|
||||
(await acknowledgeSummaries(tx, playerId, input.dispatchIds, now)) +
|
||||
(await acknowledgeFinishedUpgrades(tx, playerId, input.shipIds ?? [], now)),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -160,4 +160,27 @@ describe("starting an upgrade (Step 10I.5)", () => {
|
|||
expect(state.company.credits).toBe(cost / 2);
|
||||
expect(state.ships.filter((s) => s.upgrades.engines === 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the finished job until acknowledged, and only clears finished ones", async () => {
|
||||
const { app, clock } = testApp();
|
||||
const { owner, freighter } = await ownerWithFreighter();
|
||||
await upgrade(owner, freighter.id, "cargo-bay");
|
||||
const ack = () =>
|
||||
call(app, owner, "POST", "/api/summaries/acknowledge", {
|
||||
dispatchIds: [],
|
||||
shipIds: [freighter.id],
|
||||
});
|
||||
|
||||
// Still in the shop: acknowledging does nothing.
|
||||
expect((await ack()).json()).toEqual({ acknowledged: 0 });
|
||||
const until = shipIn(await getState(app, owner), freighter.id).shop!.until;
|
||||
|
||||
clock.now = until;
|
||||
expect(shipIn(await getState(app, owner), freighter.id)).toMatchObject({
|
||||
inShopUntil: null,
|
||||
shop: { upgradeId: "cargo-bay", level: 1, until },
|
||||
});
|
||||
expect((await ack()).json()).toEqual({ acknowledged: 1 });
|
||||
expect(shipIn(await getState(app, owner), freighter.id).shop).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatCredits, locationName, toLocation } from "../format";
|
||||
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
|
|
@ -14,21 +15,27 @@ export function AwaySummary() {
|
|||
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const navigate = useNavigate();
|
||||
const now = useClock((s) => s.now);
|
||||
const summaries = state.summaries.filter((s) => !dismissed.has(s.dispatchId));
|
||||
// Upgrades finished and not yet seen: "out of the shop" (CR-009).
|
||||
const outOfShop = state.ships.filter(
|
||||
(s) => s.shop !== null && s.shop.until <= now && !dismissed.has(`ship:${s.id}`),
|
||||
);
|
||||
// Goals waiting to be claimed (Step 10A.17).
|
||||
const readyGoals = state.goals
|
||||
.filter((g) => g.status === "ready")
|
||||
.map((g) => content.onboardingGoal(g.id))
|
||||
.filter((g) => g !== undefined);
|
||||
if (summaries.length === 0) return null;
|
||||
if (summaries.length === 0 && outOfShop.length === 0) return null;
|
||||
|
||||
const shipName = (shipId: string, tutorial: boolean) =>
|
||||
tutorial ? "Company loaner" : (state.ships.find((s) => s.id === shipId)?.name ?? "A ship");
|
||||
|
||||
const dismiss = () => {
|
||||
const ids = summaries.map((s) => s.dispatchId);
|
||||
setDismissed(new Set([...dismissed, ...ids]));
|
||||
acknowledge.mutate({ dispatchIds: ids });
|
||||
const shipIds = outOfShop.map((s) => s.id);
|
||||
setDismissed(new Set([...dismissed, ...ids, ...shipIds.map((id) => `ship:${id}`)]));
|
||||
acknowledge.mutate({ dispatchIds: ids, ...(shipIds.length > 0 ? { shipIds } : {}) });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -98,6 +105,16 @@ export function AwaySummary() {
|
|||
)}
|
||||
</li>
|
||||
))}
|
||||
{outOfShop.map((ship) => (
|
||||
<li
|
||||
key={`shop:${ship.id}`}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-amber-400/40 bg-amber-950/30 p-3 text-sm text-amber-200"
|
||||
>
|
||||
<Icon name="check" className="size-4 shrink-0" />
|
||||
{ship.name} is out of the shop:{" "}
|
||||
{content.upgrade(ship.shop!.upgradeId)?.name ?? ship.shop!.upgradeId} {ship.shop!.level}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{readyGoals.length > 0 && (
|
||||
<div className="mt-3 rounded-xl border border-emerald-400/40 bg-emerald-950/40 p-3 text-sm shadow-glow-emerald">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract, PlayerState } from "@deadhead/schema";
|
||||
import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim";
|
||||
import { shipStatsOf } from "../game";
|
||||
import { useState } from "react";
|
||||
import { cargoName, formatDuration } from "../format";
|
||||
import { Pill, type Tone } from "../ui";
|
||||
|
|
@ -70,11 +71,10 @@ export function tooSlowForAll(state: PlayerState, contract: Contract, now: numbe
|
|||
if (!contract.priority || contract.deliveryWindowGameHours === null) return false;
|
||||
const idle = state.ships.filter((s) => s.status === "idle" && s.locationId === contract.originId);
|
||||
if (idle.length === 0) return false;
|
||||
return idle.every((ship) => {
|
||||
const model = content.shipModel(ship.modelId);
|
||||
return (
|
||||
!model ||
|
||||
contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours!
|
||||
);
|
||||
});
|
||||
// Effective stats: an engines upgrade can make the window (CR-009).
|
||||
return idle.every(
|
||||
(ship) =>
|
||||
contractTripGameHours(shipStatsOf(ship), contract, now, content) >
|
||||
contract.deliveryWindowGameHours!,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { content, type Location, type ShipModel } from "@deadhead/content";
|
||||
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import {
|
||||
effectiveStats,
|
||||
MS_PER_HOUR,
|
||||
realToGame,
|
||||
routeDistance,
|
||||
travelTimeGameHours,
|
||||
type EffectiveStats,
|
||||
type ShipState,
|
||||
} from "@deadhead/sim";
|
||||
|
||||
/** Client-side predictions with the same sim the server uses (rule 0.1.6). */
|
||||
|
||||
|
|
@ -40,3 +49,20 @@ export function tripRealMs(
|
|||
);
|
||||
return (hours * MS_PER_HOUR) / content.config.timeCompression;
|
||||
}
|
||||
|
||||
/** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */
|
||||
export function shipStatsOf(ship: Pick<Ship, "modelId" | "upgrades">): EffectiveStats {
|
||||
return effectiveStats(shipModel(ship.modelId), ship.upgrades, content);
|
||||
}
|
||||
|
||||
/** The sim's view of an owned ship: location, flight, upgrades and shop time (CR-009). */
|
||||
export function simShip(ship: Ship): ShipState {
|
||||
return {
|
||||
id: ship.id,
|
||||
modelId: ship.modelId,
|
||||
locationId: ship.locationId ?? "",
|
||||
inFlight: ship.status === "in-flight",
|
||||
upgrades: ship.upgrades,
|
||||
inShopUntil: ship.inShopUntil,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { content, type ShipModel } from "@deadhead/content";
|
||||
import { checkRoute, realToGame } from "@deadhead/sim";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import { checkRoute, effectiveStats, realToGame } from "@deadhead/sim";
|
||||
|
||||
/**
|
||||
* Purchasable ship models that can fly from Earth to `locationId` at real time
|
||||
|
|
@ -14,3 +15,26 @@ export function modelsReachingFromEarth(locationId: string, now: number): ShipMo
|
|||
(model) => checkRoute(model, earth, target, gameTime).ok,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's own ships that reach `locationId` from Earth only thanks to
|
||||
* their upgrades (effective stats, CR-009): the base model can't, they can.
|
||||
*/
|
||||
export function upgradedShipsReachingFromEarth(
|
||||
ships: readonly Ship[],
|
||||
locationId: string,
|
||||
now: number,
|
||||
): Ship[] {
|
||||
const earth = content.location("earth");
|
||||
const target = content.location(locationId);
|
||||
if (!earth || !target) return [];
|
||||
const gameTime = realToGame(now, content.config);
|
||||
return ships.filter((ship) => {
|
||||
const model = content.shipModel(ship.modelId);
|
||||
if (!model?.purchasable || Object.keys(ship.upgrades).length === 0) return false;
|
||||
return (
|
||||
!checkRoute(model, earth, target, gameTime).ok &&
|
||||
checkRoute(effectiveStats(model, ship.upgrades, content), earth, target, gameTime).ok
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Contract, Pilot, PlayerState } from "@deadhead/schema";
|
|||
import {
|
||||
checkRoute,
|
||||
contractTripGameHours,
|
||||
isInShop,
|
||||
dutyAfterTrip,
|
||||
LONG_HAUL_SKILL_ID,
|
||||
needsRest,
|
||||
|
|
@ -25,7 +26,7 @@ import {
|
|||
skillName,
|
||||
} from "../format";
|
||||
import { requirementsText } from "./LocationBoards";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { location, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { RouteStrip } from "../components/RouteStrip";
|
||||
import { Art, Button, Sheet } from "../ui";
|
||||
|
|
@ -150,7 +151,8 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
|||
return state.ships
|
||||
.filter((ship) => ship.status === "idle" && ship.locationId === contract.originId)
|
||||
.map((ship) => {
|
||||
const model = shipModel(ship.modelId);
|
||||
// Effective stats: upgrades change capacity, range and trip time (CR-009).
|
||||
const model = shipStatsOf(ship);
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||
const tripMs = tripRealMs(
|
||||
model,
|
||||
|
|
@ -171,12 +173,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
|||
if (pilot) {
|
||||
const check = validateDispatch(
|
||||
{
|
||||
ship: {
|
||||
id: ship.id,
|
||||
modelId: ship.modelId,
|
||||
locationId: ship.locationId!,
|
||||
inFlight: false,
|
||||
},
|
||||
ship: simShip(ship),
|
||||
pilot: {
|
||||
id: pilot.id,
|
||||
isAvatar: pilot.isAvatar,
|
||||
|
|
@ -197,6 +194,9 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number)
|
|||
problems = [];
|
||||
} else {
|
||||
const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now);
|
||||
// In the shop (CR-009): first, with the time left.
|
||||
if (isInShop(ship, now))
|
||||
all.unshift(`In the shop — ${formatClock(ship.inShopUntil! - now)}`);
|
||||
problems = all.length > 0 ? all : [INELIGIBLE_REASONS[check.reason]];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import { useState } from "react";
|
|||
import { Link } from "react-router";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||
import { location, shipModel, tripRealMs } from "../game";
|
||||
import { ShipUpgrades } from "./ShipUpgrades";
|
||||
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
|
||||
import { location, shipModel, tripRealMs, shipStatsOf, simShip } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import {
|
||||
Art,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
export function Fleet() {
|
||||
const state = useLoadedPlayer();
|
||||
const [deadheading, setDeadheading] = useState<Ship | null>(null);
|
||||
const [upgrading, setUpgrading] = useState<string | null>(null);
|
||||
|
||||
const flying = state.ships.filter((s) => s.status === "in-flight").length;
|
||||
|
||||
|
|
@ -65,11 +67,13 @@ export function Fleet() {
|
|||
dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
|
||||
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
|
||||
onDeadhead={() => setDeadheading(ship)}
|
||||
onUpgrades={() => setUpgrading(ship.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
|
||||
{upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,11 +83,14 @@ function ShipRow(props: {
|
|||
dispatch: Dispatch | undefined;
|
||||
pilotName: string | null;
|
||||
onDeadhead: () => void;
|
||||
onUpgrades: () => void;
|
||||
}) {
|
||||
const { ship, dispatch, pilotName } = props;
|
||||
const now = useClock((s) => s.now);
|
||||
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
|
||||
const model = shipModel(ship.modelId);
|
||||
// In the upgrade shop until then (CR-009); the clock decides, so it ends on time.
|
||||
const inShop = ship.inShopUntil !== null && now < ship.inShopUntil;
|
||||
|
||||
return (
|
||||
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3">
|
||||
|
|
@ -103,6 +110,8 @@ function ShipRow(props: {
|
|||
<Pill tone="cyan" icon="send">
|
||||
In flight
|
||||
</Pill>
|
||||
) : inShop ? (
|
||||
<Pill tone="amber">In the shop</Pill>
|
||||
) : (
|
||||
<Pill tone="emerald">Idle</Pill>
|
||||
)}
|
||||
|
|
@ -133,7 +142,12 @@ function ShipRow(props: {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ship.status === "idle" && (
|
||||
{inShop && (
|
||||
<p aria-label={`${ship.name} shop`} className="font-display text-sm text-amber-200">
|
||||
In the shop — <Countdown to={ship.inShopUntil!} format={formatClock} done="done" />
|
||||
</p>
|
||||
)}
|
||||
{ship.status === "idle" && !inShop && (
|
||||
<div className="mt-auto flex flex-wrap gap-2">
|
||||
{ship.locationId && (
|
||||
<Link
|
||||
|
|
@ -155,6 +169,16 @@ function ShipRow(props: {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={props.onUpgrades}
|
||||
aria-label={`Upgrades for ${ship.name}`}
|
||||
>
|
||||
Upgrades
|
||||
{Object.keys(ship.upgrades).length > 0 &&
|
||||
` · ${Object.values(ship.upgrades).reduce((a, b) => a + b, 0)} installed`}
|
||||
</Button>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -178,7 +202,8 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
|||
const state = useLoadedPlayer();
|
||||
const now = useClock((s) => s.now);
|
||||
const reposition = useGameAction("createDispatch");
|
||||
const model = shipModel(ship.modelId);
|
||||
// Effective stats: upgraded fuel tanks reach farther (CR-009).
|
||||
const model = shipStatsOf(ship);
|
||||
const origin = ship.locationId!;
|
||||
const pilot = state.pilots.find((p) => p.assignedShipId === ship.id) ?? null;
|
||||
const discovered = state.locations.filter((l) => l.discovered).map((l) => l.id);
|
||||
|
|
@ -190,7 +215,7 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v
|
|||
.map((id) => {
|
||||
const check = validateReposition(
|
||||
{
|
||||
ship: { id: ship.id, modelId: ship.modelId, locationId: origin, inFlight: false },
|
||||
ship: simShip(ship),
|
||||
pilot: {
|
||||
id: pilot?.id ?? "",
|
||||
isAvatar: pilot?.isAvatar ?? false,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export function HQ() {
|
|||
const resting = state.pilots
|
||||
.filter((p) => p.resting && p.restEndsAt !== null)
|
||||
.sort((a, b) => a.restEndsAt! - b.restEndsAt!);
|
||||
// Ships in the upgrade shop, soonest out first (CR-009).
|
||||
const inShop = state.ships
|
||||
.filter((s) => s.inShopUntil !== null && now < s.inShopUntil)
|
||||
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
|
||||
const nextShip = next?.tutorial
|
||||
? "ship-company-courier"
|
||||
: content.shipModel(state.ships.find((s) => s.id === next?.shipId)?.modelId ?? "")?.sprite;
|
||||
|
|
@ -135,6 +139,22 @@ export function HQ() {
|
|||
</Panel>
|
||||
)}
|
||||
|
||||
{inShop.length > 0 && (
|
||||
<Panel aria-label="In the shop" tone="amber">
|
||||
<h2 className="font-display text-sm font-semibold tracking-wide text-ink-50">
|
||||
In the shop: {inShop.length}
|
||||
</h2>
|
||||
<ul className="mt-1 space-y-0.5 text-sm text-amber-100">
|
||||
{inShop.map((ship) => (
|
||||
<li key={ship.id}>
|
||||
{ship.name} · {content.upgrade(ship.shop?.upgradeId ?? "")?.name}{" "}
|
||||
{ship.shop?.level} · ready in <Countdown to={ship.inShopUntil!} done="now" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{!state.tutorial && state.ships.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No ships yet — buy one in the Market.</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useApi } from "../api/context";
|
|||
import { serverNow, useClock } from "../clock/clock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { distanceAt, shipStatsOf } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { pairOptions } from "./DispatchSheet";
|
||||
import { DeadheadPicker } from "./Fleet";
|
||||
|
|
@ -79,7 +79,8 @@ export function carryLimit(state: PlayerState, locationId: string): number | nul
|
|||
const here = state.ships.filter((s) => s.locationId === locationId);
|
||||
const ships = here.length > 0 ? here : state.ships;
|
||||
if (ships.length === 0) return null;
|
||||
return Math.max(...ships.map((s) => content.shipModel(s.modelId)?.cargoCapacityTonnes ?? 0));
|
||||
// Effective capacity: an expanded cargo bay carries more (CR-009).
|
||||
return Math.max(...ships.map((s) => shipStatsOf(s).cargoCapacityTonnes));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { distanceAt } from "../game";
|
|||
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||
import type { MapSelection } from "../map/hitTest";
|
||||
import { MapCanvas } from "../map/MapCanvas";
|
||||
import { modelsReachingFromEarth } from "../map/reach";
|
||||
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { Art, buttonClass, Icon } from "../ui";
|
||||
|
||||
|
|
@ -96,6 +96,12 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
||||
const hasBay = state.bays.some((b) => b.locationId === locationId);
|
||||
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
|
||||
// Owned ships whose upgrades get them there when their model can't (CR-009).
|
||||
const upgraded = discovered ? [] : upgradedShipsReachingFromEarth(state.ships, locationId, now);
|
||||
const reachers = [
|
||||
...reachable.map((m) => m.name),
|
||||
...upgraded.map((s) => `your ${s.name} (upgraded)`),
|
||||
];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
|
|
@ -138,8 +144,8 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
|||
Undiscovered — deliver here to unlock.
|
||||
</p>
|
||||
<p aria-label="Reachable with" className="text-ink-200">
|
||||
{reachable.length > 0
|
||||
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
|
||||
{reachers.length > 0
|
||||
? `Reachable with: ${reachers.join(", ")}`
|
||||
: "No ship can reach it from Earth right now."}
|
||||
</p>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { content, type ShipModel } from "@deadhead/content";
|
||||
import { useState } from "react";
|
||||
import { formatCredits, formatMillionKm, locationName } from "../format";
|
||||
import { shipStatsOf } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ export function Market() {
|
|||
/>
|
||||
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
|
||||
</dl>
|
||||
<OwnedUpgraded modelId={model.id} />
|
||||
<Button
|
||||
aria-label={`Buy ${model.name}`}
|
||||
block
|
||||
|
|
@ -187,3 +189,26 @@ function DeliveryPicker(props: {
|
|||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** The player's upgraded ships of a model, with their effective stats, to compare (CR-009). */
|
||||
function OwnedUpgraded({ modelId }: { modelId: string }) {
|
||||
const state = useLoadedPlayer();
|
||||
const upgraded = state.ships.filter(
|
||||
(s) => s.modelId === modelId && Object.keys(s.upgrades).length > 0,
|
||||
);
|
||||
if (upgraded.length === 0) return null;
|
||||
return (
|
||||
<ul aria-label="Your upgraded ships" className="mt-3 space-y-1 text-xs text-cyan-200">
|
||||
{upgraded.map((ship) => {
|
||||
const s = shipStatsOf(ship);
|
||||
return (
|
||||
<li key={ship.id}>
|
||||
Your {ship.name}: {formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h ·{" "}
|
||||
{Math.round(s.cargoCapacityTonnes * 10) / 10} t ·{" "}
|
||||
{formatMillionKm(s.fuelRangeMillionKm)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import { content, upgradeMaxLevel, type ShipStat, type Upgrade } from "@deadhead/content";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import {
|
||||
effectiveStats,
|
||||
nextUpgradeLevel,
|
||||
upgradeCost,
|
||||
upgradeShopMs,
|
||||
validateUpgrade,
|
||||
type EffectiveStats,
|
||||
type UpgradeFailure,
|
||||
} from "@deadhead/sim";
|
||||
import { useState } from "react";
|
||||
import { useClock } from "../clock/clock";
|
||||
import { formatCredits, formatDuration, formatMillionKm } from "../format";
|
||||
import { shipModel } from "../game";
|
||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||
import { Button, Pill, Sheet } from "../ui";
|
||||
|
||||
/** Why an upgrade can't be started now, in plain words (decisions 0.5.64, 0.5.65). */
|
||||
export const UPGRADE_REASONS: Record<UpgradeFailure, string> = {
|
||||
"tutorial-active": "Finish the company runs first",
|
||||
"not-upgradable": "Company ship",
|
||||
"ship-not-idle": "In flight",
|
||||
"ship-in-shop": "In the shop",
|
||||
"not-at-hq": "Not at an HQ",
|
||||
"upgrade-maxed": "Maxed",
|
||||
"insufficient-funds": "Can't afford",
|
||||
};
|
||||
|
||||
/** How each upgradable stat reads, with its unit. */
|
||||
const STAT_LABELS: Record<ShipStat, { label: string; format: (s: EffectiveStats) => string }> = {
|
||||
cruiseSpeedKmPerGameHour: {
|
||||
label: "Speed",
|
||||
format: (s) => `${formatMillionKm(s.cruiseSpeedKmPerGameHour / 1e6)}/h`,
|
||||
},
|
||||
cargoCapacityTonnes: {
|
||||
label: "Capacity",
|
||||
format: (s) => `${Math.round(s.cargoCapacityTonnes * 10) / 10} t`,
|
||||
},
|
||||
fuelRangeMillionKm: { label: "Range", format: (s) => formatMillionKm(s.fuelRangeMillionKm) },
|
||||
overheadGameHours: {
|
||||
label: "Takeoff/landing",
|
||||
format: (s) => `${s.overheadGameHours.toFixed(2)} h`,
|
||||
},
|
||||
handlingGameHoursPerTonne: {
|
||||
label: "Handling",
|
||||
format: (s) => `${(s.handlingGameHoursPerTonne * 60).toFixed(2)} min/t`,
|
||||
},
|
||||
};
|
||||
|
||||
/** "Speed 2.00M km/h → 2.20M km/h": a stat now and after the next level. */
|
||||
export function upgradeEffect(ship: Ship, upgrade: Upgrade): string | null {
|
||||
const model = shipModel(ship.modelId);
|
||||
const next = nextUpgradeLevel(upgrade, model.id, ship.upgrades);
|
||||
if (next === null) return null;
|
||||
const { label, format } = STAT_LABELS[upgrade.stat];
|
||||
const before = effectiveStats(model, ship.upgrades, content);
|
||||
const after = effectiveStats(model, { ...ship.upgrades, [upgrade.id]: next }, content);
|
||||
return `${label} ${format(before)} → ${format(after)}`;
|
||||
}
|
||||
|
||||
/** The ship's detail view: its upgrades, the next level of each, and starting one (CR-009). */
|
||||
export function ShipUpgrades({ shipId, onClose }: { shipId: string; onClose: () => void }) {
|
||||
const state = useLoadedPlayer();
|
||||
// The live ship, so the sheet shows the new level as soon as state refreshes.
|
||||
const ship = state.ships.find((s) => s.id === shipId)!;
|
||||
const now = useClock((s) => s.now);
|
||||
const start = useGameAction("startUpgrade");
|
||||
const [confirming, setConfirming] = useState<Upgrade | null>(null);
|
||||
const model = shipModel(ship.modelId);
|
||||
const atHq = ship.locationId !== null && state.bays.some((b) => b.locationId === ship.locationId);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
titleId="upgrades-title"
|
||||
eyebrow={model.name}
|
||||
title={ship.name}
|
||||
subtitle="Upgrades are installed at an HQ. The ship stays in the shop until the work is done."
|
||||
>
|
||||
<h3 className="mt-4 font-display text-sm font-bold tracking-wider text-ink-300 uppercase">
|
||||
Upgrades
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{content.upgrades.map((upgrade) => {
|
||||
const level = ship.upgrades[upgrade.id] ?? 0;
|
||||
const next = nextUpgradeLevel(upgrade, model.id, ship.upgrades);
|
||||
const check = validateUpgrade(
|
||||
{
|
||||
ship: {
|
||||
id: ship.id,
|
||||
modelId: ship.modelId,
|
||||
locationId: ship.locationId ?? "",
|
||||
inFlight: ship.status === "in-flight",
|
||||
upgrades: ship.upgrades,
|
||||
inShopUntil: ship.inShopUntil,
|
||||
},
|
||||
upgrade,
|
||||
atHq,
|
||||
tutorialActive: state.tutorial !== null,
|
||||
credits: state.company.credits,
|
||||
now,
|
||||
},
|
||||
content,
|
||||
);
|
||||
const max = upgradeMaxLevel(upgrade, model.id);
|
||||
return (
|
||||
<li
|
||||
key={upgrade.id}
|
||||
aria-label={upgrade.name}
|
||||
className="rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-display font-bold tracking-wide">{upgrade.name}</span>
|
||||
<span className="text-ink-400">
|
||||
Level {level}/{max}
|
||||
</span>
|
||||
</div>
|
||||
{next !== null && (
|
||||
<div className="mt-1 text-ink-200">{upgradeEffect(ship, upgrade)}</div>
|
||||
)}
|
||||
{next !== null && (
|
||||
<div className="mt-1 text-ink-400 tabular-nums">
|
||||
{formatCredits(upgradeCost(model, next, content.config))} ·{" "}
|
||||
{formatDuration(upgradeShopMs(next, content.config))} in the shop
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
{check.ok ? (
|
||||
<Button size="sm" onClick={() => setConfirming(upgrade)}>
|
||||
Upgrade to level {next}
|
||||
</Button>
|
||||
) : (
|
||||
<Pill tone={check.reason === "upgrade-maxed" ? "emerald" : "muted"}>
|
||||
{UPGRADE_REASONS[check.reason]}
|
||||
</Pill>
|
||||
)}
|
||||
</div>
|
||||
{confirming?.id === upgrade.id && check.ok && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`Start ${upgrade.name} ${check.level}?`}
|
||||
className="mt-2 rounded-lg bg-space-950/70 p-2"
|
||||
>
|
||||
<p className="text-ink-200">
|
||||
{upgrade.name} {check.level} costs {formatCredits(check.cost)}. {ship.name}{" "}
|
||||
can't fly for {formatDuration(check.shopUntil - now)} while it's in
|
||||
the shop.
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={start.isPending}
|
||||
onClick={() =>
|
||||
start.mutate(
|
||||
{ shipId: ship.id, upgradeId: upgrade.id },
|
||||
{ onSettled: () => setConfirming(null) },
|
||||
)
|
||||
}
|
||||
>
|
||||
Start upgrade
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setConfirming(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{start.isError && (
|
||||
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||
{errorMessage(start.error)}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="secondary" block onClick={onClose} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
||||
import { gameToReal, realToGame, routeDistance } from "@deadhead/sim";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
/** Mars near closest approach in the first two game years (~82M km, decision 0.5.68). */
|
||||
const gameStart = realToGame(Date.parse(content.config.epoch.realStart), content.config);
|
||||
const NOW = gameToReal(gameStart + 23 * 30.44 * 24 * 3_600_000, content.config);
|
||||
|
||||
const courier = (upgrades: Record<string, number>): Ship => ({
|
||||
id: "s1",
|
||||
modelId: "courier",
|
||||
name: "Dusty Mule",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: "p1",
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("courier", upgrades),
|
||||
});
|
||||
const pilot: Pilot = {
|
||||
id: "p1",
|
||||
name: "Rae Okafor",
|
||||
isAvatar: false,
|
||||
xp: 0,
|
||||
level: 0,
|
||||
skills: {},
|
||||
skillPointsAvailable: 0,
|
||||
assignedShipId: "s1",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dutyHours: 0,
|
||||
dutyLimit: 12,
|
||||
resting: false,
|
||||
restEndsAt: null,
|
||||
};
|
||||
/** 22 t: one tonne more than a base Courier's 20, within a Cargo Bay 1 Courier's 23. */
|
||||
const job: Contract = {
|
||||
id: "c22",
|
||||
originId: "earth",
|
||||
destinationId: "moon",
|
||||
cargoTypeId: "standard",
|
||||
goodId: "consumer-goods",
|
||||
shipper: "Pacific Orbital Logistics",
|
||||
priority: false,
|
||||
deliveryWindowGameHours: null,
|
||||
tier: 0,
|
||||
massTonnes: 22,
|
||||
payout: 1234,
|
||||
requiredSkill: null,
|
||||
requiredLongHaulLevel: 0,
|
||||
offeredAt: NOW,
|
||||
expiresAt: NOW + 30 * 60_000,
|
||||
firstContact: false,
|
||||
};
|
||||
|
||||
const api = (upgrades: Record<string, number>) =>
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
||||
bays: [{ id: "b", locationId: "earth" }],
|
||||
ships: [courier(upgrades)],
|
||||
pilots: [pilot],
|
||||
locations: content.locations.map((l) => ({
|
||||
id: l.id,
|
||||
discovered: ["earth", "moon", "mars"].includes(l.id),
|
||||
})),
|
||||
}),
|
||||
getContractBoard: () => ({ contracts: [job], periodEndsAt: NOW + 600_000 }),
|
||||
});
|
||||
|
||||
describe("effective stats in the client (Step 10I.7)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("drops the Too heavy badge once the cargo bay is expanded", async () => {
|
||||
const { unmount } = renderApp(api({}), "/contracts?location=earth");
|
||||
expect((await screen.findByText("1,234 cr")).closest("li")!).toHaveTextContent("Too heavy");
|
||||
unmount();
|
||||
|
||||
renderApp(api({ "cargo-bay": 1 }), "/contracts?location=earth");
|
||||
expect((await screen.findByText("1,234 cr")).closest("li")!).not.toHaveTextContent("Too heavy");
|
||||
});
|
||||
|
||||
it("lets the deadhead picker reach Mars only with upgraded fuel tanks", async () => {
|
||||
const distance = routeDistance(
|
||||
content.location("earth")!,
|
||||
content.location("mars")!,
|
||||
realToGame(NOW, content.config),
|
||||
);
|
||||
expect(distance).toBeLessThan(95);
|
||||
|
||||
const open = async () => {
|
||||
const card = (await screen.findByText("Dusty Mule")).closest("li")!;
|
||||
await userEvent.click(within(card).getByRole("button", { name: /Deadhead/ }));
|
||||
return within(screen.getByRole("dialog", { name: "Deadhead Dusty Mule" })).getByRole(
|
||||
"button",
|
||||
{ name: "Mars" },
|
||||
);
|
||||
};
|
||||
const { unmount } = renderApp(api({}), "/fleet");
|
||||
expect(await open()).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
renderApp(api({ "fuel-tanks": 3 }), "/fleet");
|
||||
expect(await open()).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { content } from "@deadhead/content";
|
||||
import type { Ship } from "@deadhead/schema";
|
||||
import { effectiveStats, upgradeCost } from "@deadhead/sim";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { formatCredits, formatMillionKm } from "../src/format";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
const freighter = content.shipModel("freighter")!;
|
||||
|
||||
const ship = (id: string, name: string, overrides: Partial<Ship> = {}): Ship => ({
|
||||
id,
|
||||
name,
|
||||
modelId: "freighter",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras(overrides.modelId ?? "freighter", overrides.upgrades ?? {}),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const state = (ships: Ship[], credits = 1_000_000) =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
company: { id: "c", name: "D", credits, loanBalance: 0 },
|
||||
bays: [{ id: "b1", locationId: "earth" }],
|
||||
ships,
|
||||
pilots: [],
|
||||
});
|
||||
|
||||
async function openUpgrades(shipName: string) {
|
||||
const card = (await screen.findByText(shipName)).closest("li")!;
|
||||
await userEvent.click(within(card).getByRole("button", { name: `Upgrades for ${shipName}` }));
|
||||
return screen.getByRole("dialog", { name: shipName });
|
||||
}
|
||||
const row = (dialog: HTMLElement, name: string) => within(dialog).getByRole("listitem", { name });
|
||||
|
||||
describe("upgrade shop on the Fleet screen (Step 10I.6)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("shows each upgrade's next level as before → after, from the sim, with cost and time", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => state([ship("s1", "Slow Ox", { upgrades: { engines: 1 } })]),
|
||||
}),
|
||||
"/fleet",
|
||||
);
|
||||
const dialog = await openUpgrades("Slow Ox");
|
||||
const before = effectiveStats(freighter, { engines: 1 }, content).cruiseSpeedKmPerGameHour;
|
||||
const after = effectiveStats(freighter, { engines: 2 }, content).cruiseSpeedKmPerGameHour;
|
||||
const engines = row(dialog, "Engine Tuning");
|
||||
expect(engines).toHaveTextContent("Level 1/3");
|
||||
expect(engines).toHaveTextContent(
|
||||
`Speed ${formatMillionKm(before / 1e6)}/h → ${formatMillionKm(after / 1e6)}/h`,
|
||||
);
|
||||
expect(engines).toHaveTextContent(formatCredits(upgradeCost(freighter, 2, content.config)));
|
||||
expect(engines).toHaveTextContent("20m 00s in the shop");
|
||||
expect(row(dialog, "Cargo Bay Expansion")).toHaveTextContent("Capacity 100 t → 115 t");
|
||||
});
|
||||
|
||||
it("shows why an upgrade can't start", async () => {
|
||||
const ships = [
|
||||
ship("s-moon", "Away Ox", { locationId: "moon" }),
|
||||
ship("s-fly", "Busy Ox", { status: "in-flight", locationId: null, dispatchId: "d" }),
|
||||
ship("s-shop", "Shop Ox", { inShopUntil: NOW + 60_000 }),
|
||||
ship("s-max", "Max Ox", { upgrades: { engines: 3 } }),
|
||||
];
|
||||
renderApp(fakeApi({ getPlayerState: () => state(ships, 100) }), "/fleet");
|
||||
const reasons: [string, string, string][] = [
|
||||
["Away Ox", "Engine Tuning", "Not at an HQ"],
|
||||
["Busy Ox", "Engine Tuning", "In flight"],
|
||||
["Shop Ox", "Engine Tuning", "In the shop"],
|
||||
["Max Ox", "Engine Tuning", "Maxed"],
|
||||
["Max Ox", "Cargo Bay Expansion", "Can't afford"],
|
||||
];
|
||||
for (const [shipName, upgrade, reason] of reasons) {
|
||||
const dialog = await openUpgrades(shipName);
|
||||
expect(row(dialog, upgrade), `${shipName} ${upgrade}`).toHaveTextContent(reason);
|
||||
expect(within(row(dialog, upgrade)).queryByRole("button")).not.toBeInTheDocument();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Close" }));
|
||||
}
|
||||
});
|
||||
|
||||
it("starts an upgrade after confirming", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () => state([ship("s1", "Slow Ox")]),
|
||||
startUpgrade: ({ shipId }) => ({
|
||||
ship: ship(shipId, "Slow Ox", { upgrades: { "cargo-bay": 1 } }),
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/fleet");
|
||||
const dialog = await openUpgrades("Slow Ox");
|
||||
await userEvent.click(
|
||||
within(row(dialog, "Cargo Bay Expansion")).getByRole("button", {
|
||||
name: "Upgrade to level 1",
|
||||
}),
|
||||
);
|
||||
const confirm = within(dialog).getByRole("group", { name: "Start Cargo Bay Expansion 1?" });
|
||||
expect(confirm).toHaveTextContent("10m 00s");
|
||||
await userEvent.click(within(confirm).getByRole("button", { name: "Start upgrade" }));
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("startUpgrade", {
|
||||
shipId: "s1",
|
||||
upgradeId: "cargo-bay",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a ship in the shop with a countdown instead of its actions", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () => state([ship("s1", "Slow Ox", { inShopUntil: NOW + 7.5 * 60_000 })]),
|
||||
}),
|
||||
"/fleet",
|
||||
);
|
||||
const card = (await screen.findByText("Slow Ox")).closest("li")!;
|
||||
expect(within(card).getByLabelText("Slow Ox shop")).toHaveTextContent("In the shop — 07:30");
|
||||
expect(within(card).queryByRole("button", { name: /Deadhead/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import type { Ship } from "@deadhead/schema";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useClock } from "../src/clock/clock";
|
||||
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
||||
|
||||
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||
|
||||
const ship = (id: string, name: string, overrides: Partial<Ship>): Ship => ({
|
||||
id,
|
||||
name,
|
||||
modelId: "freighter",
|
||||
status: "idle",
|
||||
locationId: "earth",
|
||||
dispatchId: null,
|
||||
pilotId: null,
|
||||
lastArrivalAt: 0,
|
||||
...shipExtras("freighter"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("the shop in summaries and on HQ (Step 10I.8)", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||
vi.setSystemTime(NOW);
|
||||
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("says a ship is out of the shop for an upgrade that finished, and acknowledges it", async () => {
|
||||
const api = fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [
|
||||
ship("s1", "Slow Ox", {
|
||||
upgrades: { engines: 2 },
|
||||
shop: { upgradeId: "engines", level: 2, until: NOW - 60_000 },
|
||||
}),
|
||||
ship("s2", "Busy Ox", {
|
||||
upgrades: { "cargo-bay": 1 },
|
||||
inShopUntil: NOW + 60_000,
|
||||
shop: { upgradeId: "cargo-bay", level: 1, until: NOW + 60_000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
renderApp(api, "/");
|
||||
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
|
||||
expect(dialog).toHaveTextContent("Slow Ox is out of the shop: Engine Tuning 2");
|
||||
expect(dialog).not.toHaveTextContent("Busy Ox");
|
||||
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Got it" }));
|
||||
await waitFor(() =>
|
||||
expect(api.call).toHaveBeenCalledWith("acknowledgeSummaries", {
|
||||
dispatchIds: [],
|
||||
shipIds: ["s1"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lists ships in the shop on HQ, soonest first, with countdowns", async () => {
|
||||
renderApp(
|
||||
fakeApi({
|
||||
getPlayerState: () =>
|
||||
samplePlayerState({
|
||||
tutorial: null,
|
||||
ships: [
|
||||
ship("s1", "Late Ox", {
|
||||
inShopUntil: NOW + 25 * 60_000,
|
||||
shop: { upgradeId: "engines", level: 2, until: NOW + 25 * 60_000 },
|
||||
}),
|
||||
ship("s2", "Soon Ox", {
|
||||
inShopUntil: NOW + 4 * 60_000,
|
||||
shop: { upgradeId: "cargo-bay", level: 1, until: NOW + 4 * 60_000 },
|
||||
}),
|
||||
ship("s3", "Free Ox", {}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
"/",
|
||||
);
|
||||
const card = await screen.findByLabelText("In the shop");
|
||||
expect(card).toHaveTextContent("In the shop: 2");
|
||||
const [first, second] = within(card).getAllByRole("listitem");
|
||||
expect(first).toHaveTextContent("Soon Ox · Cargo Bay Expansion 1 · ready in 4m 00s");
|
||||
expect(second).toHaveTextContent("Late Ox · Engine Tuning 2 · ready in 25m 00s");
|
||||
expect(card).not.toHaveTextContent("Free Ox");
|
||||
});
|
||||
});
|
||||
|
|
@ -168,6 +168,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- GameConfig fields: time compression factor; game epoch as two fields (real start timestamp and the in-game ephemeris date it maps to); starting credits; tutorial payout share; contract board size; contract board period (real minutes — contracts expire at the end of their period); first-contact chance; first-contact payout multiplier; contract mass range; Long Haul distance cut-offs (one per level); XP per delivery formula inputs; XP needed per pilot level; skill points per level; hire cost per pilot; pilot pay per delivery (flat fee).
|
||||
- **Test:** Unit tests confirm each schema accepts a valid example and rejects an object missing a required field.
|
||||
- **Amended by CR-004:** GameConfig gains `dutyLimitGameHours`, `longHaulDutyBonusGameHours` and `restGameHours` (Step 10D.1).
|
||||
- **Amended by CR-009:** Content gains the Upgrade schema (`upgradeSchema`, `SHIP_STATS`, per-model overrides), the five upgrades, and `upgradeLevels` cost/shop-time config (Step 10I.1).
|
||||
|
||||
### Step 2.2 — Add the five locations
|
||||
- Add data for Earth, Moon, Mars, Ceres Station, and Jupiter Cloud Platform following the destinations table in the GDD.
|
||||
|
|
@ -224,6 +225,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- Add a function: travel time = distance ÷ ship cruise speed + ship takeoff/landing overhead. Distance is measured at departure time (no mid-flight recalculation).
|
||||
- **Test:** For fixed inputs, the result matches a hand-calculated value. A faster ship always arrives sooner on the same route. Overhead dominates on Earth→Moon (overhead is more than half of total time for the Courier).
|
||||
- **Amended by CR-001:** Travel time also includes cargo handling: + mass × handling time per tonne (Step 10A.2, decision 0.5.31).
|
||||
- **Amended by CR-009:** Travel time takes effective stats, including the ship's own handling time per tonne (Step 10I.2).
|
||||
|
||||
### Step 3.6 — Fuel cost
|
||||
- Add a function: fuel cost = distance × ship fuel cost per million km × origin location's launch multiplier.
|
||||
|
|
@ -232,6 +234,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
### Step 3.7 — Range and landing eligibility
|
||||
- Add a function that decides whether a ship can fly a route at a given time, returning either "allowed" or a specific reason: out of range, or cannot land at destination (landing class).
|
||||
- **Test:** A Courier can fly Earth→Moon and Moon→Earth. A Courier is out of range for Mars at its closest. A test-only vacuum ship fixture is rejected at Earth with the cannot-land reason. At Jupiter's farthest point from Earth, only the Bulk Hauler is in range. A Freighter can reach Ceres. (These confirm the Step 2.3 number choices; adjust content if they fail.)
|
||||
- **Amended by CR-009:** Range checks use effective stats (Step 10I.2).
|
||||
|
||||
### Step 3.8 — Contract model and payout
|
||||
- Define a Contract shape: id, origin, destination, cargo type, tier, mass, payout, required skill and level, required Long Haul level, offered-at time, expires-at time (end of its board period), first-contact flag.
|
||||
|
|
@ -264,6 +267,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- **Test:** One test per failure reason, each constructing a scenario that triggers exactly that reason. One test for a fully valid dispatch.
|
||||
- **Amended by CR-004:** Validation (contract and deadhead) gains `pilot-resting`, checked before the other pilot checks (Step 10D.3).
|
||||
- **Amended by CR-007:** Validation gains `too-slow`: a Priority contract's trip must fit its delivery window (Step 10G.4).
|
||||
- **Amended by CR-009:** Validation uses effective stats (capacity, range, Priority window) and gains `ship-in-shop` (Steps 10I.2, 10I.3).
|
||||
|
||||
### Step 3.13 — Dispatch record creation
|
||||
- Add a function that turns a validated dispatch into a Dispatch record: id, ship, pilot, contract (or none for reposition), origin, destination, departure real time, arrival real time (computed from travel time and clock conversion), fuel cost, expected payout, seed.
|
||||
|
|
@ -323,6 +327,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- **Test:** Running migrations against the empty test database succeeds; running them a second time is a no-op.
|
||||
- **Amended by CR-002:** `contract_boards` is keyed by player, kind (`location` / `tutorial`), location and period (plus tutorial run); one board per player, location and period (Step 10B.3).
|
||||
- **Amended by CR-004:** The pilots table gains `duty_hours` and `rest_ends_at` (Step 10D.4).
|
||||
- **Amended by CR-009:** `ship_upgrades` table and, on ships, in-shop-until and the latest shop job (Step 10I.4).
|
||||
|
||||
### Step 5.4 — Test database harness
|
||||
- Add a test helper that resets the test database between integration tests and can seed a player in a given state (tutorial, owner with one ship, etc.).
|
||||
|
|
@ -484,11 +489,13 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- **Test:** Component test: an eligible pair shows correct numbers from the sim; an ineligible pair shows the correct reason; confirming calls dispatch; a server error message is displayed.
|
||||
- **Amended by CR-004:** The dispatch sheet disables resting pilots with a countdown and shows duty after the trip, warning "Will need rest on arrival" (Step 10D.7).
|
||||
- **Amended by CR-007:** For a Priority contract each pair shows its trip against the window; too-slow pairs are disabled with the numbers (Step 10G.9).
|
||||
- **Amended by CR-009:** The dispatch sheet uses effective stats and lists "In the shop — mm:ss" for ships in the shop (Step 10I.7).
|
||||
|
||||
### Step 9.6 — Fleet screen
|
||||
- List all ships: name, model, location or route, assigned pilot, and for in-flight ships a progress bar and countdown. Idle ships at a bay location have a "Reposition" action to another bay location.
|
||||
- **Test:** Component test: an in-flight ship's progress bar matches sim progress for the mocked time. Reposition opens a destination picker listing only bay locations.
|
||||
- **Amended by CR-002:** "Reposition" becomes "Deadhead (fly empty)" for every idle ship, with fuel, time and disabled-with-reason destinations (Step 10B.8).
|
||||
- **Amended by CR-009:** Fleet cards open the upgrade shop and show "In the shop — mm:ss"; the deadhead picker uses effective stats (Steps 10I.6, 10I.7).
|
||||
|
||||
### Step 9.7 — Pilots screen
|
||||
- List pilots: name, level, XP progress to next level, skill levels, available skill points, assignment, status. Actions: hire pilot (shows cost), assign to ship, spend skill point.
|
||||
|
|
@ -499,6 +506,7 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
|
|||
- List the three ship models with stats (speed, capacity, range, landing class, price). "Buy" is disabled with insufficient credits.
|
||||
- **Test:** Component test: buy is disabled when credits are below price; buying calls the endpoint and the new ship appears in Fleet.
|
||||
- **Amended by CR-002:** Buy opens a "Deliver to which HQ?" bay picker, preselected with one bay (Step 10B.9).
|
||||
- **Amended by CR-009:** The Market lists the player's upgraded ships of each model with effective stats (Step 10I.7).
|
||||
|
||||
### Step 9.9 — Locations screen
|
||||
- List all locations. Discovered ones show type, landing requirement, current distance from Earth, produces/demands, and a "Lease bay" action with price (or "Bay owned"). Undiscovered ones show only the name and "Undiscovered — deliver here to unlock."
|
||||
|
|
@ -609,6 +617,7 @@ Steps 10I.1–10I.9 are specified in `memory-bank/change-requests/CR-009-ship-up
|
|||
- **Amended by CR-002:** The simulated player picks up contracts at whatever location its ships are at, and only deadheads when no eligible contract exists there. Add a check: across the whole run, no ship is ever idle with no eligible contract **and** no reachable deadhead destination.
|
||||
- **Amended by CR-004:** The simulated player follows rest rules: it rests a pilot when no eligible contract exists at its location and deadheading isn't better. Re-check all CR-001 timing targets with rest in place; adjust content values, not thresholds. Add a check that no pilot is ever dispatched while resting.
|
||||
- **Amended by CR-007:** The simulated player considers Priority contracts and skips ones its ships are too slow for. Re-check all timing targets; adjust content values, not thresholds.
|
||||
- **Amended by CR-009:** The simulated player may buy upgrades: when it can't yet afford its next planned ship, it buys the cheapest available upgrade on its most-used ship, preferring cargo bay and engines. Re-check all timing targets; adjust content values, not thresholds.
|
||||
|
||||
### Step 12.2 — Revisit the tutorial economy test
|
||||
- Replace the placeholder average in the Step 2.5 test with the average tutorial payout measured by the balance script.
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ Spec: `memory-bank/change-requests/CR-009-ship-upgrades.md` (received as "CR-006
|
|||
- [x] **Step 10I.3 — Sim: upgrade validation and cost.** `upgradeCost(model, level)` (whole credits, % of price), `upgradeShopMs(level)`, `nextUpgradeLevel`, `isInShop(ship, now)` and `validateUpgrade` with `UPGRADE_FAILURES` in check order: `tutorial-active`, `not-upgradable`, `ship-not-idle`, `ship-in-shop`, `not-at-hq`, `upgrade-maxed`, `insufficient-funds`; success returns the level, cost and in-shop-until. `ShipState.inShopUntil`; dispatch and deadhead validation fail `ship-in-shop` right after `ship-not-idle`. Test: `upgrade-validation.test.ts`.
|
||||
- [x] **Step 10I.4 — Schema and database.** Migration `0006_cr009_ship_upgrades`: `ship_upgrades` (ship, upgrade, level; PK ship+upgrade), and on `ships` `in_shop_until`, `shop_upgrade_id`, `shop_level`. API `Ship` gains `upgrades`, `stats` (effective), `inShopUntil`, `shop`; one server mapper `toApiShip` (player state and purchases). Route `startUpgrade` (`POST /api/ships/:shipId/upgrades`). Upgrade failure reasons join the API error codes. `acknowledgeSummaries` takes optional `shipIds`. Web test helper `shipExtras(modelId)`. Dev database migrated.
|
||||
- [x] **Step 10I.5 — Server: start upgrade.** `startUpgrade` (`game/upgrades.ts`) runs inside `withPlayer` (company row lock): sim `validateUpgrade` (at an HQ = a bay where the ship is), charge, upsert `ship_upgrades`, set in-shop-until and the shop job; returns the ship. Unknown upgrade → `not-found`. Dispatch and deadhead pass the ship's upgrades and in-shop time to the sim (effective stats, `ship-in-shop`); fleet-fitted boards use effective capacity. Test: `upgrades.test.ts` (engines on a Freighter: cost, level, shop time, refused then faster trip; `not-at-hq` at the Moon; two simultaneous upgrades → one).
|
||||
- [ ] Step 10I.6 — Client: upgrade shop
|
||||
- [ ] Step 10I.7 — Client: effective stats everywhere
|
||||
- [ ] Step 10I.8 — Client: shop in summaries and on HQ
|
||||
- [ ] Step 10I.9 — Plan amendments and regression
|
||||
- [x] **Step 10I.6 — Client: upgrade shop.** Every Fleet card has "Upgrades" (with the count installed), opening `ShipUpgrades` (`screens/ShipUpgrades.tsx`): each of the five upgrades with level n/max, the next level as before → after from the sim's `effectiveStats` (e.g. "Speed 2.20M km/h → 2.40M km/h", "Capacity 100 t → 115 t"), cost and shop time; disabled ones show the sim's reason (Not at an HQ, In flight, In the shop, Maxed, Can't afford, Company ship, Finish the company runs first). Upgrading asks for confirmation, then calls `startUpgrade`. Ships in the shop show an "In the shop" pill and "In the shop — mm:ss" instead of their actions. Test: `fleet-upgrades.test.tsx`.
|
||||
- [x] **Step 10I.7 — Client: effective stats everywhere.** `game.ts` `shipStatsOf(ship)` (sim `effectiveStats` from model + upgrades) and `simShip(ship)` (sim `ShipState` with upgrades and in-shop-until). Used by: the board's Too heavy badge (`carryLimit`), Too slow (`tooSlowForAll`), the dispatch sheet (problems, trip time, Priority window, validation; an in-shop ship lists "In the shop — mm:ss" first), and the deadhead picker. The Market lists your upgraded ships of each model with their effective speed, capacity and range; the map's "Reachable with" adds "your [ship] (upgraded)" when only upgrades get it there. Test: `effective-stats-ui.test.tsx` (Cargo Bay 1 clears Too heavy on a 22 t job; Fuel Tanks 3 enables Mars in the deadhead picker near closest approach).
|
||||
- [x] **Step 10I.8 — Client: shop in summaries and on HQ.** The "While you were away" modal also opens for finished, unseen upgrades and lists "[Ship] is out of the shop: Engine Tuning 2"; "Got it" sends their `shipIds` with `acknowledgeSummaries`, and the server (`acknowledgeFinishedUpgrades`) clears only jobs whose shop time has passed. HQ has an "In the shop" card (when any) listing each ship, its upgrade and level, and a countdown, soonest first. Tests: `shop-summary-hq.test.tsx`, and the acknowledge case in `upgrades.test.ts`.
|
||||
- [x] **Step 10I.9 — Plan amendments and regression.** CR-009 §6 amendments added to plan steps 2.1, 3.5, 3.7, 3.12, 5.3, 9.5, 9.6, 9.8 and 12.1 (§0.5.63–68 were added with the CR). Typecheck, lint, full suite with coverage and the web build are green. **GDD §8 not yet updated:** the CR says the designer updates it; it still describes ship stats with no upgrades, so there is nothing to confirm yet. Scripted manual check against a live server (scratchpad `cr009-smoke.mjs`): after the deal (899 cr), Cargo Bay Expansion 1 on the Courier costs 400 cr, capacity 20 → 23 t, 10 minutes in the shop; dispatch in the shop is refused with `ship-in-shop`; afterwards the Courier takes a 22 t job. **Gameplay note:** shop time (10 min) equals the board period, so a job seen before an upgrade has always expired when the ship comes out. **Phase 10I complete — stopped for review.**
|
||||
|
|
|
|||
Loading…
Reference in New Issue