diff --git a/DECISIONS.md b/DECISIONS.md index 45c86ac..846b4ba 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -407,3 +407,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The summary dialog sits above all screens** (rendered by `App`). Dismissing hides it immediately (local state) and sends `acknowledgeSummaries` for the listed ids; the follow-up state refetch confirms it. - **Tutorial runs are shown as "Company loaner"**, since the loaner is not in the ships list. + +## Step 9.3 + +- **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. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 35c1653..3adc6e9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,7 @@ import { AwaySummary } from "./components/AwaySummary"; import { formatCredits } from "./format"; import { Layout } from "./layout/Layout"; import { SCREENS } from "./routes"; +import { Contracts } from "./screens/Contracts"; import { HQ } from "./screens/HQ"; import { Placeholder } from "./screens/Placeholder"; import { StartScreen } from "./screens/StartScreen"; @@ -14,6 +15,7 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player"; /** Built screens by path; the rest show a placeholder. */ const SCREEN_COMPONENTS: Partial> = { "/": , + "/contracts": , }; /** Syncs the clock with the server once, then ticks it every second. */ diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts new file mode 100644 index 0000000..549ef34 --- /dev/null +++ b/apps/web/src/game.ts @@ -0,0 +1,31 @@ +import { content, type Location, type ShipModel } from "@deadhead/content"; +import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; + +/** Client-side predictions with the same sim the server uses (rule 0.1.6). */ + +export function location(id: string): Location { + const found = content.location(id); + if (!found) throw new Error(`Unknown location ${id}`); + return found; +} + +export function shipModel(id: string): ShipModel { + const found = content.shipModel(id); + if (!found) throw new Error(`Unknown ship model ${id}`); + return found; +} + +/** Route distance if departing at real time `now`, in million km. */ +export function distanceAt(originId: string, destinationId: string, now: number): number { + return routeDistance( + location(originId), + location(destinationId), + realToGame(now, content.config), + ); +} + +/** Trip duration in real milliseconds for a ship departing at `now`. */ +export function tripRealMs(model: ShipModel, originId: string, destinationId: string, now: number) { + const hours = travelTimeGameHours(model, distanceAt(originId, destinationId, now)); + return (hours * MS_PER_HOUR) / content.config.timeCompression; +} diff --git a/apps/web/src/screens/Contracts.tsx b/apps/web/src/screens/Contracts.tsx new file mode 100644 index 0000000..3dc0757 --- /dev/null +++ b/apps/web/src/screens/Contracts.tsx @@ -0,0 +1,17 @@ +import { useLoadedPlayer } from "../state/actions"; +import { TutorialBoard } from "./TutorialBoard"; + +/** Contracts: the tutorial board during the tutorial, bay boards afterwards. */ +export function Contracts() { + const state = useLoadedPlayer(); + return ( +
+

Contracts

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

Bay boards coming soon.

