diff --git a/DECISIONS.md b/DECISIONS.md index 334751b..2f07000 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1806,3 +1806,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Numbering:** received as "CR-022 — Fleet Sleep Mode" for "Phase 22"; stored as **CR-024**, built as **Phase 24** (its 22.n = 24.n), since CR-022 (Welcome Screens) and CR-023 (Shop Time Counts as Rest) exist. Its decisions CR-022.D1–D4 are **0.5.136–0.5.139**. Its references: CR-008 → CR-011, CR-011 → CR-014, CR-019 → CR-021; CR-004 and plan 12.1 are unchanged. - **"HQ" is the Bridge**, as in CR-021: the dashboard with the Dispatch Office panel. The HQs tab (CR-020) only lists bays. + +## Step 24.1 + +- **"Set to the migration time" without a clock in the migration.** Migrations are pure functions of the save, so the 8 → 9 step leaves `lastManualDecisionAt` null and the engine's catch-up sets a null one to the time it runs, before anything else. That is the first time the engine opens the save (load or import), so the effect is the CR's: nobody's fleet is asleep the moment they update. +- **The sleep settings are their own config block** (`fleetSleep`), not part of `independentPilots`, whose values are all checked as positive settings of a different kind. +- **The threshold lives on the company**, beside the Dispatch Office and spending floor: it travels with the save, unlike device preferences. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 7ce67ee..156e8ea 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: 8"); + expect(text).toContain("Save version: 9"); 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 7193ccd..150b1b9 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 8; 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; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023), `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 including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), 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 9; 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; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), 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 170295f..71c0be5 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -521,7 +521,7 @@ Spec: `memory-bank/change-requests/CR-023-shop-time-rest.md` (received as "CR-02 Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022 … Phase 22"). New decisions recorded as plan §0.5.136–0.5.139. -- [ ] Step 24.1 — Content and save +- [x] **Step 24.1 — Content and save.** Config `fleetSleep` (default 24 h; options 12, 24, 48 and 72 h, rising, the default among them). Save version 9: `company.lastManualDecisionAt` and `company.sleepAfterHours`; a new company starts with its creation time and the default. The 8 → 9 migration sets the default and leaves the last decision null; the engine's catch-up sets a null one to the time it first runs on the save, which is the migration time in effect. Tests: `packages/content/test/config.test.ts`, `packages/schema/test/save.test.ts` (8 → 9), `packages/engine/test/fleet-sleep.test.ts` (a new company; an imported version-8 save is stamped with the import time). - [ ] Step 24.2 — Engine: manual decision flag - [ ] Step 24.3 — Engine: sleep in live play and catch-up - [ ] Step 24.4 — Engine: waking and "Back to work" diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 62f153c..717667b 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -97,4 +97,5 @@ export const gameConfig: GameConfig = { maxCatchUpRealDays: 7, defaultSpendingFloor: 500, }, + fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] }, }; diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index fe9f0c5..1652468 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -344,6 +344,15 @@ export const gameConfigSchema = z /** Credits pilots never spend the company below, by default (0.5.127). */ defaultSpendingFloor: positiveInt, }), + /** + * Fleet sleep (decision 0.5.137): real hours after the last manual decision + * until Independent pilots stop starting new work, and the choices offered + * in Settings → Company, shortest first. + */ + fleetSleep: z.object({ + defaultHours: z.number().positive(), + optionsHours: z.array(z.number().positive()).min(1), + }), }) .refine( (config) => @@ -358,7 +367,19 @@ export const gameConfigSchema = z message: "The first-look window must be shorter than the board period", path: ["independentPilots", "firstLookRealMinutes"], }, - ); + ) + .refine( + ({ fleetSleep: { optionsHours } }) => + optionsHours.every((hours, i) => i === 0 || hours > optionsHours[i - 1]!), + { + message: "Sleep options must be in rising order, each once", + path: ["fleetSleep", "optionsHours"], + }, + ) + .refine(({ fleetSleep }) => fleetSleep.optionsHours.includes(fleetSleep.defaultHours), { + message: "The default sleep threshold must be one of the options", + path: ["fleetSleep", "defaultHours"], + }); export type GameConfig = z.infer; /** diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts index d0adfd4..e0d9d96 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -130,3 +130,20 @@ describe("independent pilots config (Step 21.1, CR-021)", () => { ); }); }); + +describe("fleet sleep config (Step 24.1, decision 0.5.137)", () => { + const withSleep = (fleetSleep: { defaultHours: number; optionsHours: number[] }) => + gameConfigSchema.safeParse({ ...gameConfig, fleetSleep }).success; + + it("defaults to 24 hours, from 12 h, 24 h, 48 h or 3 days", () => { + expect(gameConfig.fleetSleep).toEqual({ defaultHours: 24, optionsHours: [12, 24, 48, 72] }); + }); + + it("rejects a default that isn't an option, and options out of order or repeated", () => { + expect(withSleep({ defaultHours: 36, optionsHours: [12, 24, 48] })).toBe(false); + expect(withSleep({ defaultHours: 24, optionsHours: [24, 12] })).toBe(false); + expect(withSleep({ defaultHours: 24, optionsHours: [24, 24] })).toBe(false); + expect(withSleep({ defaultHours: 0, optionsHours: [0] })).toBe(false); + expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true); + }); +}); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 2ad197a..3b413ab 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -112,6 +112,7 @@ const validGameConfig = { maxCatchUpRealDays: 7, defaultSpendingFloor: 500, }, + fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] }, tutorialLoanerShipModelId: "company-courier", tutorialRunsBeforeDeal: 5, tutorialXpMultiplier: 2, diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index a4374eb..968525b 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -185,6 +185,8 @@ export function catchUp( until: RealTime, options: CatchUpOptions, ): CatchUpResult { + // A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3). + save.company.lastManualDecisionAt ??= until; const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; const decideLimit = Math.min(until, options.decideUntil); let resolved = 0; diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index b72e330..7b4b5f8 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -43,6 +43,9 @@ export function startingSave( loanBalance: 0, dispatchOffice: false, npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor, + // The company's creation is its first decision (CR-024 Step 24.1). + lastManualDecisionAt: now, + sleepAfterHours: content.config.fleetSleep.defaultHours, }, ships: [], pilots: [ diff --git a/packages/engine/test/fleet-sleep.test.ts b/packages/engine/test/fleet-sleep.test.ts new file mode 100644 index 0000000..94ff7c9 --- /dev/null +++ b/packages/engine/test/fleet-sleep.test.ts @@ -0,0 +1,32 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { encodeSave } from "../src/index"; +import { newTestApp, readSave, signUp, TEST_NOW } from "./support"; + +const HOUR = 60 * 60_000; + +describe("fleet sleep in the save (Step 24.1, decisions 0.5.136, 0.5.137)", () => { + it("starts a new company with its creation as the last decision and the default threshold", async () => { + const { app } = newTestApp(); + await signUp(app); + const { company } = await readSave(app); + expect(company.lastManualDecisionAt).toBe(TEST_NOW); + expect(company.sleepAfterHours).toBe(content.config.fleetSleep.defaultHours); + }); + + it("stamps a migrated save's last decision with the time the engine first opens it", async () => { + const { app } = newTestApp(); + await signUp(app); + const current = await readSave(app); + // As version 8 wrote it: no sleep fields. + const { lastManualDecisionAt: _at, sleepAfterHours: _hours, ...company } = current.company; + const v8 = { ...current, saveVersion: 8, company }; + + const device = newTestApp(); + device.clock.now = TEST_NOW + 30 * HOUR; + await device.app.call("importSave", { code: await encodeSave(v8 as never) }); + const imported = (await readSave(device.app)).company; + expect(imported.lastManualDecisionAt).toBe(TEST_NOW + 30 * HOUR); + expect(imported.sleepAfterHours).toBe(24); + }); +}); diff --git a/packages/engine/test/new-places.test.ts b/packages/engine/test/new-places.test.ts index fc0f9ba..7311acc 100644 --- a/packages/engine/test/new-places.test.ts +++ b/packages/engine/test/new-places.test.ts @@ -35,7 +35,8 @@ describe("Venus, Io and Europa in the engine (Step 16.4)", () => { const after = await readSave(app); expect(after.saveVersion).toBe(before.saveVersion); expect(after.discoveredLocations).toEqual(before.discoveredLocations); - expect(after.company).toEqual(before.company); + // The import stamps the fixture's migrated last decision (CR-024 §3). + expect(after.company).toEqual({ ...before.company, lastManualDecisionAt: TEST_NOW }); }); it("offers first contacts to Venus on Earth boards over time", () => { diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index fcfb436..f86d97e 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -170,7 +170,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 8; +export const CURRENT_SAVE_VERSION = 9; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -198,6 +198,14 @@ export const saveDocumentSchema = z.object({ dispatchOffice: z.boolean(), /** Credits Independent pilots never spend below (decision 0.5.127). */ npcSpendingFloor: z.number().int().nonnegative(), + /** + * The player's last manual decision (decision 0.5.136): the fleet sleeps + * `sleepAfterHours` later (0.5.137). Null only in a save just migrated to + * version 9, until the engine first catches it up and sets it to that time. + */ + lastManualDecisionAt: timeSchema.nullable(), + /** Real hours without orders before the fleet goes quiet (Settings → Company). */ + sleepAfterHours: z.number().positive(), }), ships: z.array(savedShipSchema), pilots: z.array(savedPilotSchema), @@ -354,6 +362,22 @@ function shopPilot(save: Doc): Doc { }; } +/** + * Version 8 → 9 (CR-024): the default sleep threshold, and a last manual + * decision the engine sets to the time it first opens the save (the migration + * time, CR-024 §3), so nobody's fleet is asleep the moment they update. + */ +function fleetSleep(save: Doc): Doc { + return { + ...save, + company: { + ...(save.company as Doc), + lastManualDecisionAt: null, + sleepAfterHours: content.config.fleetSleep.defaultHours, + }, + }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, @@ -362,6 +386,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 5, migrate: catchUpState }, { from: 6, migrate: welcomeSeen }, { from: 7, migrate: shopPilot }, + { from: 8, migrate: fleetSleep }, ]; 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 42358fc..aef7dfe 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: 8, + saveVersion: 9, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -64,6 +64,9 @@ export function fullSave(): SaveDocument { loanBalance: 60_000, dispatchOffice: false, npcSpendingFloor: 500, + // As the 8 → 9 migration leaves it: the engine sets it on first catch-up. + lastManualDecisionAt: null, + sleepAfterHours: 24, }, ships: [ { diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 57a7469..743617e 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(8); + expect(CURRENT_SAVE_VERSION).toBe(9); }); 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(8); + expect(save.saveVersion).toBe(9); // 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(8); + expect(save.saveVersion).toBe(9); 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(8); + expect(result.save.saveVersion).toBe(9); 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(8); + expect(result.save.saveVersion).toBe(9); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); @@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => { delete old.player.welcomeSeen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(8); + expect(result.save.saveVersion).toBe(9); expect(result.save.player.welcomeSeen).toBe(true); expect(result.save).toEqual(before); }); @@ -207,9 +207,27 @@ describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => { for (const ship of old.ships) delete ship.shopPilotId; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(8); + expect(result.save.saveVersion).toBe(9); // Including the ship still in the shop: its upgrade finishes without the rest. expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null)); expect(result.save).toEqual(before); }); }); + +describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => { + it("sets the default threshold and leaves the last decision for the engine to stamp", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + company: Record; + }; + old.saveVersion = 8; + delete old.company.lastManualDecisionAt; + delete old.company.sleepAfterHours; + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(9); + expect(result.save.company.lastManualDecisionAt).toBeNull(); + expect(result.save.company.sleepAfterHours).toBe(24); + expect(result.save).toEqual(before); + }); +});