Step 10A.3: add tutorial board v2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 10:54:44 -04:00
parent 957c54cc2c
commit dd0b26813f
10 changed files with 164 additions and 69 deletions

View File

@ -496,3 +496,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3). - **`travelTimeGameHours` takes mass and config as arguments**, like the other sim functions, rather than reading a default bundle. Dispatch records use the contract's mass, or 0 for a reposition, so a reposition's time is exactly the old formula's. Arrival times already stored are unaffected (CR-001 §3).
- **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record. - **Client trip-time predictions pass the contract's mass,** so the dispatch sheet and tutorial board match what the server will record.
## Step 10A.3
- **The tutorial board has no randomness:** location, run number and period fully determine it, so the world-seed input was dropped. Contract ids are `tutorial:<location>:<run>:<period>:<slot>` (run is 1-based), unique per run and period.
- **Slot order is light, medium, full;** a scripted contract keyed to the upcoming run (`runsCompleted + 1`) replaces the medium slot, keeps the medium mass, uses its own cargo, and pays `round(normal payout × tutorial share × pay multiplier)`.
- **Contracts carry an optional `scriptedId`** (sim `Contract` and API `contractSchema`) so the client can style scripted contracts and look up their flavor message.
- **`SimContent` includes `scriptedTutorialContracts`;** the content bundle already provides it.
- **Temporary:** the server passes `runsCompleted: 0` until Step 10A.7 adds the counter, so every server-generated board is a first-run board for now.

View File

@ -90,10 +90,9 @@ export async function tutorialBoard(
tx, tx,
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex }, { playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
() => () =>
generateTutorialBoard( // Run counting arrives with the database change (Step 10A.7); until then
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed }, // every board is the first run's.
content, generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content),
),
now, now,
); );
return boardView(tx, playerId, contracts, periodIndex); return boardView(tx, playerId, contracts, periodIndex);

View File

@ -43,7 +43,7 @@ describe("tutorial dispatch", () => {
}); });
const travelMs = const travelMs =
(travelTimeGameHours( (travelTimeGameHours(
content.shipModel("courier")!, content.shipModel("company-courier")!,
dispatch.distanceMillionKm, dispatch.distanceMillionKm,
contract!.massTonnes, contract!.massTonnes,
content.config, content.config,

View File

@ -31,7 +31,7 @@ const board = {
periodEndsAt: NOW + 3_600_000, periodEndsAt: NOW + 3_600_000,
}; };
/** The trip time the sim predicts for the Courier loaner, Earth→Moon, departing now. */ /** The trip time the sim predicts for the Company Courier loaner, Earth→Moon, departing now. */
function expectedTripTime(now: number, massTonnes: number) { function expectedTripTime(now: number, massTonnes: number) {
const distance = routeDistance( const distance = routeDistance(
content.location("earth")!, content.location("earth")!,
@ -39,7 +39,7 @@ function expectedTripTime(now: number, massTonnes: number) {
realToGame(now, content.config), realToGame(now, content.config),
); );
const hours = travelTimeGameHours( const hours = travelTimeGameHours(
content.shipModel("courier")!, content.shipModel("company-courier")!,
distance, distance,
massTonnes, massTonnes,
content.config, content.config,

View File

@ -108,7 +108,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
- [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner. - [x] **Step 10A.1 — Content: new config and loaner ship.** `purchasable` on ShipModel; "Company Courier" (not purchasable, 6M km/h, 0.3 h overhead, 20 t); config: `tutorialLoanerShipModelId`, `tutorialRunsBeforeDeal` 4, `tutorialBoardSize` 3, `tutorialMassTonnes` 5/12/20, `handlingGameHoursPerTonne` 0.02, `loanRepaymentShare` 0.3, `dealLoanAmount` 5000, `startingCredits` 500. "Unmarked crates" scripted contract (run 3, ×2) and six onboarding goals (`src/data/onboarding.ts`). Step 2.5 test replaced per CR §6; Market lists purchasable models only and the server will not sell the loaner.
- [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated. - [x] **Step 10A.2 — Sim: handling time.** `travelTimeGameHours(ship, distance, massTonnes, config)` adds `mass × handlingGameHoursPerTonne`; dispatch records pass the contract mass (0 for repositions); client predictions pass contract mass. Step 3.5 hand-calculated test updated.
- [ ] Step 10A.3 — Sim: tutorial board v2 - [x] **Step 10A.3 — Sim: tutorial board v2.** `generateTutorialBoard({ loanerLocationId, runsCompleted, periodIndex })`: light/medium/full Standard contracts at the tutorial share; a scripted contract for the upcoming run replaces the medium slot (`scriptedId` on the contract, at its pay multiplier). Loaner is `config.tutorialLoanerShipModelId`. Step 3.10 tests replaced. Server passes `runsCompleted: 0` until Step 10A.7.
- [ ] Step 10A.4 — Sim: loan repayment - [ ] Step 10A.4 — Sim: loan repayment
- [ ] Step 10A.5 — Sim: onboarding goal evaluation - [ ] Step 10A.5 — Sim: onboarding goal evaluation
- [ ] Step 10A.6 — Schema and database - [ ] Step 10A.6 — Schema and database

View File

@ -80,6 +80,8 @@ export const contractSchema = z.object({
offeredAt: timeSchema, offeredAt: timeSchema,
expiresAt: timeSchema, expiresAt: timeSchema,
firstContact: z.boolean(), firstContact: z.boolean(),
/** Set on a scripted tutorial contract (decision 0.5.33). */
scriptedId: idSchema.optional(),
}); });
export type Contract = z.infer<typeof contractSchema>; export type Contract = z.infer<typeof contractSchema>;

View File

@ -1,4 +1,11 @@
import type { CargoType, GameConfig, Location, ShipModel, Skill } from "@deadhead/content"; import type {
CargoType,
GameConfig,
Location,
ScriptedTutorialContract,
ShipModel,
Skill,
} from "@deadhead/content";
import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock"; import { MS_PER_MINUTE, type RealTime, realToGame } from "./clock";
import { contractPayout, type Contract } from "./contract"; import { contractPayout, type Contract } from "./contract";
import { routeDistance } from "./distance"; import { routeDistance } from "./distance";
@ -10,6 +17,7 @@ export interface SimContent {
cargoTypes: readonly CargoType[]; cargoTypes: readonly CargoType[];
skills: readonly Skill[]; skills: readonly Skill[];
shipModels: readonly ShipModel[]; shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
config: GameConfig; config: GameConfig;
} }

View File

@ -18,6 +18,8 @@ export interface Contract {
/** End of the board period the contract was offered in. */ /** End of the board period the contract was offered in. */
expiresAt: RealTime; expiresAt: RealTime;
firstContact: boolean; firstContact: boolean;
/** Set on a scripted tutorial contract (decision 0.5.33): its content id. */
scriptedId?: string;
} }
export interface PayoutInput { export interface PayoutInput {

View File

@ -1,13 +1,13 @@
import type { Location, ShipModel } from "@deadhead/content"; import type { Location, ShipModel } from "@deadhead/content";
import { buildContract, standardCargo, type SimContent } from "./board"; import { buildContract, standardCargo, type SimContent } from "./board";
import type { Contract } from "./contract"; import type { Contract } from "./contract";
import { createRng, deriveSeed, type Seed } from "./rng";
/** The tutorial loaner is the company's Courier. */ /** The tutorial loaner: the content's company ship (decision 0.5.29). */
export function loanerModel(content: SimContent): ShipModel { export function loanerModel(content: SimContent): ShipModel {
const courier = content.shipModels.find((s) => s.archetype === "courier"); const id = content.config.tutorialLoanerShipModelId;
if (!courier) throw new Error("Content has no Courier ship model"); const loaner = content.shipModels.find((s) => s.id === id);
return courier; if (!loaner) throw new Error(`Content has no loaner ship model "${id}"`);
return loaner;
} }
export type TutorialLocationId = "earth" | "moon"; export type TutorialLocationId = "earth" | "moon";
@ -15,8 +15,9 @@ export type TutorialLocationId = "earth" | "moon";
export interface TutorialBoardInput { export interface TutorialBoardInput {
/** Where the loaner currently is; contracts start here (decision 0.5.3). */ /** Where the loaner currently is; contracts start here (decision 0.5.3). */
loanerLocationId: TutorialLocationId; loanerLocationId: TutorialLocationId;
/** Tutorial runs already flown; the board is for run `runsCompleted + 1`. */
runsCompleted: number;
periodIndex: number; periodIndex: number;
worldSeed: Seed;
} }
function location(id: string, content: SimContent): Location { function location(id: string, content: SimContent): Location {
@ -26,33 +27,47 @@ function location(id: string, content: SimContent): Location {
} }
/** /**
* The tutorial "company board": Earth↔Moon only, Standard cargo sized to fit * The tutorial "company board" (CR-001, Step 10A.3): three Standard contracts
* the loaner, starting at the loaner's location. Each payout is the tutorial * from the loaner's location to the other end of the Earth–Moon route, at the
* share of the equivalent normal payout, rounded to whole credits. * light, medium and full mass tiers, each paying the tutorial share. A scripted
* contract for the upcoming run (decision 0.5.33) replaces the medium slot.
* The board is fully determined by its inputs.
*/ */
export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] { export function generateTutorialBoard(input: TutorialBoardInput, content: SimContent): Contract[] {
const { loanerLocationId, periodIndex, worldSeed } = input; const { loanerLocationId, runsCompleted, periodIndex } = input;
const { config } = content; const { config } = content;
const origin = location(loanerLocationId, content); const origin = location(loanerLocationId, content);
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content); const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
const cargoType = standardCargo(content); const tiers = config.tutorialMassTonnes;
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes); const run = runsCompleted + 1;
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`)); const scripted = content.scriptedTutorialContracts.find((s) => s.run === run);
return Array.from({ length: config.tutorialBoardSize }, (_, i) => { const slot = (i: number, massTonnes: number): Contract => {
const special = i === 1 ? scripted : undefined;
const cargoType = special
? content.cargoTypes.find((c) => c.id === special.cargoTypeId)
: standardCargo(content);
if (!cargoType) throw new Error(`Unknown cargo ${special?.cargoTypeId}`);
const contract = buildContract( const contract = buildContract(
{ {
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`, id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
origin, origin,
destination, destination,
cargoType, cargoType,
tier: 0, tier: 0,
massTonnes: rng.int(config.contractMassTonnes.min, maxMass), massTonnes,
firstContact: false, firstContact: false,
periodIndex, periodIndex,
}, },
content, content,
); );
return { ...contract, payout: Math.round(contract.payout * config.tutorialPayoutShare) }; const share = contract.payout * config.tutorialPayoutShare * (special?.payMultiplier ?? 1);
}); return {
...contract,
payout: Math.round(share),
...(special ? { scriptedId: special.id } : {}),
};
};
return [slot(0, tiers.light), slot(1, tiers.medium), slot(2, tiers.full)];
} }

View File

@ -2,67 +2,128 @@ import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
boardPeriodIndex, boardPeriodIndex,
contractPayout,
deliveryXp,
generateTutorialBoard, generateTutorialBoard,
loanerModel, loanerModel,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type Contract,
type TutorialBoardInput, type TutorialBoardInput,
} from "../src/index"; } from "../src/index";
import { buildContract } from "../src/board";
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config); const config = content.config;
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 }; const START = Date.parse("2100-01-01T00:00:00Z");
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" }; const periodIndex = boardPeriodIndex(START, config);
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", runsCompleted: 0, periodIndex };
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon", runsCompleted: 1 };
const loaner = loanerModel(content);
describe("tutorial board", () => { function distanceOf(contract: Contract): number {
it("offers only Earth→Moon when the loaner is at Earth", () => { return routeDistance(
for (const c of generateTutorialBoard(atEarth, content)) { content.location(contract.originId)!,
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]); content.location(contract.destinationId)!,
realToGame(contract.offeredAt, config),
);
}
/** The normal (non-tutorial) payout for the same Standard cargo and route. */
function normalPayout(contract: Contract): number {
return contractPayout(
{
cargoType: content.cargoType("standard")!,
tier: 0,
massTonnes: contract.massTonnes,
distanceMillionKm: distanceOf(contract),
firstContact: false,
},
config,
);
}
/** Real minutes for the loaner to fly a contract, departing at its offer time. */
function tripMinutes(contract: Contract): number {
const hours = travelTimeGameHours(loaner, distanceOf(contract), contract.massTonnes, config);
return (hours * MS_PER_HOUR) / config.timeCompression / 60_000;
}
function xpPerMinute(contract: Contract): number {
return deliveryXp(distanceOf(contract), contract.tier, config) / tripMinutes(contract);
}
describe("tutorial board v2", () => {
it("uses the Company Courier as the loaner", () => {
expect(loaner.id).toBe("company-courier");
});
it("has exactly three Standard contracts at three different masses within the loaner's capacity", () => {
const board = generateTutorialBoard(atEarth, content);
expect(board).toHaveLength(3);
expect(board.map((c) => c.massTonnes)).toEqual([
config.tutorialMassTonnes.light,
config.tutorialMassTonnes.medium,
config.tutorialMassTonnes.full,
]);
expect(new Set(board.map((c) => c.massTonnes)).size).toBe(3);
for (const c of board) {
expect(c.cargoTypeId).toBe("standard");
expect(c.tier).toBe(0);
expect(c.requiredSkill).toBeNull();
expect(c.massTonnes).toBeLessThanOrEqual(loaner.cargoCapacityTonnes);
} }
}); });
it("offers only Moon→Earth when the loaner is at the Moon", () => { it("runs from the loaner's location to the other end of the Earth–Moon route", () => {
for (const c of generateTutorialBoard(atEarth, content)) {
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
}
for (const c of generateTutorialBoard(atMoon, content)) { for (const c of generateTutorialBoard(atMoon, content)) {
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]); expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
} }
}); });
it("has the configured size, Standard cargo only, sized to fit the loaner", () => { it("pays the tutorial share of the normal payout", () => {
const board = generateTutorialBoard(atEarth, content); for (const c of generateTutorialBoard(atEarth, content)) {
expect(board).toHaveLength(content.config.tutorialBoardSize); expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
for (const c of board) {
expect(c.cargoTypeId).toBe("standard");
expect(c.tier).toBe(0);
expect(c.requiredSkill).toBeNull();
expect(c.requiredLongHaulLevel).toBe(0);
expect(c.firstContact).toBe(false);
expect(c.massTonnes).toBeLessThanOrEqual(loanerModel(content).cargoCapacityTonnes);
} }
}); });
it("pays exactly the tutorial share of the equivalent normal payout", () => { it("gives an identical board for the same inputs, and a new one per run", () => {
for (const input of [atEarth, atMoon]) {
for (const c of generateTutorialBoard(input, content)) {
const normal = buildContract(
{
id: c.id,
origin: content.location(c.originId)!,
destination: content.location(c.destinationId)!,
cargoType: content.cargoType("standard")!,
tier: 0,
massTonnes: c.massTonnes,
firstContact: false,
periodIndex,
},
content,
);
expect(c.payout).toBe(Math.round(normal.payout * content.config.tutorialPayoutShare));
expect(c.payout).toBeLessThan(normal.payout);
}
}
});
it("is deterministic", () => {
expect(generateTutorialBoard(atEarth, content)).toEqual( expect(generateTutorialBoard(atEarth, content)).toEqual(
generateTutorialBoard(atEarth, content), generateTutorialBoard(atEarth, content),
); );
const nextRun = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
expect(nextRun.map((c) => c.id)).not.toEqual(
generateTutorialBoard(atEarth, content).map((c) => c.id),
);
});
it("puts Unmarked crates in the medium slot at double pay with two runs completed, and never otherwise", () => {
const [, crates] = generateTutorialBoard({ ...atEarth, runsCompleted: 2 }, content);
expect(crates).toMatchObject({
scriptedId: "unmarked-crates",
massTonnes: config.tutorialMassTonnes.medium,
});
expect(crates!.payout).toBe(Math.round(normalPayout(crates!) * config.tutorialPayoutShare * 2));
for (const runsCompleted of [0, 1, 3, 4, 7]) {
const board = generateTutorialBoard({ ...atEarth, runsCompleted }, content);
expect(board.some((c) => c.scriptedId !== undefined)).toBe(false);
}
});
it("trades XP per minute (light) against credits per minute (full)", () => {
const [light, , full] = generateTutorialBoard(atEarth, content);
expect(xpPerMinute(light!)).toBeGreaterThan(xpPerMinute(full!));
expect(full!.payout / tripMinutes(full!)).toBeGreaterThan(light!.payout / tripMinutes(light!));
});
it("keeps the four slowest possible tutorial runs under 6 real minutes in total", () => {
const slowest = Math.max(
...[atEarth, atMoon].map((input) => tripMinutes(generateTutorialBoard(input, content)[2]!)),
);
expect(4 * slowest).toBeLessThan(6);
}); });
}); });