Step 22.1: content and save field
CI / check (push) Successful in 6m51s Details

Welcome card text and the tutorial employer's name in content; save
version 7 records whether this company's welcome was seen (decision
0.5.131).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-11 13:32:51 -04:00
parent 8507fa7c1e
commit 7355f7dbfb
13 changed files with 133 additions and 12 deletions

View File

@ -1745,3 +1745,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
## CR-022 recorded (Welcome Screens) ## CR-022 recorded (Welcome Screens)
- **Numbering:** received as "CR-020 — Welcome Screens" for "Phase 20"; stored as **CR-022**, built as **Phase 22** (its 20.n = 22.n), since CR-020 (Bridge) and CR-021 (Independent Pilots) exist. Its decisions CR-020.D1–D2 are **0.5.131–0.5.132**. Its references: CR-008 → CR-011, CR-011 → CR-014; CR-001 is unchanged. - **Numbering:** received as "CR-020 — Welcome Screens" for "Phase 20"; stored as **CR-022**, built as **Phase 22** (its 20.n = 22.n), since CR-020 (Bridge) and CR-021 (Independent Pilots) exist. Its decisions CR-020.D1–D2 are **0.5.131–0.5.132**. Its references: CR-008 → CR-011, CR-011 → CR-014; CR-001 is unchanged.
## Step 22.1
- **"Halvorsen Lunar Freight" is now content** (`tutorialCompanyName`): the CR says the welcome takes it "from the existing content name", but it was hard-coded in three screens. Card 3 says `{company}`, and Step 22.2 points the screens at the content name too.
- **`welcomeSeen` lives on the save's `player`**, alongside the tutorial state, since it belongs to the company, not the device. That's why it isn't a one-time tip in device preferences like the tutorial intro card.

View File

@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
expect(text).toContain("Build: test"); expect(text).toContain("Build: test");
// CR-014: the save, its format version and where it is stored; no player id. // CR-014: the save, its format version and where it is stored; no player id.
expect(text).toContain("Save: player-1"); expect(text).toContain("Save: player-1");
expect(text).toContain("Save version: 6"); expect(text).toContain("Save version: 7");
expect(text).toContain("Storage: unknown"); expect(text).toContain("Storage: unknown");
expect(text).not.toContain("Player:"); expect(text).not.toContain("Player:");
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);

View File

