Step 10I.6: upgrade shop on the Fleet screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 19:34:31 -04:00
parent f81a8e41ef
commit e205194758
5 changed files with 346 additions and 3 deletions

View File

@ -963,3 +963,10 @@ 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 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. - **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`. - **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.

View File

@ -11,7 +11,8 @@ import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useClock } from "../clock/clock"; import { useClock } from "../clock/clock";
import { Countdown } from "../components/Countdown"; import { Countdown } from "../components/Countdown";
import { formatCredits, formatDuration, locationName, shipModelName } from "../format"; import { ShipUpgrades } from "./ShipUpgrades";
import { formatClock, formatCredits, formatDuration, locationName, shipModelName } from "../format";
import { location, shipModel, tripRealMs } from "../game"; import { location, shipModel, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { import {
@ -31,6 +32,7 @@ import {
export function Fleet() { export function Fleet() {
const state = useLoadedPlayer(); const state = useLoadedPlayer();
const [deadheading, setDeadheading] = useState<Ship | null>(null); 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; 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)} dispatch={state.activeDispatches.find((d) => d.id === ship.dispatchId)}
pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null} pilotName={state.pilots.find((p) => p.id === ship.pilotId)?.name ?? null}
onDeadhead={() => setDeadheading(ship)} onDeadhead={() => setDeadheading(ship)}
onUpgrades={() => setUpgrading(ship.id)}
/> />
))} ))}
</ul> </ul>
)} )}
{deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />} {deadheading && <DeadheadPicker ship={deadheading} onClose={() => setDeadheading(null)} />}
{upgrading && <ShipUpgrades shipId={upgrading} onClose={() => setUpgrading(null)} />}
</Screen> </Screen>
); );
} }
@ -79,11 +83,14 @@ function ShipRow(props: {
dispatch: Dispatch | undefined; dispatch: Dispatch | undefined;
pilotName: string | null; pilotName: string | null;
onDeadhead: () => void; onDeadhead: () => void;
onUpgrades: () => void;
}) { }) {
const { ship, dispatch, pilotName } = props; const { ship, dispatch, pilotName } = props;
const now = useClock((s) => s.now); const now = useClock((s) => s.now);
const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0; const progress = dispatch ? Math.round(dispatchProgress(dispatch, now) * 100) : 0;
const model = shipModel(ship.modelId); 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 ( return (
<Panel as="li" tone={dispatch ? "cyan" : "default"} className="flex flex-col gap-3"> <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"> <Pill tone="cyan" icon="send">
In flight In flight
</Pill> </Pill>
) : inShop ? (
<Pill tone="amber">In the shop</Pill>
) : ( ) : (
<Pill tone="emerald">Idle</Pill> <Pill tone="emerald">Idle</Pill>
)} )}
@ -133,7 +142,12 @@ function ShipRow(props: {
</div> </div>
</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"> <div className="mt-auto flex flex-wrap gap-2">
{ship.locationId && ( {ship.locationId && (
<Link <Link
@ -155,6 +169,16 @@ function ShipRow(props: {
</Button> </Button>
</div> </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> </Panel>
); );
} }

View File

@ -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&apos;t fly for {formatDuration(check.shopUntil - now)} while it&apos;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>
);
}

View File

@ -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();
});
});

View File

@ -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.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.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). - [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 - [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`.
- [ ] Step 10I.7 — Client: effective stats everywhere - [ ] Step 10I.7 — Client: effective stats everywhere
- [ ] Step 10I.8 — Client: shop in summaries and on HQ - [ ] Step 10I.8 — Client: shop in summaries and on HQ
- [ ] Step 10I.9 — Plan amendments and regression - [ ] Step 10I.9 — Plan amendments and regression