Step 10A.12: show the tutorial tradeoff and scripted contracts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 11:25:52 -04:00
parent 0488a84db4
commit c53910b6e3
5 changed files with 195 additions and 98 deletions

View File

@ -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`.

View File

@ -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() {
</span>
<span>+{s.pilotXp} XP</span>
</div>
{s.scriptedId && (
<p className="mt-1 italic text-fuchsia-200">
{
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
?.flavorMessage
}
</p>
)}
{s.discoveredLocationId && (
<div className="mt-1 text-amber-300">
Discovered {locationName(s.discoveredLocationId)}!

View File

@ -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 (
<li
key={contract.id}
data-suggested={suggested || undefined}
className={`rounded-lg border p-3 ${suggested ? "border-amber-400 ring-1 ring-amber-400" : "border-slate-800"}`}
data-scripted={scripted ? true : undefined}
className={`rounded-lg border p-3 ${border}`}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium">
{locationName(contract.originId)} → {locationName(contract.destinationId)}
{scripted ? (
<span className="text-fuchsia-300">{scripted.name}</span>
) : (
<>
{locationName(contract.originId)} → {locationName(contract.destinationId)}
</>
)}
{suggested && (
<span className="ml-2 rounded bg-amber-500/20 px-1.5 py-0.5 text-xs text-amber-300">
Suggested
</span>
)}
</span>
<span className="font-semibold tabular-nums text-emerald-400">
{formatCredits(contract.payout)}
<span className="text-sm text-slate-400">
{cargoName(contract.cargoTypeId)} · {contract.massTonnes} t
</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,
contract.massTonnes,
now,
),
)}
</span>
</span>
{scripted && (
<p className="mt-1 text-xs text-fuchsia-200">
{locationName(contract.originId)} → {locationName(contract.destinationId)} ·
double pay, no questions asked
</p>
)}
<div className="mt-2 flex items-center justify-between gap-2">
<dl className="grid flex-1 grid-cols-3 text-sm">
<div>
<dt className="text-xs text-slate-400">Pay</dt>
<dd aria-label="Pay" className="font-semibold tabular-nums text-emerald-400">
{formatCredits(contract.payout)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">Time</dt>
<dd aria-label="Trip time" className="tabular-nums">
{formatDuration(tripMs)}
</dd>
</div>
<div>
<dt className="text-xs text-slate-400">XP</dt>
<dd aria-label="XP" className="tabular-nums">
+{xp}
</dd>
</div>
</dl>
<button
type="button"
disabled={runInFlight || fly.isPending}

View File

@ -1,102 +1,110 @@
import { content } from "@deadhead/content";
import type { Contract } from "@deadhead/schema";
import { MS_PER_HOUR, realToGame, routeDistance, travelTimeGameHours } from "@deadhead/sim";
import {
deliveryXp,
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 { afterEach, beforeEach, describe, expect, it, vi } 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";
import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const loaner = content.shipModel("company-courier")!;
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 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);
});
});

View File

@ -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