diff --git a/DECISIONS.md b/DECISIONS.md index 846b4ba..d759e8b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -412,3 +412,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Trip-time predictions use sim with the content the server uses** (`src/game.ts`: distance at the current server time, `travelTimeGameHours`, converted to real time). They are predictions only (rule 0.1.6). - **Boards are TanStack queries under the `["board", …]` key**, which every game action invalidates, so a flown contract disappears from the list. + +## Step 9.4 + +- **The distance shown on a contract is the current distance** (sim, at the server clock), which is what fuel and travel time use when dispatching now. The payout was fixed at the distance when the contract was offered (decision 0.5.11). +- **Requirements are shown as text** ("Needs Heavy Cargo 2, Long Haul 1"); whether a particular pilot meets them is shown in the dispatch sheet (Step 9.5). +- **The bay selector is a native ` setSelected(e.target.value)} + className="rounded-md border border-slate-700 bg-slate-900 px-2 py-1" + > + {state.bays.map((bay) => ( + + ))} + + + + + ); +} + +function BayBoard({ + locationId, + onSelect, +}: { + locationId: string; + onSelect?: (contract: Contract) => void; +}) { + const api = useApi(); + const now = useClock((s) => s.now); + const board = useQuery({ + queryKey: bayBoardKey(locationId), + queryFn: () => api.call("getContractBoard", { locationId }), + }); + + if (board.isPending) return

Loading contracts…

; + if (board.isError) { + return ( +
+ Could not load contracts.{" "} + +
+ ); + } + if (board.data.contracts.length === 0) { + return ( +

No contracts left this period. Check back soon.

+ ); + } + + return ( + + ); +} diff --git a/apps/web/src/screens/Contracts.tsx b/apps/web/src/screens/Contracts.tsx index 3dc0757..24eb1ad 100644 --- a/apps/web/src/screens/Contracts.tsx +++ b/apps/web/src/screens/Contracts.tsx @@ -1,4 +1,5 @@ import { useLoadedPlayer } from "../state/actions"; +import { BayBoards } from "./BayBoards"; import { TutorialBoard } from "./TutorialBoard"; /** Contracts: the tutorial board during the tutorial, bay boards afterwards. */ @@ -7,11 +8,7 @@ export function Contracts() { return (

Contracts

- {state.tutorial ? ( - - ) : ( -

Bay boards coming soon.

- )} + {state.tutorial ? : }
); } diff --git a/apps/web/test/bay-boards.test.tsx b/apps/web/test/bay-boards.test.tsx new file mode 100644 index 0000000..2a59310 --- /dev/null +++ b/apps/web/test/bay-boards.test.tsx @@ -0,0 +1,107 @@ +import type { Contract } from "@deadhead/schema"; +import { screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useClock } from "../src/clock/clock"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const contract = (overrides: Partial): Contract => ({ + id: "c", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 50, + payout: 2000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 30 * 60_000, + firstContact: false, + ...overrides, +}); + +const boards: Record = { + earth: [ + contract({ id: "e1", payout: 1999 }), + contract({ + id: "e2", + destinationId: "mars", + cargoTypeId: "heavy", + tier: 2, + requiredSkill: { skillId: "heavy-cargo", level: 2 }, + requiredLongHaulLevel: 1, + firstContact: true, + payout: 88_000, + }), + ], + moon: [contract({ id: "m1", originId: "moon", destinationId: "earth", payout: 1234 })], +}; + +const owner = () => + samplePlayerState({ + tutorial: null, + bays: [ + { id: "b1", locationId: "earth" }, + { id: "b2", locationId: "moon" }, + ], + }); + +describe("bay contract boards", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows the first bay's board, and switching bays shows that bay's board", async () => { + const api = fakeApi({ + getPlayerState: owner, + getContractBoard: ({ locationId }) => ({ + contracts: boards[locationId] ?? [], + periodEndsAt: NOW + 30 * 60_000, + }), + }); + renderApp(api, "/contracts"); + + expect(await screen.findByText("1,999 cr")).toBeInTheDocument(); + expect(screen.queryByText("1,234 cr")).not.toBeInTheDocument(); + + await userEvent.selectOptions(screen.getByLabelText("Bay"), "moon"); + expect(await screen.findByText("1,234 cr")).toBeInTheDocument(); + expect(screen.getByText("Moon → Earth")).toBeInTheDocument(); + expect(screen.queryByText("1,999 cr")).not.toBeInTheDocument(); + }); + + it("shows contract details, requirements, expiry and the first-contact badge", async () => { + const api = fakeApi({ + getPlayerState: owner, + getContractBoard: () => ({ contracts: boards.earth!, periodEndsAt: NOW + 30 * 60_000 }), + }); + renderApp(api, "/contracts"); + + const items = await screen.findAllByRole("listitem"); + const [plain, special] = items; + expect(plain).toHaveTextContent("Standard · 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(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1"); + expect(special).toHaveTextContent(/M km/); + expect(within(special!).getByText("First contact")).toBeInTheDocument(); + }); + + it("explains how to get contracts when the player has no bays", async () => { + renderApp( + fakeApi({ getPlayerState: () => samplePlayerState({ tutorial: null }) }), + "/contracts", + ); + expect(await screen.findByText(/You have no bays yet/)).toBeInTheDocument(); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1b54541..9c01561 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -88,7 +88,7 @@ - [x] **Step 9.1 — HQ screen.** `src/screens/HQ.tsx`: credits, game date, idle/in-flight counts, next arrival with live countdown, tutorial goal progress (cheapest ship + Earth bay). Shared helpers: `src/format.ts`, `Countdown`, `useLoadedPlayer` / `useGameAction` (`src/state/actions.ts`). - [x] **Step 9.2 — "While you were away" summary.** `src/components/AwaySummary.tsx`: dialog over every screen listing each unacknowledged delivery (ship or "Company loaner", route, credits, XP, discovery); "Got it" hides it at once and calls `acknowledgeSummaries`. - [x] **Step 9.3 — Tutorial contract board.** Contracts screen shows the tutorial board during the tutorial (`src/screens/TutorialBoard.tsx`): route, cargo, mass, payout, sim-predicted trip time (`src/game.ts`), and "Fly it" (disabled while a run is in flight; server errors shown). -- [ ] Step 9.4 — Bay contract boards +- [x] **Step 9.4 — Bay contract boards.** After the tutorial the Contracts screen shows `BayBoards`: a bay selector and, per contract, route, cargo (and tier), mass, current distance, payout, required skills, live expiry countdown and a first-contact badge. - [ ] Step 9.5 — Dispatch flow - [ ] Step 9.6 — Fleet screen - [ ] Step 9.7 — Pilots screen