+ )} +
+ ); +} diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx new file mode 100644 index 0000000..c8bac66 --- /dev/null +++ b/apps/web/src/screens/TutorialBoard.tsx @@ -0,0 +1,86 @@ +import { content } from "@deadhead/content"; +import { 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 { tripRealMs } from "../game"; +import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; + +export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const; + +/** The company board during the tutorial (Step 9.3). */ +export function TutorialBoard() { + const api = useApi(); + const state = useLoadedPlayer(); + const now = useClock((s) => s.now); + const board = useQuery({ + queryKey: TUTORIAL_BOARD_KEY, + queryFn: () => api.call("getTutorialBoard", {}), + }); + const fly = useGameAction("createDispatch"); + const runInFlight = state.tutorial?.runInFlight ?? false; + const loaner = loanerModel(content); + + if (board.isPending) return

Loading contracts…

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

+ Company runs in the loaner {loaner.name}. You keep a share of each payout. +

+ {runInFlight && ( +

+ Your run is in flight. Wait for it to land. +

+ )} + {fly.isError && ( +

+ {errorMessage(fly.error)} +

+ )} +
    + {board.data.contracts.map((contract) => ( +
  • +
    + + {locationName(contract.originId)} → {locationName(contract.destinationId)} + + + {formatCredits(contract.payout)} + +
    +
    + + {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "} + + {formatDuration( + tripRealMs(loaner, contract.originId, contract.destinationId, now), + )} + + + +
    +
  • + ))} +
+
+ ); +} diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx new file mode 100644 index 0000000..1d435cb --- /dev/null +++ b/apps/web/test/tutorial-board.test.tsx @@ -0,0 +1,110 @@ +import { content } from "@deadhead/content"; +import type { Contract } from "@deadhead/schema"; +import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ApiError } from "../src/api/client"; +import { useClock } from "../src/clock/clock"; +import { formatDuration } from "../src/format"; +import { fakeApi, renderApp, samplePlayerState } from "./render"; + +const NOW = Date.parse("2100-01-01T00:00:00Z"); + +const contract = (id: string, mass: number, payout: number): Contract => ({ + id, + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: mass, + payout, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + firstContact: false, +}); + +const board = { + contracts: [contract("t1", 12, 230), contract("t2", 18, 345)], + periodEndsAt: NOW + 3_600_000, +}; + +/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */ +function expectedTripTime(now: number) { + const distance = routeDistance( + content.location("earth")!, + content.location("moon")!, + realToGame(now, content.config), + ); + const hours = travelTimeGameHours(content.shipModel("courier")!, distance); + return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression); +} + +describe("tutorial contract board", () => { + beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW })); + + it("shows each contract with route, cargo, payout and the sim's trip time", async () => { + const api = fakeApi({ + getPlayerState: () => samplePlayerState(), + getTutorialBoard: () => board, + }); + renderApp(api, "/contracts"); + + const items = await screen.findAllByRole("listitem"); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent("Earth → Moon"); + expect(items[0]).toHaveTextContent("Standard · 12 t"); + expect(items[0]).toHaveTextContent("230 cr"); + expect(within(items[0]!).getByLabelText("Trip time")).toHaveTextContent( + expectedTripTime(useClock.getState().now), + ); + }); + + it("dispatches the tutorial run with Fly it", async () => { + const api = fakeApi({ + getPlayerState: () => samplePlayerState(), + getTutorialBoard: () => board, + }); + renderApp(api, "/contracts"); + const [first] = await screen.findAllByRole("listitem"); + await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); + await waitFor(() => + expect(api.call).toHaveBeenCalledWith("createDispatch", { + kind: "tutorial", + contractId: "t1", + }), + ); + }); + + it("disables Fly it while a tutorial run is in flight", async () => { + const api = fakeApi({ + getPlayerState: () => + samplePlayerState({ tutorial: { loanerLocationId: "earth", runInFlight: true } }), + getTutorialBoard: () => board, + }); + renderApp(api, "/contracts"); + await screen.findAllByRole("listitem"); + for (const button of screen.getAllByRole("button", { name: "Fly it" })) { + expect(button).toBeDisabled(); + } + expect(screen.getByText(/Your run is in flight/)).toBeInTheDocument(); + }); + + it("shows the server's message when dispatch fails", async () => { + const api = fakeApi({ + getPlayerState: () => samplePlayerState(), + getTutorialBoard: () => board, + createDispatch: () => { + throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying."); + }, + }); + renderApp(api, "/contracts"); + const [first] = await screen.findAllByRole("listitem"); + await userEvent.click(within(first!).getByRole("button", { name: "Fly it" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your tutorial run is still flying.", + ); + }); +}); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5bd7f49..1b54541 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -87,7 +87,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`. -- [ ] Step 9.3 — Tutorial contract board +- [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 - [ ] Step 9.5 — Dispatch flow - [ ] Step 9.6 — Fleet screen