Step 17.2: sim: tutorial XP and the level-1 guarantee
CI / check (push) Has been cancelled Details

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 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 16:06:35 -04:00
parent 35b12e50da
commit 4537773560
5 changed files with 90 additions and 3 deletions

View File

@ -1558,3 +1558,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 17.1 ## 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). - **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.

View File

@ -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. 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. - [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.3 — Sim: tutorial timing
- [ ] Step 17.4 — Engine: deal availability and placement - [ ] Step 17.4 — Engine: deal availability and placement
- [ ] Step 17.5 — Client: Pilots tab badge - [ ] Step 17.5 — Client: Pilots tab badge

View File

@ -85,7 +85,7 @@ export type { GoalProgress } from "./goals";
export { splitLoanRepayment } from "./loan"; export { splitLoanRepayment } from "./loan";
export type { LoanSplit } 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 type { RefuelPolicy, Resolution, ResolveInput, ResolveResult, TopUp } from "./resolution";
export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty"; export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty";

View File

@ -102,6 +102,15 @@ function dutyOutcome(input: ResolveInput, content: SimContent) {
return { pilotDutyHours: duty, pilotRestEndsAt: null }; 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. */ /** Resolves a dispatch once it has arrived. Same inputs always give the same result. */
export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult { export function resolveDispatch(input: ResolveInput, content: SimContent): ResolveResult {
const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input; const { record, pilotIsAvatar, discoveredIds, loanBalance, now } = input;
@ -144,7 +153,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol
topUp: topUpOnLanding(input, net, content), topUp: topUpOnLanding(input, net, content),
pilotPay, pilotPay,
loanRepayment: repayment, loanRepayment: repayment,
pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), pilotXp: deliveryXpFor(record, contract.tier, content),
discoversDestination: !discoveredIds.has(record.destinationId), discoversDestination: !discoveredIds.has(record.destinationId),
}, },
}; };

View File

@ -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);
}
});
});