Step 10G.8: contract cards headline the good, with class, Priority and too-slow chips

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 17:17:21 -04:00
parent 4f8ee4a06d
commit 8945972372
6 changed files with 209 additions and 26 deletions

View File

@ -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<Contract, "goodId" | "priority">): 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<string, Tone> = {
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<Contract, "cargoTypeId" | "tier"> }) {
return (
<Pill tone={CLASS_TONES[contract.cargoTypeId] ?? "default"}>
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` · T${contract.tier}`}
</Pill>
);
}
/**
* "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 (
<button
type="button"
onClick={() => setReal(!real)}
title={`Within ${formatWindow(windowHours)} game time, about ${realTimeOf(windowHours)} real time`}
className="rounded-full"
>
<Pill tone="amber" icon="bolt">
Priority · within {real ? realTimeOf(windowHours) : formatWindow(windowHours)}
</Pill>
</button>
);
}
/**
* 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!
);
});
}

View File

@ -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"
>
<div className="flex items-start justify-between gap-2">
<span className="font-display font-bold tracking-wide">
<span className="font-display text-sm font-semibold tracking-wide text-ink-300">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{contract.firstContact && (
<Pill tone="amber" icon="star" className="ml-2 align-middle">
First contact
</Pill>
)}
</span>
<span className="font-display text-lg font-bold whitespace-nowrap text-emerald-300 tabular-nums">
{formatCredits(contract.payout)}
</span>
</div>
<div>
<h3 className="font-display text-lg leading-tight font-bold tracking-wide">
{goodName(contract)}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<ClassChip contract={contract} />
{contract.priority && contract.deliveryWindowGameHours !== null && (
<PriorityChip windowHours={contract.deliveryWindowGameHours} />
)}
{contract.firstContact && (
<Pill tone="amber" icon="star">
First contact
</Pill>
)}
{tooHeavy && (
<Pill tone="rose" icon="alert">
Too heavy
</Pill>
)}
{tooSlowForAll(state, contract, now) && (
<Pill tone="rose" icon="clock">
Too slow
</Pill>
)}
</div>
</div>
<RouteStrip
originId={contract.originId}
destinationId={contract.destinationId}
@ -205,18 +220,12 @@ function LocationBoard({
<div className="flex items-center gap-1.5 text-sm text-ink-300">
<Icon name="cargo" className="size-4 shrink-0 text-ink-400" />
<span>
{cargoName(contract.cargoTypeId)}
{contract.tier > 0 && ` (tier ${contract.tier})`} ·{" "}
{contract.shipper} ·{" "}
<span className={tooHeavy ? "font-semibold text-rose-300" : undefined}>
{contract.massTonnes} t
</span>{" "}
· {formatMillionKm(distanceAt(contract.originId, contract.destinationId, now))}
</span>
{tooHeavy && (
<Pill tone="rose" icon="alert" className="ml-auto">
Too heavy
</Pill>
)}
</div>
<div className="mt-auto flex items-end justify-between gap-2 border-t border-space-700/70 pt-2 text-sm">
<span className="text-ink-400">

View File

@ -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() {
</span>
<span className="flex items-center gap-1 text-sm whitespace-nowrap text-ink-300">
<Icon name="cargo" className="size-4 text-ink-400" />
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
{goodName(contract)} · {contract.massTonnes} t
</span>
</div>
{scripted && (

View File

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

View File

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

View File

@ -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.