@ -81,7 +81,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
## Save document (`packages/schema/src/save.ts`, CR-014) ## Save document (`packages/schema/src/save.ts`, CR-014)
One document per company on the device, validated on every load and save: `saveVersion` (currently 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields, and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. One document per company on the device, validated on every load and save: `saveVersion` (currently 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields, and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing.
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.

View File

@ -489,7 +489,7 @@ Spec: `memory-bank/change-requests/CR-021-independent-pilots.md` (received as "C
Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132. Spec: `memory-bank/change-requests/CR-022-welcome-screens.md` (received as "CR-020 … Phase 20"). New decisions recorded as plan §0.5.131–0.5.132.
- [ ] Step 22.1 — Content and save field - [x] **Step 22.1 — Content and save field.** Content: `welcome` (three cards' titles and bodies, and the Next, Skip, "Let's fly" and Close labels; `welcomeSchema`), with `{company}` in the last card filled from the new `tutorialCompanyName` ("Halvorsen Lunar Freight", until now written into three screens). Save version 7: `player.welcomeSeen`, false for a new company; the 6 → 7 migration sets it true for existing ones. Tests: `packages/content/test/onboarding.test.ts` (the cards validate, in order, with `{company}` and the labels; empty text is rejected); `packages/engine/test/start.test.ts` (a new company's welcome is unseen); `packages/schema/test/save.test.ts` (6 → 7 marks it seen and changes nothing else).
- [ ] Step 22.2 — Client: welcome pop-up - [ ] Step 22.2 — Client: welcome pop-up
- [ ] Step 22.3 — Client: new company and "How to play" - [ ] Step 22.3 — Client: new company and "How to play"
- [ ] Step 22.4 — Regression - [ ] Step 22.4 — Regression

View File

@ -3,7 +3,13 @@ import { gameConfig } from "./data/config";
import { dealLoanAmount } from "./deal"; import { dealLoanAmount } from "./deal";
import { goods } from "./data/goods"; import { goods } from "./data/goods";
import { locations } from "./data/locations"; import { locations } from "./data/locations";
import { onboardingGoals, scriptedTutorialContracts, veteranPilot } from "./data/onboarding"; import {
onboardingGoals,
scriptedTutorialContracts,
tutorialCompanyName,
veteranPilot,
welcome,
} from "./data/onboarding";
import { shipModels } from "./data/ships"; import { shipModels } from "./data/ships";
import { upgrades } from "./data/upgrades"; import { upgrades } from "./data/upgrades";
@ -26,6 +32,9 @@ export const content = {
scriptedTutorialContracts, scriptedTutorialContracts,
onboardingGoals, onboardingGoals,
veteranPilot, veteranPilot,
/** The tutorial employer (CR-001) and the welcome cards (CR-022). */
tutorialCompanyName,
welcome,
upgrades, upgrades,
/** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */ /** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */
dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }), dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }),

View File

@ -1,4 +1,29 @@
import type { OnboardingGoal, ScriptedTutorialContract, VeteranPilot } from "../schemas"; import type { OnboardingGoal, ScriptedTutorialContract, VeteranPilot, Welcome } from "../schemas";
/** The company the player flies for in the tutorial (CR-001). */
export const tutorialCompanyName = "Halvorsen Lunar Freight";
/** The welcome cards for every new company (decisions 0.5.131, 0.5.132). */
export const welcome: Welcome = {
cards: [
{
title: "Welcome to Deadhead",
body: "A space trucking tycoon. Haul freight across the solar system, grow from a hired pilot into a shipping company, and never fly home empty.",
},
{
title: "Your ships keep flying",
body: "Pick contracts, choose a ship and pilot, and send them off. Trips run in real time, even when the app is closed. Come back to finished deliveries, then send them out again.",
},
{
title: "First, earn your wings",
body: "You're flying for {company} on the Earth–Moon run. Do good work, and the old man might have an offer for you.",
},
],
next: "Next",
skip: "Skip",
start: "Let's fly",
close: "Close",
};
/** Special tutorial contracts by run (decision 0.5.33). */ /** Special tutorial contracts by run (decision 0.5.33). */
export const scriptedTutorialContracts: ScriptedTutorialContract[] = [ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [

View File

@ -417,3 +417,18 @@ export const veteranPilotSchema = z.object({
skills: z.record(idSchema, positiveInt), skills: z.record(idSchema, positiveInt),
}); });
export type VeteranPilot = z.infer<typeof veteranPilotSchema>; export type VeteranPilot = z.infer<typeof veteranPilotSchema>;
/**
* The welcome cards (decisions 0.5.131, 0.5.132): what the game is, how it
* plays and where you start. `{company}` in a body is the tutorial employer's name.
*/
export const welcomeSchema = z.object({
cards: z.array(z.object({ title: z.string().min(1), body: z.string().min(1) })).min(1),
next: z.string().min(1),
skip: z.string().min(1),
/** The last card's button for a new company. */
start: z.string().min(1),
/** The last card's button when reopened from Settings → Help. */
close: z.string().min(1),
});
export type Welcome = z.infer<typeof welcomeSchema>;

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
content, content,
onboardingGoalSchema, onboardingGoalSchema,
welcomeSchema,
onboardingGoals, onboardingGoals,
scriptedTutorialContractSchema, scriptedTutorialContractSchema,
scriptedTutorialContracts, scriptedTutorialContracts,
@ -60,3 +61,29 @@ describe("onboarding goals", () => {
]); ]);
}); });
}); });
describe("welcome cards (Step 22.1, decision 0.5.131)", () => {
it("validates, with three cards and the tutorial employer's name in the last", () => {
expect(() => welcomeSchema.parse(content.welcome)).not.toThrow();
expect(content.welcome.cards.map((c) => c.title)).toEqual([
"Welcome to Deadhead",
"Your ships keep flying",
"First, earn your wings",
]);
expect(content.welcome.cards[2]!.body).toContain("{company}");
expect(content.tutorialCompanyName).toBe("Halvorsen Lunar Freight");
expect(content.welcome).toMatchObject({
next: "Next",
skip: "Skip",
start: "Let's fly",
close: "Close",
});
});
it("rejects empty text", () => {
expect(welcomeSchema.safeParse({ ...content.welcome, cards: [] }).success).toBe(false);
expect(
welcomeSchema.safeParse({ ...content.welcome, cards: [{ title: "", body: "x" }] }).success,
).toBe(false);
});
});

View File

@ -34,6 +34,7 @@ export function startingSave(
tutorialRunsCompleted: 0, tutorialRunsCompleted: 0,
dealAccepted: false, dealAccepted: false,
createdAt: now, createdAt: now,
welcomeSeen: false,
}, },
company: { company: {
id: random.id(), id: random.id(),

View File

@ -95,3 +95,14 @@ describe("company creation on the device (Step 14.8)", () => {
expect(b.state.company.name).toBe(companyName(2)); expect(b.state.company.name).toBe(companyName(2));
}); });
}); });
describe("welcome cards in the save (Step 22.1, decision 0.5.131)", () => {
it("starts a new company with its welcome not yet seen", async () => {
const backend = new MemoryBackend();
const engine = engineOn(backend);
await engine.call("createGuest", {});
const save = await new SaveStorage(backend).load();
if (save.status !== "loaded") throw new Error(save.status);
expect(save.save.player.welcomeSeen).toBe(false);
});
});

