Step 20.2: HQs screen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 22:24:20 -04:00
parent a16c34ffc3
commit fec2c481df
3 changed files with 261 additions and 2 deletions

View File

@ -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). */ /** The player's HQs (bays): information only (CR-020, decision 0.5.121). */
export function HQs() { export function HQs() {
const state = useLoadedPlayer();
return ( return (
<Screen className="max-w-4xl"> <Screen className="max-w-4xl">
<ScreenHeader title="HQs" eyebrow="Your bases" icon="hq" /> <ScreenHeader
title="HQs"
eyebrow="Your bases"
icon="hq"
subtitle={state.bays.length > 0 ? HQ_ALLOWS : undefined}
/>
{state.bays.length === 0 ? (
<Panel tone="muted" className="space-y-3 text-sm">
<p className="text-ink-200">You don&apos;t have an HQ yet.</p>
<p className="text-ink-400">
An HQ lets you hire pilots and take delivery of new ships at its location.
</p>
<Link to="/locations" className={buttonClass("primary", "sm")}>
<Icon name="places" className="size-4" />
Lease one in Places
</Link>
</Panel>
) : (
<ul className="grid gap-4 md:grid-cols-2">
{state.bays.map((bay) => (
<HqCard key={bay.id} bay={bay} />
))}
</ul>
)}
</Screen> </Screen>
); );
} }
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 (
<Panel as="li" feature tone="emerald" aria-label={`HQ at ${name}`} className="text-sm">
<div className="flex items-center gap-4">
{location && (
<Art
sprite={location.sprite}
className="size-16 shrink-0 drop-shadow-[0_0_14px_rgb(125_211_252/0.35)]"
/>
)}
<div className="min-w-0 flex-1">
<h2 className="font-display text-xl font-bold tracking-wide">{name}</h2>
<p className="text-ink-300">
Fuel: <FuelPrice locationId={bay.locationId} />
</p>
</div>
</div>
<section aria-label={`Ships at ${name}`} className="mt-4">
<Eyebrow>Ships here</Eyebrow>
{shipsHere.length === 0 ? (
<p className="mt-1 text-ink-400">None right now.</p>
) : (
<ul className="mt-1 space-y-1">
{shipsHere.map((ship) => {
const inShop = ship.inShopUntil !== null && now < ship.inShopUntil;
return (
<li key={ship.id} className="flex items-center justify-between gap-2">
<span>
<span className="font-semibold text-ink-50">{ship.name}</span>{" "}
<span className="text-ink-400">{shipModelName(ship.modelId)}</span>
</span>
{inShop ? (
<Pill tone="amber">In the shop</Pill>
) : (
<Pill tone="emerald">Idle</Pill>
)}
</li>
);
})}
</ul>
)}
</section>
{inbound.length > 0 && (
<section aria-label={`Inbound to ${name}`} className="mt-3">
<Eyebrow>Inbound</Eyebrow>
<ul className="mt-1 space-y-1">
{inbound.map((d) => (
<li key={d.id} className="flex items-center justify-between gap-2">
<span className="text-ink-200">
{state.ships.find((s) => s.id === d.shipId)?.name ?? "A ship"} from{" "}
{locationName(d.originId)}
</span>
<span className="font-display text-cyan-200">
<Countdown to={d.arrivalAt} />
</span>
</li>
))}
</ul>
</section>
)}
<section aria-label={`Pilots at ${name}`} className="mt-3">
<Eyebrow>Pilots without a ship</Eyebrow>
{spare.length === 0 ? (
<p className="mt-1 text-ink-400">None.</p>
) : (
<ul className="mt-1 space-y-1">
{spare.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-2">
<span className="font-semibold text-ink-50">{p.name}</span>
<span className="text-ink-400">
Level {p.level}
{p.resting && " · resting"}
</span>
</li>
))}
</ul>
)}
</section>
<div className="mt-4 flex flex-wrap gap-2">
<Link
to={`/contracts?location=${bay.locationId}`}
className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}
>
<Icon name="jobs" className="size-4" />
View contracts
</Link>
<Link to="/pilots" className={`${buttonClass("secondary", "sm")} flex-1 basis-36`}>
<Icon name="hire" className="size-4" />
Hire pilots
</Link>
</div>
</Panel>
);
}

View File

@ -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> = {}): 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",
);
});
});

View File

@ -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. 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.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 <place>"): 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).