From 7101b26abf83fbdeb04f787f1b7d233a336a2065 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 02:13:05 -0400 Subject: [PATCH] Step 9.5: add dispatch flow Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 + apps/web/src/screens/Contracts.tsx | 7 +- apps/web/src/screens/DispatchSheet.tsx | 220 +++++++++++++++++++++++++ apps/web/test/dispatch-sheet.test.tsx | 165 +++++++++++++++++++ memory-bank/progress.md | 2 +- 5 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/screens/DispatchSheet.tsx create mode 100644 apps/web/test/dispatch-sheet.test.tsx diff --git a/DECISIONS.md b/DECISIONS.md index d759e8b..7f1e5d3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -418,3 +418,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **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 ` setChosen(option.shipId)} + /> + {option.shipName} + + {option.modelName} · {option.pilotName ?? "no pilot"} + + + {option.reason ? ( +

{option.reason}

+ ) : ( +
+
Trip
+
{formatDuration(option.tripMs)}
+
Fuel
+
{formatCredits(option.fuelCost)}
+
Net profit
+
{formatCredits(option.netProfit)}
+
ETA (game)
+
+ {formatGameTime(realToGame(option.arrivalAt, content.config))} +
+
ETA (real)
+
{formatLocalTime(option.arrivalAt)}
+
+ )} + + + ))} + + )} + + {dispatch.isError && ( +

+ {errorMessage(dispatch.error)} +

+ )} +
+ + +
+ + + ); +} diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx new file mode 100644 index 0000000..fddbf05 --- /dev/null +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -0,0 +1,165 @@ +import { content } from "@deadhead/content"; +import type { Contract, Pilot, Ship } from "@deadhead/schema"; +import { + MS_PER_HOUR, + realToGame, + routeDistance, + travelTimeGameHours, + tripFuelCost, +} from "@deadhead/sim"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "../src/api/client"; +import { useClock } from "../src/clock/clock"; +import { formatGameTime } from "../src/clock/GameClock"; +import { formatCredits, formatDuration } from "../src/format"; +import { formatLocalTime } from "../src/screens/DispatchSheet"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const ship = (id: string, name: string, modelId: string, pilotId: string | null): Ship => ({ + id, + name, + modelId, + status: "idle", + locationId: "earth", + dispatchId: null, + pilotId, +}); + +const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pilot => ({ + id, + name, + isAvatar, + xp: 0, + level: 0, + skills: {}, + skillPointsAvailable: 0, + assignedShipId: shipId, + status: "idle", + locationId: "earth", +}); + +const marsJob: Contract = { + id: "c-mars", + originId: "earth", + destinationId: "mars", + cargoTypeId: "standard", + tier: 0, + massTonnes: 15, + payout: 500_000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + firstContact: false, +}; + +const state = samplePlayerState({ + tutorial: null, + company: { id: "c", name: "D", credits: 1_000_000 }, + bays: [{ id: "b1", locationId: "earth" }], + ships: [ + ship("s-courier", "Dusty Mule", "courier", "avatar"), + ship("s-freighter", "Slow Ox", "freighter", "p-rae"), + ship("s-spare", "Spare Kettle", "courier", null), + ], + pilots: [ + pilot("avatar", "Captain", "s-courier", true), + pilot("p-rae", "Rae Okafor", "s-freighter", false), + ], +}); + +function api(createDispatch?: () => never) { + return fakeApi({ + getPlayerState: () => state, + getContractBoard: () => ({ contracts: [marsJob], periodEndsAt: NOW + 3_600_000 }), + ...(createDispatch ? { createDispatch } : {}), + }); +} + +async function openSheet() { + await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); + return screen.getByRole("dialog"); +} + +const option = (dialog: HTMLElement, shipName: string) => + within(dialog).getByText(shipName).closest("li")!; + +describe("dispatch sheet", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(NOW); + useClock.setState({ offsetMs: 0, synced: true, now: NOW }); + }); + afterEach(() => vi.useRealTimers()); + + it("shows the sim's trip time, fuel, net profit and ETAs for an eligible pair", async () => { + renderApp(api(), "/contracts"); + const dialog = await openSheet(); + const freighter = option(dialog, "Slow Ox"); + + const model = content.shipModel("freighter")!; + const earth = content.location("earth")!; + const mars = content.location("mars")!; + const gameNow = realToGame(NOW, content.config); + const tripMs = + (travelTimeGameHours(model, routeDistance(earth, mars, gameNow)) * MS_PER_HOUR) / + content.config.timeCompression; + const fuel = tripFuelCost(model, earth, mars, gameNow); + const net = marsJob.payout - fuel - content.config.pilotPayPerDelivery; + + expect(within(freighter).getByRole("radio")).toBeEnabled(); + expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`); + expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`); + expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`); + expect(freighter).toHaveTextContent( + `ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config))}`, + ); + expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`); + }); + + it("lists ineligible pairs disabled, with the reason", async () => { + renderApp(api(), "/contracts"); + const dialog = await openSheet(); + + const courier = option(dialog, "Dusty Mule"); + expect(within(courier).getByRole("radio")).toBeDisabled(); + expect(courier).toHaveTextContent("Out of range"); + + const spare = option(dialog, "Spare Kettle"); + expect(within(spare).getByRole("radio")).toBeDisabled(); + expect(spare).toHaveTextContent("No pilot assigned"); + }); + + it("sends the dispatch for the chosen pair on Confirm", async () => { + const fake = api(); + renderApp(fake, "/contracts"); + const dialog = await openSheet(); + await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" })); + await waitFor(() => + expect(fake.call).toHaveBeenCalledWith("createDispatch", { + kind: "contract", + contractId: "c-mars", + shipId: "s-freighter", + pilotId: "p-rae", + }), + ); + }); + + it("shows the server's message when the dispatch is refused", async () => { + renderApp( + api(() => { + throw new ApiError(409, "contract-expired", "This contract has expired."); + }), + "/contracts", + ); + const dialog = await openSheet(); + await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" })); + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "This contract has expired.", + ); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 9c01561..f2c5026 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -89,7 +89,7 @@ - [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). - [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 +- [x] **Step 9.5 — Dispatch flow.** `src/screens/DispatchSheet.tsx`: each idle ship at the pickup with its pilot, checked with sim `validateDispatch`; eligible pairs show trip time, fuel, net profit and game/real ETA; ineligible pairs are disabled with a short reason; Confirm sends the dispatch and shows server errors. - [ ] Step 9.6 — Fleet screen - [ ] Step 9.7 — Pilots screen - [ ] Step 9.8 — Market screen