View File

@ -164,7 +164,7 @@ export const savedDispatchSchema = z.object({
}); });
/** The save version this build writes. Bump it with a new migration. */ /** The save version this build writes. Bump it with a new migration. */
export const CURRENT_SAVE_VERSION = 6; export const CURRENT_SAVE_VERSION = 7;
export const saveDocumentSchema = z.object({ export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION), saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -180,6 +180,8 @@ export const saveDocumentSchema = z.object({
tutorialRunsCompleted: z.number().int().nonnegative(), tutorialRunsCompleted: z.number().int().nonnegative(),
dealAccepted: z.boolean(), dealAccepted: z.boolean(),
createdAt: timeSchema, createdAt: timeSchema,
/** This company's welcome cards have been seen (decision 0.5.131). */
welcomeSeen: z.boolean(),
}), }),
company: z.object({ company: z.object({
id: idSchema, id: idSchema,
@ -327,12 +329,21 @@ function catchUpState(save: Doc): Doc {
}; };
} }
/**
* Version 6 → 7 (CR-022 §3): companies from before the welcome cards count as
* having seen them, so nobody gets them mid-game.
*/
function welcomeSeen(save: Doc): Doc {
return { ...save, player: { ...(save.player as Doc), welcomeSeen: true } };
}
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 1, migrate: addFuelTanks }, { from: 1, migrate: addFuelTanks },
{ from: 2, migrate: manualRefueling }, { from: 2, migrate: manualRefueling },
{ from: 3, migrate: dropBoards }, { from: 3, migrate: dropBoards },
{ from: 4, migrate: independentPilots }, { from: 4, migrate: independentPilots },
{ from: 5, migrate: catchUpState }, { from: 5, migrate: catchUpState },
{ from: 6, migrate: welcomeSeen },
]; ];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };

View File

@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
seed: 42, seed: 42,
}); });
return { return {
saveVersion: 6, saveVersion: 7,
saveId: "save-1", saveId: "save-1",
lastTimeSeen: T + 3_600_000, lastTimeSeen: T + 3_600_000,
player: { player: {
@ -55,6 +55,7 @@ export function fullSave(): SaveDocument {
tutorialRunsCompleted: 4, tutorialRunsCompleted: 4,
dealAccepted: true, dealAccepted: true,
createdAt: T, createdAt: T,
welcomeSeen: true,
}, },
company: { company: {
id: "co1", id: "co1",

View File

@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => { describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => { it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
expect(CURRENT_SAVE_VERSION).toBe(6); expect(CURRENT_SAVE_VERSION).toBe(7);
}); });
it("rejects an invalid save with a readable error naming the field", () => { it("rejects an invalid save with a readable error naming the field", () => {
@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => {
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const { save } = result; const { save } = result;
const before = fullSave(); const before = fullSave();
expect(save.saveVersion).toBe(6); expect(save.saveVersion).toBe(7);
// The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base
// price, 5 cr/fu since CR-018 (decision 0.5.110). // price, 5 cr/fu since CR-018 (decision 0.5.110).
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" });
@ -116,7 +116,7 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => {
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const { save } = result; const { save } = result;
expect(save.saveVersion).toBe(6); expect(save.saveVersion).toBe(7);
expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual"));
expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual(
before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]),
@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
for (const board of old.contractBoards) delete board.fleet; for (const board of old.contractBoards) delete board.fleet;
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(6); expect(result.save.saveVersion).toBe(7);
expect(result.save.contractBoards).toEqual([]); expect(result.save.contractBoards).toEqual([]);
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
}); });
@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => {
} }
const result = migrateSave(old); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(6); expect(result.save.saveVersion).toBe(7);
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
for (const pilot of result.save.pilots) { for (const pilot of result.save.pilots) {
expect(pilot.mode).toBe("manual"); expect(pilot.mode).toBe("manual");
@ -180,3 +180,19 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => {
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
}); });
}); });
describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
it("marks an existing company's welcome as seen and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
player: Record<string, unknown>;
};
old.saveVersion = 6;
delete old.player.welcomeSeen;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(7);
expect(result.save.player.welcomeSeen).toBe(true);
expect(result.save).toEqual(before);
});
});