Step 17.1: content: tutorial settings
CI / check (push) Has been cancelled Details

Five tutorial runs before the deal and a tutorial XP multiplier of 2.
The deal no longer waits for the loaner to be back at Earth, since five
runs end at the Moon; accepting it still puts the avatar at Earth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 16:04:12 -04:00
parent a95b94d73d
commit 35b12e50da
11 changed files with 55 additions and 25 deletions

View File

@ -1554,3 +1554,7 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## CR-017 recorded (Tutorial XP and Skill Point Prompt)
- **Numbering:** received as "CR-016 — Tutorial XP and Skill Point Prompt" for "Phase 16"; stored as **CR-017**, built as **Phase 17** (its 16.n = 17.n). Its decisions CR-016.D1–D4 are **0.5.106–0.5.109**. Its references: CR-007 → CR-010, CR-008 → CR-011, CR-010 → CR-013, CR-011 → CR-014; its "CR-001 0.5.22" is 0.5.30. The numbering note at the top of the CR file has the full map.
## 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).

View File

@ -414,7 +414,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.
- [ ] Step 17.1 — Content: tutorial settings
- [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
- [ ] Step 17.3 — Sim: tutorial timing
- [ ] Step 17.4 — Engine: deal availability and placement

View File

@ -16,7 +16,9 @@ export const gameConfig: GameConfig = {
// One tutorial contract per mass tier (CR-001).
tutorialBoardSize: 3,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4,
// Five runs, at double XP, so the avatar has a skill point at the deal (CR-017).
tutorialRunsBeforeDeal: 5,
tutorialXpMultiplier: 2,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
// 20 t adds 0.4 game hours (24 real seconds) of loading and unloading.
handlingGameHoursPerTonne: 0.02,

View File

@ -216,8 +216,10 @@ export const gameConfigSchema = z
tutorialBoardSize: positiveInt,
/** Ship model the company lends during the tutorial (decision 0.5.29). */
tutorialLoanerShipModelId: idSchema,
/** Tutorial runs completed before the deal is offered (decision 0.5.30). */
/** Tutorial runs completed before the deal is offered (decisions 0.5.30, 0.5.106). */
tutorialRunsBeforeDeal: positiveInt,
/** Tutorial runs earn normal XP × this (decision 0.5.107). */
tutorialXpMultiplier: z.number().min(1),
/** Cargo mass of the three tutorial contracts (decision 0.5.31). */
tutorialMassTonnes: z
.object({

View File

@ -84,3 +84,17 @@ describe("game config", () => {
expect(gameConfig.longHaulDutyBonusGameHours).toBeGreaterThan(0);
});
});
describe("tutorial settings (Step 17.1, decisions 0.5.106, 0.5.107)", () => {
it("runs five tutorial runs before the deal, at double XP", () => {
expect(gameConfig.tutorialRunsBeforeDeal).toBe(5);
expect(gameConfig.tutorialXpMultiplier).toBe(2);
});
it("never lets the tutorial XP multiplier fall below 1", () => {
expect(gameConfigSchema.safeParse(gameConfig).success).toBe(true);
expect(gameConfigSchema.safeParse({ ...gameConfig, tutorialXpMultiplier: 0.5 }).success).toBe(
false,
);
});
});

View File

@ -104,7 +104,8 @@ const validGameConfig = {
pilotHireCost: 1000,
pilotProfitShare: 0.25,
tutorialLoanerShipModelId: "company-courier",
tutorialRunsBeforeDeal: 4,
tutorialRunsBeforeDeal: 5,
tutorialXpMultiplier: 2,
tutorialMassTonnes: { light: 5, medium: 12, full: 20 },
handlingGameHoursPerTonne: 0.02,
upgradeLevels: [{ costPercentOfPrice: 10, shopRealMinutes: 10 }],

View File

@ -190,7 +190,7 @@ export function isDealAvailable(save: SaveDocument): boolean {
player.tutorialActive &&
!player.dealAccepted &&
player.tutorialRunsCompleted >= content.config.tutorialRunsBeforeDeal &&
player.loanerLocationId === "earth" &&
// Wherever the loaner is (decision 0.5.108): accepting puts the avatar at Earth.
!runInFlight
);
}

View File

@ -81,13 +81,20 @@ describe("accepting the deal", () => {
expect(await snapshot(guest)).toEqual(before);
});
it("fails while the loaner is at the Moon", async () => {
it("is accepted with the loaner at the Moon, and the avatar ends up at Earth (CR-017)", async () => {
const guest = await signUp(testApp().app);
await readyForDeal(guest);
await edit(testApp().app, (save) => {
save.player.loanerLocationId = "moon";
});
expect((await accept(guest)).statusCode).toBe(409);
const response = await accept(guest);
expect(response.statusCode).toBe(200);
const state = playerStateSchema.parse(response.json());
const avatar = state.pilots.find((p) => p.isAvatar)!;
const courier = state.ships.find((s) => s.id === avatar.assignedShipId)!;
expect(avatar.locationId).toBe("earth");
expect(courier).toMatchObject({ modelId: "courier", locationId: "earth" });
expect(state.tutorial).toBeNull();
});
it("fails the second time", async () => {

View File

@ -2,7 +2,7 @@
import { describe, expect, it, vi } from "vitest";
import { useTestApp, signUp, getState, flyTutorialRun } from "../support";
// The deal after three runs instead of four: the loaner is then at the Moon.
// The deal after three runs instead of five: the loaner is then at the Moon.
vi.mock("@deadhead/content", async (importOriginal) => {
const original = await importOriginal<typeof import("@deadhead/content")>();
return {
@ -17,19 +17,18 @@ vi.mock("@deadhead/content", async (importOriginal) => {
const testApp = useTestApp();
describe("deal availability with tutorialRunsBeforeDeal = 3", () => {
it("waits until a fourth run returns the loaner to Earth", async () => {
it("offers the deal after the third run, though the loaner is at the Moon (decision 0.5.108)", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
for (let run = 1; run <= 2; run++) await flyTutorialRun(testApp(), guest);
expect((await getState(testApp().app, guest)).tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
runsCompleted: 2,
dealAvailable: false,
});
const state = await flyTutorialRun(testApp(), guest);
expect(state.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: true,
});
});

View File

@ -55,20 +55,20 @@ describe("tutorial flow v2", () => {
expect(third.find((c) => c.scriptedId === "unmarked-crates")).toBeDefined();
});
it("makes the deal available after four runs ending at Earth, not after three", async () => {
it("makes the deal available after five runs ending at the Moon, not after four (CR-017)", async () => {
const guest = await signUp(testApp().app);
for (let run = 1; run <= 3; run++) await flyTutorialRun(testApp(), guest);
const afterThree = await getState(testApp().app, guest);
expect(afterThree.tutorial).toMatchObject({
runsCompleted: 3,
loanerLocationId: "moon",
dealAvailable: false,
});
const afterFour = await flyTutorialRun(testApp(), guest);
for (let run = 1; run <= 4; run++) await flyTutorialRun(testApp(), guest);
const afterFour = await getState(testApp().app, guest);
expect(afterFour.tutorial).toMatchObject({
runsCompleted: 4,
loanerLocationId: "earth",
dealAvailable: false,
});
const afterFive = await flyTutorialRun(testApp(), guest);
expect(afterFive.tutorial).toMatchObject({
runsCompleted: 5,
loanerLocationId: "moon",
runInFlight: false,
dealAvailable: true,
});

View File

@ -384,7 +384,8 @@ export function simulate(options: SimulationOptions): SimulationResult {
// --- Tutorial and the deal (server dispatch.ts, deal.ts). ---
function playTutorial(): boolean {
if (flights.some((f) => f.record.tutorial)) return false;
if (runsCompleted >= config.tutorialRunsBeforeDeal && loanerLocationId === "earth") {
// Wherever the loaner is (decision 0.5.108).
if (runsCompleted >= config.tutorialRunsBeforeDeal) {
acceptDeal();
return true;
}