Step 17.4: engine: deal availability and placement
CI / check (push) Has been cancelled Details

Tests the five-run deal: not after four runs, available after the fifth
at the Moon, both pilots and ships at Earth after accepting, and an
older save with four runs done needing one more. No save migration is
needed since the deal is worked out from the run count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 16:09:21 -04:00
parent d6ba26a9c1
commit 1520cb30fd
3 changed files with 73 additions and 1 deletions

View File

@ -1562,3 +1562,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## Step 17.2 ## 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. - **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.
## Step 17.4
- **No save migration** (CR-017 §3 asks for one): a tutorial save already stores only its completed-run count, and whether the deal is on offer is worked out from the current config, so an older save with four runs needs one more run with no change to its data.

View File

@ -417,7 +417,7 @@ Spec: `memory-bank/change-requests/CR-017-tutorial-xp-skill-prompt.md` (received
- [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.
- [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. - [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.
- [x] **Step 17.3 — Sim: tutorial timing.** The CR-001 Step 10A.3 timing test now covers five runs (Earth → Moon → Earth → Moon → Earth → Moon), each taking the slowest job on its board, and requires the total under 7 real minutes (it was four runs under 6), keeping "own a ship in under 10 minutes". Test: `packages/sim/test/tutorial.test.ts`. - [x] **Step 17.3 — Sim: tutorial timing.** The CR-001 Step 10A.3 timing test now covers five runs (Earth → Moon → Earth → Moon → Earth → Moon), each taking the slowest job on its board, and requires the total under 7 real minutes (it was four runs under 6), keeping "own a ship in under 10 minutes". Test: `packages/sim/test/tutorial.test.ts`.
- [ ] Step 17.4 — Engine: deal availability and placement - [x] **Step 17.4 — Engine: deal availability and placement.** (The rule itself changed in 17.1.) No save migration and no save-version change: the save keeps the completed-run count and the deal is computed from it, so a save with four runs done now simply needs a fifth. Tests (`packages/engine/test/deal-five-runs.test.ts`): after four runs the deal isn't on offer (and accepting is refused), after the fifth, ending at the Moon, it is; accepting puts the avatar on the Courier and Rosa on the Clipper, both at Earth, with no loaner left; a save from before CR-017 with four runs done, opened on this build, still has four runs and no deal until one more run.
- [ ] Step 17.5 — Client: Pilots tab badge - [ ] Step 17.5 — Client: Pilots tab badge
- [ ] Step 17.6 — Client: skill point pop-up - [ ] Step 17.6 — Client: skill point pop-up
- [ ] Step 17.7 — Plan amendments and regression - [ ] Step 17.7 — Plan amendments and regression

View File

@ -0,0 +1,68 @@
import { content } from "@deadhead/content";
import { describe, expect, it } from "vitest";
import { encodeSave, GameEngine, MemoryBackend, SaveStorage } from "../src/index";
import { flyTutorialRun, getState, readSave, signUp, TestClock, useTestApp } from "./support";
const testApp = useTestApp();
describe("the deal after five runs (Step 17.4, decisions 0.5.106, 0.5.108)", () => {
it("comes after the fifth run, at the Moon, not after the fourth", async () => {
const { app } = testApp();
await signUp(app);
for (let run = 1; run <= 4; run++) await flyTutorialRun(testApp());
expect((await getState(app)).tutorial).toMatchObject({
runsCompleted: 4,
dealAvailable: false,
});
await expect(app.call("acceptDeal", {})).rejects.toMatchObject({ code: "deal-not-available" });
const afterFive = await flyTutorialRun(testApp());
expect(afterFive.tutorial).toMatchObject({
runsCompleted: 5,
loanerLocationId: "moon",
dealAvailable: true,
});
});
it("puts the avatar on the Courier and Rosa on the Clipper, both at Earth, with no loaner", async () => {
const { app } = testApp();
await signUp(app);
for (let run = 1; run <= 5; run++) await flyTutorialRun(testApp());
const state = await app.call("acceptDeal", {});
const avatar = state.pilots.find((p) => p.isAvatar)!;
const rosa = state.pilots.find((p) => p.isVeteran)!;
expect(rosa.name).toBe(content.veteranPilot.name);
const shipOf = (pilotId: string) => state.ships.find((s) => s.pilotId === pilotId)!;
expect(avatar.locationId).toBe("earth");
expect(shipOf(avatar.id)).toMatchObject({ modelId: "courier", locationId: "earth" });
expect(rosa.locationId).toBe("earth");
expect(shipOf(rosa.id)).toMatchObject({ modelId: "clipper", locationId: "earth" });
// The loaner is gone with the tutorial.
expect(state.tutorial).toBeNull();
expect(state.ships.map((s) => s.modelId).sort()).toEqual(["clipper", "courier"]);
});
it("asks a save from before CR-017, four runs done, for one more run", async () => {
// A tutorial save with four runs done: under the old rule, the deal was on offer.
const { app } = testApp();
await signUp(app);
for (let run = 1; run <= 4; run++) await flyTutorialRun(testApp());
const old = await readSave(app);
expect(old.player).toMatchObject({ tutorialRunsCompleted: 4, loanerLocationId: "earth" });
// Opened on this build (a new device importing it).
const clock = new TestClock();
clock.now = testApp().clock.now;
const device = new GameEngine({
storage: new SaveStorage(new MemoryBackend()),
clock: { now: () => clock.now },
});
const { state } = await device.call("importSave", { code: await encodeSave(old) });
expect(state.tutorial).toMatchObject({ runsCompleted: 4, dealAvailable: false });
expect((await readSave(device)).player.tutorialRunsCompleted).toBe(4);
const afterFive = await flyTutorialRun({ ...testApp(), app: device, clock });
expect(afterFive.tutorial).toMatchObject({ runsCompleted: 5, dealAvailable: true });
});
});