diff --git a/DECISIONS.md b/DECISIONS.md index dbfe303..a7b5ab4 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1745,3 +1745,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## 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. + +## 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. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index e6f4b59..028672e 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => { expect(text).toContain("Build: test"); // 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 version: 6"); + expect(text).toContain("Save version: 7"); expect(text).toContain("Storage: unknown"); expect(text).not.toContain("Player:"); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index f4fe4fc..7bb8b0c 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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) -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 077015f..dacddcf 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. -- [ ] 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.3 — Client: new company and "How to play" - [ ] Step 22.4 — Regression diff --git a/packages/content/src/bundle.ts b/packages/content/src/bundle.ts index 5949ce4..8d1dbb6 100644 --- a/packages/content/src/bundle.ts +++ b/packages/content/src/bundle.ts @@ -3,7 +3,13 @@ import { gameConfig } from "./data/config"; import { dealLoanAmount } from "./deal"; import { goods } from "./data/goods"; 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 { upgrades } from "./data/upgrades"; @@ -26,6 +32,9 @@ export const content = { scriptedTutorialContracts, onboardingGoals, veteranPilot, + /** The tutorial employer (CR-001) and the welcome cards (CR-022). */ + tutorialCompanyName, + welcome, upgrades, /** The deal's loan: its ships' prices plus the bay lease (decision 0.5.83). */ dealLoanAmount: dealLoanAmount({ config: gameConfig, shipModels, locations }), diff --git a/packages/content/src/data/onboarding.ts b/packages/content/src/data/onboarding.ts index 9827827..b8644c5 100644 --- a/packages/content/src/data/onboarding.ts +++ b/packages/content/src/data/onboarding.ts @@ -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). */ export const scriptedTutorialContracts: ScriptedTutorialContract[] = [ diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 06f09c1..fe9f0c5 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -417,3 +417,18 @@ export const veteranPilotSchema = z.object({ skills: z.record(idSchema, positiveInt), }); export type VeteranPilot = z.infer; + +/** + * 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; diff --git a/packages/content/test/onboarding.test.ts b/packages/content/test/onboarding.test.ts index 83094ff..c3d09bb 100644 --- a/packages/content/test/onboarding.test.ts +++ b/packages/content/test/onboarding.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { content, onboardingGoalSchema, + welcomeSchema, onboardingGoals, scriptedTutorialContractSchema, 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); + }); +}); diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 09f9974..c99410a 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -34,6 +34,7 @@ export function startingSave( tutorialRunsCompleted: 0, dealAccepted: false, createdAt: now, + welcomeSeen: false, }, company: { id: random.id(), diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index 9ef97c3..cffe4f6 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -95,3 +95,14 @@ describe("company creation on the device (Step 14.8)", () => { 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); + }); +}); diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 11fcf8c..cd2ca05 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -164,7 +164,7 @@ export const savedDispatchSchema = z.object({ }); /** 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({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -180,6 +180,8 @@ export const saveDocumentSchema = z.object({ tutorialRunsCompleted: z.number().int().nonnegative(), dealAccepted: z.boolean(), createdAt: timeSchema, + /** This company's welcome cards have been seen (decision 0.5.131). */ + welcomeSeen: z.boolean(), }), company: z.object({ 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[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, { from: 3, migrate: dropBoards }, { from: 4, migrate: independentPilots }, { from: 5, migrate: catchUpState }, + { from: 6, migrate: welcomeSeen }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index d5efaf9..346eb4d 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -45,7 +45,7 @@ export function fullSave(): SaveDocument { seed: 42, }); return { - saveVersion: 6, + saveVersion: 7, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -55,6 +55,7 @@ export function fullSave(): SaveDocument { tutorialRunsCompleted: 4, dealAccepted: true, createdAt: T, + welcomeSeen: true, }, company: { id: "co1", diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 04fbcb2..08e35b7 100644 --- a/packages/schema/test/save.test.ts +++ b/packages/schema/test/save.test.ts @@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { 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", () => { @@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { if (!result.ok) throw new Error(result.error); const { save } = result; 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 // price, 5 cr/fu since CR-018 (decision 0.5.110). 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); if (!result.ok) throw new Error(result.error); 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.fuelFu, s.fuelAvgPrice])).toEqual( 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; const result = migrateSave(old); 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({ ...before, contractBoards: [] }); }); @@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => { } const result = migrateSave(old); 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 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); @@ -180,3 +180,19 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => { 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 & { + player: Record; + }; + 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); + }); +});