From fec2c481df80a0bae058e75402d921568732b266 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 22:24:20 -0400 Subject: [PATCH] Step 20.2: HQs screen Co-Authored-By: Claude Opus 5.5 --- apps/web/src/screens/HQs.tsx | 150 +++++++++++++++++++++++++++++- apps/web/test/hqs-screen.test.tsx | 112 ++++++++++++++++++++++ memory-bank/progress.md | 1 + 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 apps/web/test/hqs-screen.test.tsx diff --git a/apps/web/src/screens/HQs.tsx b/apps/web/src/screens/HQs.tsx index 4f00538..799153f 100644 --- a/apps/web/src/screens/HQs.tsx +++ b/apps/web/src/screens/HQs.tsx @@ -1,10 +1,156 @@ -import { Screen, ScreenHeader } from "../ui"; +import { content } from "@deadhead/content"; +import type { Bay } from "@deadhead/schema"; +import { Link } from "react-router"; +import { useClock } from "../clock/clock"; +import { Countdown } from "../components/Countdown"; +import { FuelPrice } from "../components/Fuel"; +import { locationName, shipModelName } from "../format"; +import { useLoadedPlayer } from "../state/actions"; +import { Art, buttonClass, Eyebrow, Icon, Panel, Pill, Screen, ScreenHeader } from "../ui"; + +/** What owning an HQ allows (CR-002: bays are HQs; CR-020). */ +export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here."; /** The player's HQs (bays): information only (CR-020, decision 0.5.121). */ export function HQs() { + const state = useLoadedPlayer(); return ( - + 0 ? HQ_ALLOWS : undefined} + /> + {state.bays.length === 0 ? ( + +

You don't have an HQ yet.

+

+ An HQ lets you hire pilots and take delivery of new ships at its location. +

