Step 24.1: content and save
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
32f27b88c8
commit
bf55eb8d9f
|
|
@ -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.
|
- **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.
|
- **"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.
|
||||||
|
|
|
||||||
|
|
@ -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: 8");
|
expect(text).toContain("Save version: 9");
|
||||||
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/);
|
||||||
|
|
|
||||||
|
|
@ -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 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.2 — Engine: manual decision flag
|
||||||
- [ ] Step 24.3 — Engine: sleep in live play and catch-up
|
- [ ] Step 24.3 — Engine: sleep in live play and catch-up
|
||||||
- [ ] Step 24.4 — Engine: waking and "Back to work"
|
- [ ] Step 24.4 — Engine: waking and "Back to work"
|
||||||
|
|
|
||||||
|
|
@ -97,4 +97,5 @@ export const gameConfig: GameConfig = {
|
||||||
maxCatchUpRealDays: 7,
|
maxCatchUpRealDays: 7,
|
||||||
defaultSpendingFloor: 500,
|
defaultSpendingFloor: 500,
|
||||||
},
|
},
|
||||||
|
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,15 @@ export const gameConfigSchema = z
|
||||||
/** Credits pilots never spend the company below, by default (0.5.127). */
|
/** Credits pilots never spend the company below, by default (0.5.127). */
|
||||||
defaultSpendingFloor: positiveInt,
|
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(
|
.refine(
|
||||||
(config) =>
|
(config) =>
|
||||||
|
|
@ -358,7 +367,19 @@ export const gameConfigSchema = z
|
||||||
message: "The first-look window must be shorter than the board period",
|
message: "The first-look window must be shorter than the board period",
|
||||||
path: ["independentPilots", "firstLookRealMinutes"],
|
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>;
|
export type GameConfig = z.infer<typeof gameConfigSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ const validGameConfig = {
|
||||||
maxCatchUpRealDays: 7,
|
maxCatchUpRealDays: 7,
|
||||||
defaultSpendingFloor: 500,
|
defaultSpendingFloor: 500,
|
||||||
},
|
},
|
||||||
|
fleetSleep: { defaultHours: 24, optionsHours: [12, 24, 48, 72] },
|
||||||
tutorialLoanerShipModelId: "company-courier",
|
tutorialLoanerShipModelId: "company-courier",
|
||||||
tutorialRunsBeforeDeal: 5,
|
tutorialRunsBeforeDeal: 5,
|
||||||
tutorialXpMultiplier: 2,
|
tutorialXpMultiplier: 2,
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,8 @@ export function catchUp(
|
||||||
until: RealTime,
|
until: RealTime,
|
||||||
options: CatchUpOptions,
|
options: CatchUpOptions,
|
||||||
): CatchUpResult {
|
): 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 maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
||||||
const decideLimit = Math.min(until, options.decideUntil);
|
const decideLimit = Math.min(until, options.decideUntil);
|
||||||
let resolved = 0;
|
let resolved = 0;
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export function startingSave(
|
||||||
loanBalance: 0,
|
loanBalance: 0,
|
||||||
dispatchOffice: false,
|
dispatchOffice: false,
|
||||||
npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor,
|
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: [],
|
ships: [],
|
||||||
pilots: [
|
pilots: [
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -35,7 +35,8 @@ describe("Venus, Io and Europa in the engine (Step 16.4)", () => {
|
||||||
const after = await readSave(app);
|
const after = await readSave(app);
|
||||||
expect(after.saveVersion).toBe(before.saveVersion);
|
expect(after.saveVersion).toBe(before.saveVersion);
|
||||||
expect(after.discoveredLocations).toEqual(before.discoveredLocations);
|
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", () => {
|
it("offers first contacts to Venus on Earth boards over time", () => {
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,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 = 8;
|
export const CURRENT_SAVE_VERSION = 9;
|
||||||
|
|
||||||
export const saveDocumentSchema = z.object({
|
export const saveDocumentSchema = z.object({
|
||||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
||||||
|
|
@ -198,6 +198,14 @@ export const saveDocumentSchema = z.object({
|
||||||
dispatchOffice: z.boolean(),
|
dispatchOffice: z.boolean(),
|
||||||
/** Credits Independent pilots never spend below (decision 0.5.127). */
|
/** Credits Independent pilots never spend below (decision 0.5.127). */
|
||||||
npcSpendingFloor: z.number().int().nonnegative(),
|
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),
|
ships: z.array(savedShipSchema),
|
||||||
pilots: z.array(savedPilotSchema),
|
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[] = [
|
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
||||||
{ from: 1, migrate: addFuelTanks },
|
{ from: 1, migrate: addFuelTanks },
|
||||||
{ from: 2, migrate: manualRefueling },
|
{ from: 2, migrate: manualRefueling },
|
||||||
|
|
@ -362,6 +386,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
||||||
{ from: 5, migrate: catchUpState },
|
{ from: 5, migrate: catchUpState },
|
||||||
{ from: 6, migrate: welcomeSeen },
|
{ from: 6, migrate: welcomeSeen },
|
||||||
{ from: 7, migrate: shopPilot },
|
{ from: 7, migrate: shopPilot },
|
||||||
|
{ from: 8, migrate: fleetSleep },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
|
||||||
seed: 42,
|
seed: 42,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
saveVersion: 8,
|
saveVersion: 9,
|
||||||
saveId: "save-1",
|
saveId: "save-1",
|
||||||
lastTimeSeen: T + 3_600_000,
|
lastTimeSeen: T + 3_600_000,
|
||||||
player: {
|
player: {
|
||||||
|
|
@ -64,6 +64,9 @@ export function fullSave(): SaveDocument {
|
||||||
loanBalance: 60_000,
|
loanBalance: 60_000,
|
||||||
dispatchOffice: false,
|
dispatchOffice: false,
|
||||||
npcSpendingFloor: 500,
|
npcSpendingFloor: 500,
|
||||||
|
// As the 8 → 9 migration leaves it: the engine sets it on first catch-up.
|
||||||
|
lastManualDecisionAt: null,
|
||||||
|
sleepAfterHours: 24,
|
||||||
},
|
},
|
||||||
ships: [
|
ships: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(8);
|
expect(CURRENT_SAVE_VERSION).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(8);
|
expect(save.saveVersion).toBe(9);
|
||||||
// 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(8);
|
expect(save.saveVersion).toBe(9);
|
||||||
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(8);
|
expect(result.save.saveVersion).toBe(9);
|
||||||
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(8);
|
expect(result.save.saveVersion).toBe(9);
|
||||||
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");
|
||||||
|
|
@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
|
||||||
delete old.player.welcomeSeen;
|
delete old.player.welcomeSeen;
|
||||||
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(8);
|
expect(result.save.saveVersion).toBe(9);
|
||||||
expect(result.save.player.welcomeSeen).toBe(true);
|
expect(result.save.player.welcomeSeen).toBe(true);
|
||||||
expect(result.save).toEqual(before);
|
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;
|
for (const ship of old.ships) delete ship.shopPilotId;
|
||||||
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(8);
|
expect(result.save.saveVersion).toBe(9);
|
||||||
// Including the ship still in the shop: its upgrade finishes without the rest.
|
// 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.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null));
|
||||||
expect(result.save).toEqual(before);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue