Step 20.2: HQs screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a16c34ffc3
commit
fec2c481df
|
|
@ -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 (
|
||||
<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'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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 <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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue