From 4537773560a450f795df5cd6997970596eec61d6 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 16:06:35 -0400 Subject: [PATCH] Step 17.2: sim: tutorial XP and the level-1 guarantee Tutorial runs earn their XP times the tutorial XP multiplier. At 2x the five cheapest runs make exactly the 100 XP for level 1, so the avatar always has a skill point at the deal. Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 4 ++ memory-bank/progress.md | 2 +- packages/sim/src/index.ts | 2 +- packages/sim/src/resolution.ts | 11 +++- packages/sim/test/tutorial-xp.test.ts | 74 +++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 packages/sim/test/tutorial-xp.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index e79c4ee..a95b3b9 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1558,3 +1558,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## Step 17.1 - **The "loaner at Earth" condition went with the run count** (it is Step 17.4's rule): with five runs ending at the Moon, the deal could never come otherwise. No placement code was needed: accepting the deal assigns the avatar to the Courier at the Earth bay, and an assigned pilot is wherever their ship is (decision 0.5.27). + +## Step 17.2 + +- **The tutorial XP multiplier stays at 2, with no margin**: five of the cheapest runs (10 XP each, doubled) make exactly the 100 XP that level 1 needs. Any change that lowers tutorial XP (cheaper XP per run, a sixth threshold below 100 removed) breaks the guarantee, and the test will say so. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 7cdec22..8850c5a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -415,7 +415,7 @@ Spec: `memory-bank/change-requests/CR-016-venus-io-europa-belt.md` (received as Spec: `memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md` (received as "CR-016 … Phase 16"). New decisions recorded as plan §0.5.106–0.5.109. - [x] **Step 17.1 — Content: tutorial settings.** GameConfig: `tutorialRunsBeforeDeal` 5 (was 4) and a new `tutorialXpMultiplier` 2 (at least 1; used from 17.2). Pulled forward so the game keeps working: the deal no longer waits for the loaner to be back at Earth (decision 0.5.108) in the engine (`isDealAvailable`) and the balance simulator, since five runs end at the Moon; accepting it already puts the avatar on the Courier at the Earth bay. Balance medians: deal 3.7 min, first purchase 92.6 (45–95), discovery 49.6, loan 138.5, four ships 136 vs 138. Tests: `packages/content/test/config.test.ts` (five runs at double XP; a multiplier under 1 is rejected); the engine's deal tests now expect the deal after five runs ending at the Moon (not after four), after three with the three-run config, and accepting with the loaner at the Moon to put the avatar and the Courier at Earth. -- [ ] Step 17.2 — Sim: tutorial XP and the level-1 guarantee +- [x] **Step 17.2 — Sim: tutorial XP and the level-1 guarantee.** `deliveryXpFor(record, tier, content)` in `resolution.ts`: a delivery's XP, × `tutorialXpMultiplier` (rounded) on tutorial runs; `resolveDispatch` uses it. The multiplier stays at 2: an Earth–Moon Standard run is 10 XP, so the cheapest five runs make exactly 100 XP, the level-1 threshold (a level gives 2 points). Tests (`packages/sim/test/tutorial-xp.test.ts`): a tutorial run pays exactly 2 × a normal run's XP for the same route and cargo; over 50 board periods, taking the lowest-XP job on every board for five runs (the scripted "Unmarked crates" board included) always reaches level 1 with at least one point. - [ ] Step 17.3 — Sim: tutorial timing - [ ] Step 17.4 — Engine: deal availability and placement - [ ] Step 17.5 — Client: Pilots tab badge diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index cbb2f3e..2f9125b 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -85,7 +85,7 @@ export type { GoalProgress } from "./goals"; export { splitLoanRepayment } from "./loan"; export type { LoanSplit } from "./loan"; -export { resolveDispatch } from "./resolution"; +export { deliveryXpFor, resolveDispatch } from "./resolution"; export type { RefuelPolicy, Resolution, ResolveInput, ResolveResult, TopUp } from "./resolution"; export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty"; diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index 00fd64c..a9f7861 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -102,6 +102,15 @@ function dutyOutcome(input: ResolveInput, content: SimContent) { return { pilotDutyHours: duty, pilotRestEndsAt: null }; } +/** + * XP for a delivery (Step 6.8); tutorial runs earn it × the tutorial XP + * multiplier, so the avatar has a skill point by the deal (decision 0.5.107). + */ +export function deliveryXpFor(record: DispatchRecord, tier: number, content: SimContent): number { + const xp = deliveryXp(record.distanceMillionKm, tier, content.config); + return record.tutorial ? Math.round(xp * content.config.tutorialXpMultiplier) : xp; +} + /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */ export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input; @@ -144,7 +153,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol topUp: topUpOnLanding(input, net, content), pilotPay, loanRepayment: repayment, - pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), + pilotXp: deliveryXpFor(record, contract.tier, content), discoversDestination: !discoveredIds.has(record.destinationId), }, }; diff --git a/packages/sim/test/tutorial-xp.test.ts b/packages/sim/test/tutorial-xp.test.ts new file mode 100644 index 0000000..a6738bb --- /dev/null +++ b/packages/sim/test/tutorial-xp.test.ts @@ -0,0 +1,74 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createDispatchRecord, + deliveryXp, + deliveryXpFor, + generateTutorialBoard, + pilotLevel, + routeDistance, + skillPointsAvailable, + type Contract, +} from "../src/index"; + +const { config } = content; +const loc = (id: string) => content.location(id)!; +const now = Date.parse("2100-01-01T00:30:00Z"); +const pilot = { + id: "avatar", + isAvatar: true, + assignedShipId: null, + inFlight: false, + skills: {}, + restEndsAt: null, +}; +const loaner = { id: "loaner", modelId: "company-courier", locationId: "earth", inFlight: false }; + +/** The XP a contract would pay on a tutorial run. */ +function tutorialXp(contract: Contract): number { + const record = createDispatchRecord( + { + id: "t", + ship: { ...loaner, locationId: contract.originId }, + pilot, + contract, + tutorial: true, + now, + worldSeed: 1, + }, + content, + ); + return deliveryXpFor(record, contract.tier, content); +} + +describe("tutorial XP (Step 17.2, decision 0.5.107)", () => { + it("grants exactly the multiplier times a normal run's XP for the same route and cargo", () => { + const [contract] = generateTutorialBoard( + { loanerLocationId: "earth", runsCompleted: 0, periodIndex: 0 }, + content, + ); + const normal = deliveryXp(routeDistance(loc("earth"), loc("moon")), contract!.tier, config); + expect(tutorialXp(contract!)).toBe(normal * config.tutorialXpMultiplier); + expect(config.tutorialXpMultiplier).toBeGreaterThanOrEqual(2); + }); + + it("brings the avatar to level 1 with a skill point after five runs of the lowest-XP jobs", () => { + // Every board period gives a different board; take the worst case of many. + for (let periodIndex = 0; periodIndex < 50; periodIndex++) { + let xp = 0; + let at: "earth" | "moon" = "earth"; + for (let run = 0; run < config.tutorialRunsBeforeDeal; run++) { + const board = generateTutorialBoard( + { loanerLocationId: at, runsCompleted: run, periodIndex: periodIndex + run }, + content, + ); + // Run 2 offers the scripted "Unmarked crates" job; the cheapest choice is still taken. + xp += Math.min(...board.map(tutorialXp)); + at = at === "earth" ? "moon" : "earth"; + } + const level = pilotLevel(xp, config); + expect(level, `period ${periodIndex}: ${xp} XP`).toBeGreaterThanOrEqual(1); + expect(skillPointsAvailable(level, 0, config)).toBeGreaterThanOrEqual(1); + } + }); +});