diff --git a/DECISIONS.md b/DECISIONS.md
index aec0596..4d27bea 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -558,3 +558,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The intro card and "Suggested" highlight show only before the first run** (`runsCompleted === 0`); after that the board is a plain choice.
- **The suggested contract is the board's first slot,** which the v2 generator always fills with the light tier (fastest, most XP per minute).
- **Story names live in client copy:** "Halvorsen Lunar Freight" is presentation text, not game data, so it is not in content.
+
+## Step 10A.12
+
+- **XP shown on a tutorial contract is sim `deliveryXp` at the current distance**, the same formula resolution uses, so the light-versus-heavy tradeoff the player sees is the real one.
+- **Scripted contracts show their content name** ("Unmarked crates") in a distinct colour with a short "double pay, no questions asked" line; the summary looks up the flavor message by the summary's `scriptedId`.
diff --git a/apps/web/src/components/AwaySummary.tsx b/apps/web/src/components/AwaySummary.tsx
index eb2df3d..c93b236 100644
--- a/apps/web/src/components/AwaySummary.tsx
+++ b/apps/web/src/components/AwaySummary.tsx
@@ -1,3 +1,4 @@
+import { content } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, locationName } from "../format";
import { useGameAction, useLoadedPlayer } from "../state/actions";
@@ -46,6 +47,14 @@ export function AwaySummary() {
+{s.pilotXp} XP
+ {s.scriptedId && (
+
+ {
+ content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
+ ?.flavorMessage
+ }
+
+ )}
{s.discoveredLocationId && (
Discovered {locationName(s.discoveredLocationId)}!
diff --git a/apps/web/src/screens/TutorialBoard.tsx b/apps/web/src/screens/TutorialBoard.tsx
index f52b829..9fd2e03 100644
--- a/apps/web/src/screens/TutorialBoard.tsx
+++ b/apps/web/src/screens/TutorialBoard.tsx
@@ -1,10 +1,10 @@
import { content } from "@deadhead/content";
-import { loanerModel } from "@deadhead/sim";
+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 { tripRealMs } from "../game";
+import { distanceAt, tripRealMs } from "../game";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
export const TUTORIAL_BOARD_KEY = ["board", "tutorial"] as const;
@@ -66,40 +66,79 @@ export function TutorialBoard() {
{board.data.contracts.map((contract, index) => {
// On the first run, suggest the lightest (fastest) contract.
const suggested = firstRun && index === 0;
+ const scripted = contract.scriptedId
+ ? content.scriptedTutorialContracts.find((s) => s.id === contract.scriptedId)
+ : undefined;
+ const tripMs = tripRealMs(
+ loaner,
+ contract.originId,
+ contract.destinationId,
+ contract.massTonnes,
+ now,
+ );
+ const xp = deliveryXp(
+ distanceAt(contract.originId, contract.destinationId, now),
+ contract.tier,
+ content.config,
+ );
+ const border = scripted
+ ? "border-fuchsia-500 bg-fuchsia-500/10"
+ : suggested
+ ? "border-amber-400 ring-1 ring-amber-400"
+ : "border-slate-800";
return (
- {locationName(contract.originId)} → {locationName(contract.destinationId)}
+ {scripted ? (
+ {scripted.name}
+ ) : (
+ <>
+ {locationName(contract.originId)} → {locationName(contract.destinationId)}
+ >
+ )}
{suggested && (
Suggested
)}
-
- {formatCredits(contract.payout)}
+
+ {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
-
-
- {cargoName(contract.cargoTypeId)} · {contract.massTonnes} t ·{" "}
-
- {formatDuration(
- tripRealMs(
- loaner,
- contract.originId,
- contract.destinationId,
- contract.massTonnes,
- now,
- ),
- )}
-
-
+ {scripted && (
+
+ {locationName(contract.originId)} → {locationName(contract.destinationId)} ·
+ double pay, no questions asked
+
+ )}
+
+
+
+
Pay
+
+ {formatCredits(contract.payout)}
+
+
+
+
Time
+
+ {formatDuration(tripMs)}
+
+
+
+
XP
+
+ +{xp}
+
+
+
({
- 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 Company Courier loaner, Earth→Moon, departing now. */
-function expectedTripTime(now: number, massTonnes: number) {
+/** What the sim predicts for the loaner flying this contract now. */
+function expected(contract: Contract) {
const distance = routeDistance(
- content.location("earth")!,
- content.location("moon")!,
- realToGame(now, content.config),
+ content.location(contract.originId)!,
+ content.location(contract.destinationId)!,
+ realToGame(NOW, content.config),
);
- const hours = travelTimeGameHours(
- content.shipModel("company-courier")!,
- distance,
- massTonnes,
- content.config,
- );
- return formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression);
+ const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
+ return {
+ pay: formatCredits(contract.payout),
+ time: formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression),
+ xp: `+${deliveryXp(distance, contract.tier, content.config)}`,
+ };
}
describe("tutorial contract board", () => {
- beforeEach(() => useClock.setState({ offsetMs: 0, synced: true, now: NOW }));
+ beforeEach(() => {
+ vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
+ vi.setSystemTime(NOW);
+ useClock.setState({ offsetMs: 0, synced: true, now: NOW });
+ });
+ afterEach(() => vi.useRealTimers());
- 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, 12),
+ it("shows pay, trip time and XP side by side for all three contracts, matching the sim", async () => {
+ const board = sampleTutorialBoard(NOW);
+ renderApp(
+ fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board }),
+ "/contracts",
);
+ const items = await screen.findAllByRole("listitem");
+ expect(items).toHaveLength(3);
+ items.forEach((item, i) => {
+ const want = expected(board.contracts[i]!);
+ expect(within(item).getByLabelText("Pay")).toHaveTextContent(want.pay);
+ expect(within(item).getByLabelText("Trip time")).toHaveTextContent(want.time);
+ expect(within(item).getByLabelText("XP")).toHaveTextContent(want.xp);
+ });
+ // The heavy run takes longer than the light one.
+ expect(expected(board.contracts[2]!).time).not.toBe(expected(board.contracts[0]!).time);
+ });
+
+ it("styles the scripted contract and shows its name", async () => {
+ renderApp(
+ fakeApi({
+ getPlayerState: () => samplePlayerState(),
+ getTutorialBoard: () => sampleTutorialBoard(NOW, { scriptedMedium: true }),
+ }),
+ "/contracts",
+ );
+ const items = await screen.findAllByRole("listitem");
+ expect(items[1]).toHaveAttribute("data-scripted", "true");
+ expect(items[1]).toHaveTextContent("Unmarked crates");
+ expect(items[0]).not.toHaveAttribute("data-scripted");
});
it("dispatches the tutorial run with Fly it", async () => {
const api = fakeApi({
getPlayerState: () => samplePlayerState(),
- getTutorialBoard: () => board,
+ getTutorialBoard: () => sampleTutorialBoard(NOW),
});
renderApp(api, "/contracts");
- const [first] = await screen.findAllByRole("listitem");
- await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
+ const [, medium] = await screen.findAllByRole("listitem");
+ await userEvent.click(within(medium!).getByRole("button", { name: "Fly it" }));
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("createDispatch", {
kind: "tutorial",
- contractId: "t1",
+ contractId: "tutorial:earth:1:0:1",
}),
);
});
it("disables Fly it while a tutorial run is in flight", async () => {
- const api = fakeApi({
- getPlayerState: () =>
- samplePlayerState({
- tutorial: {
- loanerLocationId: "earth",
- runInFlight: true,
- runsCompleted: 0,
- dealAvailable: false,
- },
- }),
- getTutorialBoard: () => board,
- });
- renderApp(api, "/contracts");
+ renderApp(
+ fakeApi({
+ getPlayerState: () =>
+ samplePlayerState({
+ tutorial: {
+ loanerLocationId: "earth",
+ runInFlight: true,
+ runsCompleted: 1,
+ dealAvailable: false,
+ },
+ }),
+ getTutorialBoard: () => sampleTutorialBoard(NOW),
+ }),
+ "/contracts",
+ );
await screen.findAllByRole("listitem");
for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
expect(button).toBeDisabled();
@@ -105,14 +113,16 @@ describe("tutorial contract board", () => {
});
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");
+ renderApp(
+ fakeApi({
+ getPlayerState: () => samplePlayerState(),
+ getTutorialBoard: () => sampleTutorialBoard(NOW),
+ createDispatch: () => {
+ throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
+ },
+ }),
+ "/contracts",
+ );
const [first] = await screen.findAllByRole("listitem");
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
@@ -120,3 +130,37 @@ describe("tutorial contract board", () => {
);
});
});
+
+describe("scripted delivery summary", () => {
+ it("shows the scripted contract's flavor message after it resolves", async () => {
+ const flavor = content.scriptedTutorialContracts.find(
+ (s) => s.id === "unmarked-crates",
+ )!.flavorMessage;
+ renderApp(
+ fakeApi({
+ getPlayerState: () =>
+ samplePlayerState({
+ summaries: [
+ {
+ dispatchId: "d3",
+ shipId: "loaner",
+ pilotId: "avatar",
+ originId: "earth",
+ destinationId: "moon",
+ tutorial: true,
+ credits: 461,
+ loanRepayment: 0,
+ pilotXp: 10,
+ discoveredLocationId: null,
+ scriptedId: "unmarked-crates",
+ resolvedAt: NOW,
+ },
+ ],
+ }),
+ acknowledgeSummaries: () => ({ acknowledged: 1 }),
+ }),
+ );
+ const dialog = await screen.findByRole("dialog", { name: "While you were away" });
+ expect(dialog).toHaveTextContent(flavor);
+ });
+});
diff --git a/memory-bank/progress.md b/memory-bank/progress.md
index 598d4ec..458241f 100644
--- a/memory-bank/progress.md
+++ b/memory-bank/progress.md
@@ -117,7 +117,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.9 — Server: loan repayment.** Resolution reads the company loan balance, adds only the net payout and lowers the balance by the repayment (stored in the dispatch resolution, shown in summaries). `POST /api/loan/repay` (`src/game/loan.ts`) moves up to the balance from credits to the loan under the player lock; `409 insufficient-funds` with no changes if credits fall short.
- [x] **Step 10A.10 — Server: goals.** `src/game/goals.ts`: `goalProgress` (own-ship deliveries, skill points spent, ships, hires, extra discoveries, deal, loan balance) → `goalStates` (empty during the tutorial) in player state; `POST /api/goals/:goalId/claim` adds the reward once (`goal-not-ready`, `goal-already-claimed`, `404 unknown-goal`).
- [x] **Step 10A.11 — Client: first launch goes straight to work.** "Start your company" opens Contracts; on the first run the tutorial board shows the Halvorsen intro card and highlights the light contract as "Suggested". Step 8.4 test updated.
-- [ ] Step 10A.12 — Client: tutorial board shows the tradeoff
+- [x] **Step 10A.12 — Client: tutorial board shows the tradeoff.** Each tutorial contract shows Pay, Time and XP side by side (time and XP from sim); scripted contracts get their own name and fuchsia styling; their flavor message appears in the delivery summary. Step 9.3 tests updated.
- [ ] Step 10A.13 — Client: the deal
- [ ] Step 10A.14 — Client: loan on HQ
- [ ] Step 10A.15 — Client: goals on HQ