From 894597237283aea386d1900f1bb31f88ff927776 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 17:17:21 -0400 Subject: [PATCH] Step 10G.8: contract cards headline the good, with class, Priority and too-slow chips Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Goods.tsx | 77 +++++++++++++++++++++ apps/web/src/screens/LocationBoards.tsx | 51 ++++++++------ apps/web/src/screens/TutorialBoard.tsx | 5 +- apps/web/test/contract-card.test.tsx | 91 +++++++++++++++++++++++++ apps/web/test/location-boards.test.tsx | 10 ++- memory-bank/progress.md | 1 + 6 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/Goods.tsx create mode 100644 apps/web/test/contract-card.test.tsx diff --git a/apps/web/src/components/Goods.tsx b/apps/web/src/components/Goods.tsx new file mode 100644 index 0000000..ecf5677 --- /dev/null +++ b/apps/web/src/components/Goods.tsx @@ -0,0 +1,77 @@ +import { content } from "@deadhead/content"; +import type { Contract, PlayerState } from "@deadhead/schema"; +import { contractTripGameHours, MS_PER_HOUR } from "@deadhead/sim"; +import { useState } from "react"; +import { cargoName, formatDuration } from "../format"; +import { Pill, type Tone } from "../ui"; + +/** The good's name as shown on a contract: a Priority good may have its own name (CR-007). */ +export function goodName(contract: Pick): string { + const good = content.good(contract.goodId); + if (!good) return contract.goodId; + return contract.priority && good.priorityName ? good.priorityName : good.name; +} + +/** A delivery window in game hours, e.g. "5h" or "2.5h". */ +export function formatWindow(hours: number): string { + const rounded = hours < 10 ? Math.round(hours * 10) / 10 : Math.round(hours); + return `${rounded}h`; +} + +/** Real time for a span of game hours, e.g. "5m 12s". */ +export function realTimeOf(gameHours: number): string { + return formatDuration((gameHours * MS_PER_HOUR) / content.config.timeCompression); +} + +const CLASS_TONES: Record = { + heavy: "cyan", + "high-value": "emerald", + dangerous: "rose", +}; + +/** "HIGH-VALUE · T1": the cargo class, with the tier when it has one. */ +export function ClassChip({ contract }: { contract: Pick }) { + return ( + + {cargoName(contract.cargoTypeId)} + {contract.tier > 0 && ` · T${contract.tier}`} + + ); +} + +/** + * "PRIORITY · within 5h" (game hours). Tap to switch to real time; hovering + * shows both. + */ +export function PriorityChip({ windowHours }: { windowHours: number }) { + const [real, setReal] = useState(false); + return ( + + ); +} + +/** + * True when a Priority contract has idle ships at its origin and none of them + * can make the window (CR-007). Display only; the sim decides at dispatch. + */ +export function tooSlowForAll(state: PlayerState, contract: Contract, now: number): boolean { + 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! + ); + }); +} diff --git a/apps/web/src/screens/LocationBoards.tsx b/apps/web/src/screens/LocationBoards.tsx index a5c5d1d..a67d00e 100644 --- a/apps/web/src/screens/LocationBoards.tsx +++ b/apps/web/src/screens/LocationBoards.tsx @@ -5,14 +5,7 @@ import { useSearchParams } from "react-router"; import { useApi } from "../api/context"; import { serverNow, useClock } from "../clock/clock"; import { Countdown } from "../components/Countdown"; -import { - cargoName, - formatClock, - formatCredits, - formatMillionKm, - locationName, - skillName, -} from "../format"; +import { formatClock, formatCredits, formatMillionKm, locationName, skillName } from "../format"; import { distanceAt } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { pairOptions } from "./DispatchSheet"; @@ -20,6 +13,7 @@ import { DeadheadPicker } from "./Fleet"; import { content } from "@deadhead/content"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Icon, Panel, Pill } from "../ui"; +import { ClassChip, goodName, PriorityChip, tooSlowForAll } from "../components/Goods"; export const locationBoardKey = (locationId: string) => ["board", "location", locationId] as const; @@ -185,18 +179,39 @@ function LocationBoard({ className="flex flex-col gap-2" >
- + {locationName(contract.originId)} → {locationName(contract.destinationId)} - {contract.firstContact && ( - - First contact - - )} {formatCredits(contract.payout)}
+
+

+ {goodName(contract)} +

+
+ + {contract.priority && contract.deliveryWindowGameHours !== null && ( + + )} + {contract.firstContact && ( + + First contact + + )} + {tooHeavy && ( + + Too heavy + + )} + {tooSlowForAll(state, contract, now) && ( + + Too slow + + )} +
+
- {cargoName(contract.cargoTypeId)} - {contract.tier > 0 && ` (tier ${contract.tier})`} ·{" "} + {contract.shipper} ·{" "} {contract.massTonnes} t {" "} · {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))} - {tooHeavy && ( - - Too heavy - - )}
diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx index 7d934c4..8cf4159 100644 --- a/apps/web/src/screens/TutorialBoard.tsx +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -3,7 +3,8 @@ import { deliveryXp, loanerModel } from "@deadhead/sim"; import { useQuery } from "@tanstack/react-query"; import { useApi } from "../api/context"; import { useClock } from "../clock/clock"; -import { formatCredits, formatDuration, locationName, cargoName } from "../format"; +import { formatCredits, formatDuration, locationName } from "../format"; +import { goodName } from "../components/Goods"; import { distanceAt, tripRealMs } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; @@ -113,7 +114,7 @@ export function TutorialBoard() { - {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t + {goodName(contract)} · {contract.massTonnes} t
{scripted && ( diff --git a/apps/web/test/contract-card.test.tsx b/apps/web/test/contract-card.test.tsx new file mode 100644 index 0000000..72985a7 --- /dev/null +++ b/apps/web/test/contract-card.test.tsx @@ -0,0 +1,91 @@ +import type { Contract, Ship } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { realTimeOf } from "../src/components/Goods"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.now(); + +const job = (overrides: Partial): Contract => ({ + id: "j", + originId: "earth", + destinationId: "moon", + cargoTypeId: "high-value", + goodId: "medical-supplies", + shipper: "Meridian Health", + priority: false, + deliveryWindowGameHours: null, + tier: 1, + massTonnes: 6, + payout: 3_210, + requiredSkill: { skillId: "expensive-cargo", level: 1 }, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 600_000, + firstContact: false, + ...overrides, +}); + +const ship = (modelId: string): Ship => ({ + id: modelId, + modelId, + name: `My ${modelId}`, + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId: null, + lastArrivalAt: 0, +}); + +function render(contracts: Contract[], ships: Ship[]) { + renderApp( + fakeApi({ + getPlayerState: () => + samplePlayerState({ tutorial: null, bays: [{ id: "b", locationId: "earth" }], ships }), + getContractBoard: () => ({ contracts, periodEndsAt: NOW + 600_000 }), + }), + "/contracts?location=earth", + ); +} + +const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!; + +describe("contract card (CR-007)", () => { + it("shows the good, its class and tier, the shipper, mass and requirement", async () => { + render([job({})], [ship("courier")]); + const c = await card("3,210 cr"); + expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument(); + expect(c).toHaveTextContent("High-Value · T1"); + expect(c).toHaveTextContent("Meridian Health · 6 t"); + expect(c).toHaveTextContent("Needs Expensive Cargo 1"); + expect(c).not.toHaveTextContent("Priority"); + }); + + it("shows a Priority window in game hours, and real time on tap", async () => { + render( + [ + job({ + goodId: "machine-parts", + cargoTypeId: "heavy", + priority: true, + deliveryWindowGameHours: 5, + }), + ], + [ship("courier")], + ); + const c = await card("3,210 cr"); + // A Priority good can have its own name. + expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument(); + const chip = within(c).getByRole("button", { name: /Priority · within 5h/ }); + await userEvent.click(chip); + expect(chip).toHaveTextContent(`Priority · within ${realTimeOf(5)}`); + expect(c).not.toHaveTextContent("Too slow"); + }); + + it("marks a Priority job too slow when no idle ship here can make the window", async () => { + const tight = job({ priority: true, deliveryWindowGameHours: 0.5 }); + render([tight], [ship("bulk-hauler")]); + expect(await card("3,210 cr")).toHaveTextContent("Too slow"); + }); +}); diff --git a/apps/web/test/location-boards.test.tsx b/apps/web/test/location-boards.test.tsx index 8a618a1..884cdd6 100644 --- a/apps/web/test/location-boards.test.tsx +++ b/apps/web/test/location-boards.test.tsx @@ -141,19 +141,23 @@ describe("location contract boards", () => { expect(screen.getByRole("button", { name: /Earth/, pressed: true })).toBeInTheDocument(); }); - it("shows contract details, requirements, expiry and the first-contact badge", async () => { + it("shows the good, class and tier, shipper, mass, requirements, expiry and first contact", async () => { renderApp(boardApi(), "/contracts?location=earth"); await screen.findByText("1,999 cr"); const items = screen.getAllByRole("listitem"); const [plain, special] = items; - expect(plain).toHaveTextContent("Standard · 50 t"); + // CR-007 card: good headline, class chip, shipper · mass · distance. + expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument(); + expect(plain).toHaveTextContent("Standard"); + expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t"); expect(plain).toHaveTextContent("No skills needed"); expect(plain).toHaveTextContent("expires in 30m 00s"); expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument(); expect(special).toHaveTextContent("Earth → Mars"); - expect(special).toHaveTextContent("Heavy (tier 2)"); + expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument(); + expect(special).toHaveTextContent("Heavy · T2"); expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1"); expect(special).toHaveTextContent(/M km/); expect(within(special!).getByText("First contact")).toBeInTheDocument(); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index a4321a8..20c7b74 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -209,3 +209,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / - [x] **Step 10G.5 — Sim: tutorial goods.** Each tutorial slot carries a Standard good made at the loaner's location and wanted at the other end, chosen (seeded from location, run and period) among goods whose range holds the CR-001 tier mass, else clamped to a good's range; a random shipper from the origin; never Priority. On both routes some good holds 5, 12 and 20 t, so tutorial masses are unchanged; payouts now include the good's pay multiplier (e.g. Mail ×1.2). "Unmarked crates" is unchanged. Test: `tutorial.test.ts` (goods per direction over 6 runs × 20 periods, never Priority, shippers, masses 5/12/20; the existing scripted-run test still passes). - [x] **Step 10G.6 — Schema and database.** API `Contract` (since 10G.2) carries `goodId`, `shipper`, `priority`, `deliveryWindowGameHours`; `ResolutionSummary` gains `goodId`, `massTonnes`, `shipper` (null for a deadhead); `too-slow` is an API error code via the sim's list. Contracts are JSONB, so no columns change: migration `0005_cr007_goods` (custom SQL) backfills stored boards and dispatch snapshots without a good (default good of the class, origin's first shipper, no Priority) and skips rows that have one. Dev and screenshot databases migrated (all 48 stored dev contracts now carry a good). `architecture.md` updated. Tests: `goods-backfill.test.ts` (legacy board and in-flight contract are filled, a second run changes nothing, the API then serves them), `migrations.test.ts` unchanged, schema cases for Priority windows, empty goods and summary goods. - [x] **Step 10G.7 — Server: pass-through and validation.** No server code changes were needed beyond 10G.4's message and 10G.6's summary fields: boards come from the sim and dispatch validation returns the sim's `too-slow`. Tests (`contract-dispatch.test.ts`): a Priority job whose window is shorter than the Courier's trip is refused with `too-slow` and changes nothing; with a window the Courier makes, it dispatches and the stored contract keeps its Priority good; every generated Earth contract names a catalog good of its class and a shipper from Earth's list. +- [x] **Step 10G.8 — Client: contract card redesign.** New `components/Goods.tsx`: `goodName` (a Priority good may use its `priorityName`, e.g. Emergency Parts), `ClassChip` ("High-Value · T1", toned by class), `PriorityChip` ("Priority · within 5h" in game hours; tap switches to real time, hover shows both), `tooSlowForAll` (idle ships at the origin exist and none makes the window, via sim `contractTripGameHours`), `formatWindow`, `realTimeOf`. Location board cards, top to bottom: route and payout; the good as an `h3` headline with class, Priority, First contact, Too heavy and Too slow chips; route strip; shipper · mass · distance; requirement and expiry with Dispatch. Tutorial cards show the good's name instead of the class. Tests: `contract-card.test.tsx` (good, class/tier chip, shipper, mass, requirement; Priority chip in game hours and real time on tap with the Priority name; Too slow with only a Bulk Hauler idle); `location-boards.test.tsx` updated to the new card.