Step 9.3: add tutorial contract board
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b1ddb1154b
commit
8f7444b7ec
|
|
@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AwaySummary } from "./components/AwaySummary";
|
||||||
import { formatCredits } from "./format";
|
import { formatCredits } from "./format";
|
||||||
import { Layout } from "./layout/Layout";
|
import { Layout } from "./layout/Layout";
|
||||||
import { SCREENS } from "./routes";
|
import { SCREENS } from "./routes";
|
||||||
|
import { Contracts } from "./screens/Contracts";
|
||||||
import { HQ } from "./screens/HQ";
|
import { HQ } from "./screens/HQ";
|
||||||
import { Placeholder } from "./screens/Placeholder";
|
import { Placeholder } from "./screens/Placeholder";
|
||||||
import { StartScreen } from "./screens/StartScreen";
|
import { StartScreen } from "./screens/StartScreen";
|
||||||
|
|
@ -14,6 +15,7 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
||||||
/** Built screens by path; the rest show a placeholder. */
|
/** Built screens by path; the rest show a placeholder. */
|
||||||
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
|
const SCREEN_COMPONENTS: Partial<Record<(typeof SCREENS)[number]["path"], ReactNode>> = {
|
||||||
"/": <HQ />,
|
"/": <HQ />,
|
||||||
|
"/contracts": <Contracts />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Syncs the clock with the server once, then ticks it every second. */
|
/** Syncs the clock with the server once, then ticks it every second. */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<section className="space-y-3 p-4">
|
||||||
|
<h1 className="text-xl font-semibold">Contracts</h1>
|
||||||
|
{state.tutorial ? (
|
||||||
|
<TutorialBoard />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-400">Bay boards coming soon.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 <p className="p-4 text-slate-400">Loading contracts…</p>;
|
||||||
|
if (board.isError) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="p-4 text-red-400">
|
||||||
|
Could not load contracts.{" "}
|
||||||
|
<button type="button" className="underline" onClick={() => void board.refetch()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Company runs in the loaner {loaner.name}. You keep a share of each payout.
|
||||||
|
</p>
|
||||||
|
{runInFlight && (
|
||||||
|
<p className="rounded-lg bg-slate-800 p-2 text-sm">
|
||||||
|
Your run is in flight. Wait for it to land.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fly.isError && (
|
||||||
|
<p role="alert" className="text-sm text-red-400">
|
||||||
|
{errorMessage(fly.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{board.data.contracts.map((contract) => (
|
||||||
|
<li key={contract.id} className="rounded-lg border border-slate-800 p-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{locationName(contract.originId)} → {locationName(contract.destinationId)}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tabular-nums text-emerald-400">
|
||||||
|
{formatCredits(contract.payout)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center justify-between gap-2 text-sm text-slate-400">
|
||||||
|
<span>
|
||||||
|
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
|
||||||
|
<span aria-label="Trip time">
|
||||||
|
{formatDuration(
|
||||||
|
tripRealMs(loaner, contract.originId, contract.destinationId, now),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={runInFlight || fly.isPending}
|
||||||
|
onClick={() => fly.mutate({ kind: "tutorial", contractId: contract.id })}
|
||||||
|
className="rounded-md bg-amber-500 px-3 py-1 text-sm font-semibold text-slate-950 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Fly it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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.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.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.4 — Bay contract boards
|
||||||
- [ ] Step 9.5 — Dispatch flow
|
- [ ] Step 9.5 — Dispatch flow
|
||||||
- [ ] Step 9.6 — Fleet screen
|
- [ ] Step 9.6 — Fleet screen
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue