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).
- **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,
{ playerId, kind: "tutorial", locationId: loanerLocationId, periodIndex },
() =>
generateTutorialBoard(
{ loanerLocationId, periodIndex, worldSeed: player.worldSeed },
content,
),
// Run counting arrives with the database change (Step 10A.7); until then
// every board is the first run's.
generateTutorialBoard({ loanerLocationId, runsCompleted: 0, periodIndex }, content),
now,
);
return boardView(tx, playerId, contracts, periodIndex);

View File

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

View File

@ -31,7 +31,7 @@ const board = {
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) {
const distance = routeDistance(
content.location("earth")!,
@ -39,7 +39,7 @@ function expectedTripTime(now: number, massTonnes: number) {
realToGame(now, content.config),
);
const hours = travelTimeGameHours(
content.shipModel("courier")!,
content.shipModel("company-courier")!,
distance,
massTonnes,
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.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.5 — Sim: onboarding goal evaluation
- [ ] Step 10A.6 — Schema and database

View File

@ -80,6 +80,8 @@ export const contractSchema = z.object({
offeredAt: timeSchema,
expiresAt: timeSchema,
firstContact: z.boolean(),
/** Set on a scripted tutorial contract (decision 0.5.33). */
scriptedId: idSchema.optional(),
});
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 { contractPayout, type Contract } from "./contract";
import { routeDistance } from "./distance";
@ -10,6 +17,7 @@ export interface SimContent {
cargoTypes: readonly CargoType[];
skills: readonly Skill[];
shipModels: readonly ShipModel[];
scriptedTutorialContracts: readonly ScriptedTutorialContract[];
config: GameConfig;
}

View File

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

View File

@ -1,13 +1,13 @@
import type { Location, ShipModel } from "@deadhead/content";
import { buildContract, standardCargo, type SimContent } from "./board";
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 {
const courier = content.shipModels.find((s) => s.archetype === "courier");
if (!courier) throw new Error("Content has no Courier ship model");
return courier;
const id = content.config.tutorialLoanerShipModelId;
const loaner = content.shipModels.find((s) => s.id === id);
if (!loaner) throw new Error(`Content has no loaner ship model "${id}"`);
return loaner;
}
export type TutorialLocationId = "earth" | "moon";
@ -15,8 +15,9 @@ export type TutorialLocationId = "earth" | "moon";
export interface TutorialBoardInput {
/** Where the loaner currently is; contracts start here (decision 0.5.3). */
loanerLocationId: TutorialLocationId;
/** Tutorial runs already flown; the board is for run `runsCompleted + 1`. */
runsCompleted: number;
periodIndex: number;
worldSeed: Seed;
}
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 loaner, starting at the loaner's location. Each payout is the tutorial
* share of the equivalent normal payout, rounded to whole credits.
* The tutorial "company board" (CR-001, Step 10A.3): three Standard contracts
* from the loaner's location to the other end of the Earth–Moon route, at the
* 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[] {
const { loanerLocationId, periodIndex, worldSeed } = input;
const { loanerLocationId, runsCompleted, periodIndex } = input;
const { config } = content;
const origin = location(loanerLocationId, content);
const destination = location(loanerLocationId === "earth" ? "moon" : "earth", content);
const cargoType = standardCargo(content);
const maxMass = Math.min(config.contractMassTonnes.max, loanerModel(content).cargoCapacityTonnes);
const rng = createRng(deriveSeed(worldSeed, `tutorial:${loanerLocationId}:${periodIndex}`));
const tiers = config.tutorialMassTonnes;
const run = runsCompleted + 1;
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(
{
id: `tutorial:${loanerLocationId}:${periodIndex}:${i}`,
id: `tutorial:${loanerLocationId}:${run}:${periodIndex}:${i}`,
origin,
destination,
cargoType,
tier: 0,
massTonnes: rng.int(config.contractMassTonnes.min, maxMass),
massTonnes,
firstContact: false,
periodIndex,
},
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 {
boardPeriodIndex,
contractPayout,
deliveryXp,
generateTutorialBoard,
loanerModel,
MS_PER_HOUR,
realToGame,
routeDistance,
travelTimeGameHours,
type Contract,
type TutorialBoardInput,
} from "../src/index";
import { buildContract } from "../src/board";
const periodIndex = boardPeriodIndex(Date.parse("2100-01-01T00:00:00Z"), content.config);
const atEarth: TutorialBoardInput = { loanerLocationId: "earth", periodIndex, worldSeed: 99 };
const atMoon: TutorialBoardInput = { ...atEarth, loanerLocationId: "moon" };
const config = content.config;
const START = Date.parse("2100-01-01T00:00:00Z");
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", () => {
it("offers only Earth→Moon when the loaner is at Earth", () => {
for (const c of generateTutorialBoard(atEarth, content)) {
expect([c.originId, c.destinationId]).toEqual(["earth", "moon"]);
function distanceOf(contract: Contract): number {
return routeDistance(
content.location(contract.originId)!,
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)) {
expect([c.originId, c.destinationId]).toEqual(["moon", "earth"]);
}
});
it("has the configured size, Standard cargo only, sized to fit the loaner", () => {
const board = generateTutorialBoard(atEarth, content);
expect(board).toHaveLength(content.config.tutorialBoardSize);
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 the tutorial share of the normal payout", () => {
for (const c of generateTutorialBoard(atEarth, content)) {
expect(c.payout).toBe(Math.round(normalPayout(c) * config.tutorialPayoutShare));
}
});
it("pays exactly the tutorial share of the equivalent normal payout", () => {
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", () => {
it("gives an identical board for the same inputs, and a new one per run", () => {
expect(generateTutorialBoard(atEarth, content)).toEqual(
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);
});
});