diff --git a/DECISIONS.md b/DECISIONS.md index a680ccf..76bbd56 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1995,3 +1995,8 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Numbering:** received as "CR-025 — Service Bookings and Auto-Repair" for "Phase 25"; stored as **CR-027**, built as **Phase 27** (its 25.n = 27.n), since CR-025 (Short Away Summary) and CR-026 (Random Events) exist. Its decisions CR-025.D1–D7 are **0.5.153–0.5.159**. Its references: CR-006 → CR-009, CR-011 → CR-014, CR-014 → CR-015, CR-019 → CR-021, CR-021 → CR-023, CR-022 → CR-024, CR-023 → CR-025, CR-024 → CR-026. - **All seven decisions are recorded**: §8 says "Add CR-025.D1–D6", but §6 lists D7 (no double waiting) too. - **No double waiting, with short repairs:** CR-023 counts the shop as rest only when it lasts a full rest; a repair can take 5 minutes, shorter than a 10-minute rest. Then the rest simply runs on to its end, which is still "the later of the two", so the CR's rule holds without changing CR-023's. + +## Step 27.1 + +- **The booking lives on the ship** (one per ship, as the CR has one booking per ship), with its timings: `startedAt` once the work is in the shop (the booking clears at the shop end), `waitingSince` while it waits for funds at the HQ. +- **One upgrade level per booking** (`upgradeId`: the next level of that upgrade when the work starts), as the CR allows one level per visit. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 94a5115..5006f4f 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: 11"); + expect(text).toContain("Save version: 12"); 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 934eb5a..631af11 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -86,7 +86,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 11; 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; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026), `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`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). 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 12; 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; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026; 11 → 12 added ship `booking` (null) and `autoRepair` (on, 40%), CR-027), `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`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). 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 f69e0d4..09c86f4 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -569,7 +569,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-025 … Phase 25"). New decisions recorded as plan §0.5.153–0.5.159. -- [ ] Step 27.1 — Content and save +- [x] **Step 27.1 — Content and save.** Config `autoRepair` (default 40%, 10–90% in steps of 5; the default must be a step). Save version 12: each ship has `booking` (repair, upgrade, HQ, by player or auto, created, started, waiting for funds since; null when none) and `autoRepair` (on, threshold); new ships start on at 40% with no booking; the 11 → 12 migration does the same for existing ships. Tests: `packages/content/test/config.test.ts`; `packages/schema/test/save.test.ts` (11 → 12); `packages/engine/test/trip-events.test.ts` (a bought ship). - [ ] Step 27.2 — Sim: routing and triggers - [ ] Step 27.3 — Engine: booking commands and service start - [ ] Step 27.4 — Engine: Independent pilots and catch-up diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 135186b..9d97227 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -116,6 +116,7 @@ export const gameConfig: GameConfig = { responseWindowRealMinutes: 30, minTripFraction: 0.5, }, + autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 }, hull: { floorPct: 5, ratingReductionPerPoint: 0.1, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index b115038..e768fb1 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -486,6 +486,13 @@ export const gameConfigSchema = z /** A speed-up never makes a trip shorter than this share of it. */ minTripFraction: z.number().min(0).max(1), }), + /** Auto-repair (decision 0.5.157): the default threshold and the choices, in percent. */ + autoRepair: z.object({ + defaultThresholdPct: z.number().min(0).max(100), + minPct: z.number().min(0).max(100), + maxPct: z.number().min(0).max(100), + stepPct: z.number().positive(), + }), /** Hull condition and repairs (decision 0.5.148). */ hull: z.object({ floorPct: z.number().min(0).max(100), @@ -526,6 +533,17 @@ export const gameConfigSchema = z path: ["fleetSleep", "optionsHours"], }, ) + .refine( + ({ autoRepair: a }) => + a.minPct <= a.defaultThresholdPct && + a.defaultThresholdPct <= a.maxPct && + (a.defaultThresholdPct - a.minPct) % a.stepPct === 0 && + (a.maxPct - a.minPct) % a.stepPct === 0, + { + message: "The auto-repair default must be one of the steps from min to max", + path: ["autoRepair", "defaultThresholdPct"], + }, + ) .refine(({ events }) => events.fireFrom < events.fireTo, { message: "Events fire inside the trip, early before late", path: ["events", "fireFrom"], diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts index e0d9d96..c43a38a 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -147,3 +147,23 @@ describe("fleet sleep config (Step 24.1, decision 0.5.137)", () => { expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true); }); }); + +describe("auto-repair config (Step 27.1, decision 0.5.157)", () => { + const withAuto = (autoRepair: typeof gameConfig.autoRepair) => + gameConfigSchema.safeParse({ ...gameConfig, autoRepair }).success; + + it("defaults to 40%, from 10% to 90% in steps of 5", () => { + expect(gameConfig.autoRepair).toEqual({ + defaultThresholdPct: 40, + minPct: 10, + maxPct: 90, + stepPct: 5, + }); + }); + + it("rejects a default off the steps or outside the range", () => { + expect(withAuto({ defaultThresholdPct: 42, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(false); + expect(withAuto({ defaultThresholdPct: 95, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(false); + expect(withAuto({ defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 })).toBe(true); + }); +}); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index f4fd2dc..348d739 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -131,6 +131,7 @@ const validGameConfig = { responseWindowRealMinutes: 30, minTripFraction: 0.5, }, + autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 }, hull: { floorPct: 5, ratingReductionPerPoint: 0.1, diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 44de26b..6343a12 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -140,6 +140,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi shopRepair: false, hullConditionPct: 100, quietTrips: content.config.events.shipCooldownTrips, + booking: null, + autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct }, fromDeal: true, createdAt: now, lastArrivalAt: now, diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index f86f009..aa84d4e 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -82,6 +82,8 @@ export function buyShip( shopRepair: false, hullConditionPct: 100, quietTrips: content.config.events.shipCooldownTrips, + booking: null, + autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct }, fromDeal: false, createdAt: now, lastArrivalAt: now, diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index 5f85c5d..4062c03 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -316,6 +316,8 @@ export function makeOwner( shopRepair: false, hullConditionPct: 100, quietTrips: 2, + booking: null, + autoRepair: { on: true, thresholdPct: 40 }, // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. fromDeal: true, createdAt: TEST_NOW, @@ -384,6 +386,8 @@ export function addShip( shopRepair: false, hullConditionPct: 100, quietTrips: 2, + booking: null, + autoRepair: { on: true, thresholdPct: 40 }, fromDeal: false, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, diff --git a/packages/engine/test/trip-events.test.ts b/packages/engine/test/trip-events.test.ts index bea7ea9..c182834 100644 --- a/packages/engine/test/trip-events.test.ts +++ b/packages/engine/test/trip-events.test.ts @@ -325,3 +325,18 @@ describe("hull wear (Step 26.9, decision 0.5.152)", () => { expect(ship.hullConditionPct).toBeLessThan(100); }); }); + +describe("service settings on new ships (Step 27.1, decision 0.5.157)", () => { + it("gives a bought ship auto-repair on at 40% and no booking", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + await setCredits(test.app, 100_000); + const { ship } = await test.app.call("buyShip", { + modelId: "courier", + bayId: owner.earthBayId, + }); + const saved = (await readSave(test.app)).ships.find((s) => s.id === ship.id)!; + expect(saved.autoRepair).toEqual({ on: true, thresholdPct: 40 }); + expect(saved.booking).toBeNull(); + }); +}); diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 2e14730..142ac39 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -105,6 +105,26 @@ export const savedShipSchema = z.object({ hullConditionPct: z.number().min(0).max(100), /** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */ quietTrips: z.number().int().nonnegative(), + /** + * Service booked at an HQ (decision 0.5.153): a repair and/or one upgrade + * level, booked by the player or by auto-repair; null when none. + */ + booking: z + .object({ + repair: z.boolean(), + upgradeId: idSchema.nullable(), + /** The HQ (a location with one of the player's bays). */ + hqId: idSchema, + by: z.enum(["player", "auto"]), + createdAt: timeSchema, + /** The work started in the shop; the booking clears when it ends. */ + startedAt: timeSchema.nullable(), + /** At the HQ but unable to pay, since then (decision 0.5.156). */ + waitingSince: timeSchema.nullable(), + }) + .nullable(), + /** Auto-repair (decision 0.5.157): on or off, and the hull percent that triggers it. */ + autoRepair: z.object({ on: z.boolean(), thresholdPct: z.number().min(0).max(100) }), /** Handed over in the deal rather than bought (CR-013). */ fromDeal: z.boolean(), createdAt: timeSchema, @@ -187,7 +207,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 11; +export const CURRENT_SAVE_VERSION = 12; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -520,6 +540,19 @@ function tripEvents(save: Doc): Doc { }; } +/** Version 11 → 12 (CR-027 §5): every ship on auto-repair at the default threshold, no booking. */ +function serviceBookings(save: Doc): Doc { + const thresholdPct = content.config.autoRepair.defaultThresholdPct; + return { + ...save, + ships: records(save.ships).map((ship) => ({ + ...ship, + booking: null, + autoRepair: { on: true, thresholdPct }, + })), + }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, @@ -531,6 +564,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 8, migrate: fleetSleep }, { from: 9, migrate: logbook }, { from: 10, migrate: tripEvents }, + { from: 11, migrate: serviceBookings }, ]; 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 8590d9e..920976d 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: 11, + saveVersion: 12, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -91,6 +91,8 @@ export function fullSave(): SaveDocument { shopRepair: false, hullConditionPct: 100, quietTrips: 2, + booking: null, + autoRepair: { on: true, thresholdPct: 40 }, fromDeal: true, createdAt: T, lastArrivalAt: T + 200_000, @@ -114,6 +116,8 @@ export function fullSave(): SaveDocument { shopRepair: false, hullConditionPct: 100, quietTrips: 2, + booking: null, + autoRepair: { on: true, thresholdPct: 40 }, fromDeal: true, createdAt: T, lastArrivalAt: T, diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 2d5589c..c14b5ae 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(11); + expect(CURRENT_SAVE_VERSION).toBe(12); }); 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(11); + expect(save.saveVersion).toBe(12); // 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(11); + expect(save.saveVersion).toBe(12); 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(11); + expect(result.save.saveVersion).toBe(12); 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(11); + expect(result.save.saveVersion).toBe(12); 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(11); + expect(result.save.saveVersion).toBe(12); expect(result.save.player.welcomeSeen).toBe(true); expect(result.save).toEqual(before); }); @@ -207,7 +207,7 @@ 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(11); + expect(result.save.saveVersion).toBe(12); // 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); @@ -225,7 +225,7 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => { delete old.company.sleepAfterHours; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(11); + expect(result.save.saveVersion).toBe(12); expect(result.save.company.lastManualDecisionAt).toBeNull(); expect(result.save.company.sleepAfterHours).toBe(24); expect(result.save).toEqual(before); @@ -245,7 +245,7 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => { for (const ship of old.ships) delete ship.shopOpen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(11); + expect(result.save.saveVersion).toBe(12); // The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up. expect(result.save.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]); expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]); @@ -285,7 +285,7 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => { } const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(11); + expect(result.save.saveVersion).toBe(12); expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]); expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]); // The deal is made, so the grace trips are behind it. @@ -293,3 +293,25 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => { expect(result.save).toEqual(before); }); }); + +describe("save migration 11 → 12: service bookings (CR-027 §5)", () => { + it("puts every ship on auto-repair at 40% with no booking, and changes nothing else", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + ships: Record[]; + }; + old.saveVersion = 11; + for (const ship of old.ships) { + delete ship.booking; + delete ship.autoRepair; + } + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(12); + expect(result.save.ships.map((s) => [s.booking, s.autoRepair])).toEqual([ + [null, { on: true, thresholdPct: 40 }], + [null, { on: true, thresholdPct: 40 }], + ]); + expect(result.save).toEqual(before); + }); +});