+ + + Lease one in Places + +
+ ) : ( +
    + {state.bays.map((bay) => ( + + ))} +
+ )}
); } + +function HqCard({ bay }: { bay: Bay }) { + const state = useLoadedPlayer(); + const now = useClock((s) => s.now); + const location = content.location(bay.locationId); + const name = locationName(bay.locationId); + const shipsHere = state.ships.filter((s) => s.locationId === bay.locationId); + const inbound = state.activeDispatches + .filter((d) => d.destinationId === bay.locationId && !d.tutorial) + .sort((a, b) => a.arrivalAt - b.arrivalAt); + const spare = state.pilots.filter( + (p) => p.assignedShipId === null && p.status === "idle" && p.locationId === bay.locationId, + ); + + return ( + +
+ {location && ( + + )} +
+

{name}

+

+ Fuel: +

+
+
+ +
+ Ships here + {shipsHere.length === 0 ? ( +

None right now.

+ ) : ( +
    + {shipsHere.map((ship) => { + const inShop = ship.inShopUntil !== null && now < ship.inShopUntil; + return ( +
  • + + {ship.name}{" "} + {shipModelName(ship.modelId)} + + {inShop ? ( + In the shop + ) : ( + Idle + )} +
  • + ); + })} +
+ )} +
+ + {inbound.length > 0 && ( +
+ Inbound +
    + {inbound.map((d) => ( +
  • + + {state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "} + {locationName(d.originId)} + + + + +
  • + ))} +
+
+ )} + +
+ Pilots without a ship + {spare.length === 0 ? ( +

None.

+ ) : ( +
    + {spare.map((p) => ( +
  • + {p.name} + + Level {p.level} + {p.resting && " · resting"} + +
  • + ))} +
+ )} +
+ +
+ + + View contracts + + + + Hire pilots + +
+
+ ); +} diff --git a/apps/web/test/hqs-screen.test.tsx b/apps/web/test/hqs-screen.test.tsx new file mode 100644 index 0000000..e396a6c --- /dev/null +++ b/apps/web/test/hqs-screen.test.tsx @@ -0,0 +1,112 @@ +import type { Dispatch, Pilot, Ship } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { fuelPriceOf, formatFuelPrice } from "../src/components/Fuel"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const ship = (id: string, name: string, overrides: Partial = {}): Ship => ({ + id, + modelId: "courier", + name, + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + ...shipExtras("courier"), + pilotId: null, + ...overrides, +}); + +const avatar = samplePlayerState().pilots[0]!; +const spare: Pilot = { ...avatar, id: "p2", name: "Rae Okafor", isAvatar: false, level: 2 }; + +const inbound: Dispatch = { + id: "d1", + shipId: "s-fly", + pilotId: "avatar", + contract: null, + originId: "moon", + destinationId: "earth", + tutorial: false, + departureAt: Date.now() - 60_000, + arrivalAt: Date.now() + 180_000, + distanceMillionKm: 0.39, + fuelCost: 50, + fuelBurnedFu: 0, + expectedPayout: 0, + seed: 1, + status: "in-flight", + resolvedAt: null, +}; + +function render(bays: { id: string; locationId: string }[]) { + renderApp( + fakeApi({ + getPlayerState: () => + samplePlayerState({ + tutorial: null, + bays, + ships: [ + ship("s1", "Dusty Mule"), + ship("s2", "Moon Moth", { locationId: "moon" }), + ship("s-fly", "Swift Heron", { + status: "in-flight", + locationId: null, + dispatchId: "d1", + }), + ], + pilots: [ + { ...avatar, assignedShipId: "s-fly", status: "in-flight", locationId: null }, + spare, + ], + activeDispatches: [inbound], + }), + }), + "/hqs", + ); +} + +describe("HQs screen (CR-020)", () => { + it("shows each HQ's ships, inbound flights, spare pilots and fuel price", async () => { + render([{ id: "b1", locationId: "earth" }]); + const hq = await screen.findByRole("listitem", { name: "HQ at Earth" }); + expect(hq).toHaveTextContent(`Fuel: ${formatFuelPrice(fuelPriceOf("earth"))}`); + const ships = within(hq).getByRole("region", { name: "Ships at Earth" }); + expect(ships).toHaveTextContent("Dusty Mule"); + expect(ships).not.toHaveTextContent("Moon Moth"); + expect(within(hq).getByRole("region", { name: "Inbound to Earth" })).toHaveTextContent( + "Swift Heron from Moon", + ); + expect(within(hq).getByRole("region", { name: "Pilots at Earth" })).toHaveTextContent( + "Rae OkaforLevel 2", + ); + expect(within(hq).getByRole("link", { name: "View contracts" })).toHaveAttribute( + "href", + "/contracts?location=earth", + ); + expect(within(hq).getByRole("link", { name: "Hire pilots" })).toHaveAttribute( + "href", + "/pilots", + ); + }); + + it("lists one card per HQ", async () => { + render([ + { id: "b1", locationId: "earth" }, + { id: "b2", locationId: "moon" }, + ]); + expect(await screen.findByRole("listitem", { name: "HQ at Moon" })).toHaveTextContent( + "Moon Moth", + ); + expect(screen.getAllByRole("listitem", { name: /^HQ at / })).toHaveLength(2); + }); + + it("points to Places when the player has no HQ", async () => { + render([]); + expect(await screen.findByText("You don't have an HQ yet.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Lease one in Places" })).toHaveAttribute( + "href", + "/locations", + ); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index abbc96e..998813b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -452,3 +452,4 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C Spec: `memory-bank/change-requests/CR-020-bridge-dashboard.md`. New decisions recorded as plan §0.5.118–0.5.121. - [x] **Step 20.1 — Navigation: Bridge and HQs.** `SCREENS`: `/` is now "Dashboard" with the tab label "Bridge" and a new `helm` icon; `/hqs` ("HQs", HQ icon) sits after Pilots, with a first `HQs` screen (header only until 20.2). The bottom bar is eight columns; labels tighten their tracking below `sm` so "PILOTS"/"PLACES" fit about 48 px. The goals badge stays on `/`. The HQ screen's heading is "Dashboard"; the away summary's "Claim on HQ" is "Claim on the Bridge" (tests that looked for the "HQ" heading updated). Test: `layout.test.tsx` (eight links, Bridge first and current on launch, HQs at `/hqs`; every screen's heading at its path). +- [x] **Step 20.2 — HQs screen.** `screens/HQs.tsx`: one emerald feature card per bay (labelled "HQ at "): location art and name, fuel price there (cheapest highlighted), ships at the location with Idle / In the shop, flights inbound to it with countdowns, unassigned pilots there (level, resting), and links to the location's contracts and to Pilots for hiring; the header says what an HQ allows (`HQ_ALLOWS`). No bays: a short explanation and "Lease one in Places". Leasing stays on Places. Test: `hqs-screen.test.tsx` (an HQ's ships, inbound flight, spare pilot, fuel price and links; ships elsewhere not listed; one card per HQ; empty state).