Step 24.1: content and save
CI / check (push) Has been cancelled Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-15 21:07:50 -04:00
parent 32f27b88c8
commit bf55eb8d9f
15 changed files with 144 additions and 14 deletions

View File

@ -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.

View File

@ -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/);

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)
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.

View File

@ -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"

View File

@ -97,4 +97,5 @@ export const gameConfig: GameConfig = {
maxCatchUpRealDays: 7,
defaultSpendingFloor: 500,
},
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
};

View File

@ -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<typeof gameConfigSchema>;
/**

View File

@ -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);
});
});

View File

@ -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,

View File

@ -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;

View File

@ -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: [

View File

@ -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);
});
});

View File

@ -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", () => {

View File

@ -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 };

View File

@ -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: [
{

View File

@ -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<string, unknown> & {
company: Record<string, unknown>;
};
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);
});
});