From dd0b26813ffe9ca1dbe8765dcd1b2dcaa6cc99bc Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 10:54:44 -0400 Subject: [PATCH] Step 10A.3: add tutorial board v2 Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ apps/server/src/game/boards.ts | 7 +- apps/server/test/tutorial-dispatch.test.ts | 2 +- apps/web/test/tutorial-board.test.tsx | 4 +- memory-bank/progress.md | 2 +- packages/schema/src/entities.ts | 2 + packages/sim/src/board.ts | 10 +- packages/sim/src/contract.ts | 2 + packages/sim/src/tutorial.ts | 51 +++++--- packages/sim/test/tutorial.test.ts | 145 +++++++++++++++------ 10 files changed, 164 insertions(+), 69 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 5bb1bfc..3a3dc58 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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::::` (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. diff --git a/apps/server/src/game/boards.ts b/apps/server/src/game/boards.ts index 1f9ec20..f61a6ce 100644 --- a/apps/server/src/game/boards.ts +++ b/apps/server/src/game/boards.ts @@ -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); diff --git a/apps/server/test/tutorial-dispatch.test.ts b/apps/server/test/tutorial-dispatch.test.ts index 4490f70..9ee182c 100644 --- a/apps/server/test/tutorial-dispatch.test.ts +++ b/apps/server/test/tutorial-dispatch.test.ts @@ -43,7 +43,7 @@ describe("tutorial dispatch", () => { }); const travelMs = (travelTimeGameHours( - content.shipModel("courier")!, + content.shipModel("company-courier")!, dispatch.distanceMillionKm, contract!.massTonnes, content.config, diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 0c61091..64718f7 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -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, diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 4a10545..78792b9 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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 diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index e1d9a5b..64ed565 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -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; diff --git a/packages/sim/src/board.ts b/packages/sim/src/board.ts index dad7d5b..5d19345 100644 --- a/packages/sim/src/board.ts +++ b/packages/sim/src/board.ts @@ -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; } diff --git a/packages/sim/src/contract.ts b/packages/sim/src/contract.ts index c2da6f0..f8c316e 100644 --- a/packages/sim/src/contract.ts +++ b/packages/sim/src/contract.ts @@ -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 { diff --git a/packages/sim/src/tutorial.ts b/packages/sim/src/tutorial.ts index e1cb864..0292813 100644 --- a/packages/sim/src/tutorial.ts +++ b/packages/sim/src/tutorial.ts @@ -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)]; } diff --git a/packages/sim/test/tutorial.test.ts b/packages/sim/test/tutorial.test.ts index cfc8aef..b710684 100644 --- a/packages/sim/test/tutorial.test.ts +++ b/packages/sim/test/tutorial.test.ts @@ -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); }); });