Step 27.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
49e7cca67a
commit
bd2a8de100
|
|
@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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: 11");
|
expect(text).toContain("Save version: 12");
|
||||||
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/);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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.2 — Sim: routing and triggers
|
||||||
- [ ] Step 27.3 — Engine: booking commands and service start
|
- [ ] Step 27.3 — Engine: booking commands and service start
|
||||||
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
|
- [ ] Step 27.4 — Engine: Independent pilots and catch-up
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ export const gameConfig: GameConfig = {
|
||||||
responseWindowRealMinutes: 30,
|
responseWindowRealMinutes: 30,
|
||||||
minTripFraction: 0.5,
|
minTripFraction: 0.5,
|
||||||
},
|
},
|
||||||
|
autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 },
|
||||||
hull: {
|
hull: {
|
||||||
floorPct: 5,
|
floorPct: 5,
|
||||||
ratingReductionPerPoint: 0.1,
|
ratingReductionPerPoint: 0.1,
|
||||||
|
|
|
||||||
|
|
@ -486,6 +486,13 @@ export const gameConfigSchema = z
|
||||||
/** A speed-up never makes a trip shorter than this share of it. */
|
/** A speed-up never makes a trip shorter than this share of it. */
|
||||||
minTripFraction: z.number().min(0).max(1),
|
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 condition and repairs (decision 0.5.148). */
|
||||||
hull: z.object({
|
hull: z.object({
|
||||||
floorPct: z.number().min(0).max(100),
|
floorPct: z.number().min(0).max(100),
|
||||||
|
|
@ -526,6 +533,17 @@ export const gameConfigSchema = z
|
||||||
path: ["fleetSleep", "optionsHours"],
|
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, {
|
.refine(({ events }) => events.fireFrom < events.fireTo, {
|
||||||
message: "Events fire inside the trip, early before late",
|
message: "Events fire inside the trip, early before late",
|
||||||
path: ["events", "fireFrom"],
|
path: ["events", "fireFrom"],
|
||||||
|
|
|
||||||
|
|
@ -147,3 +147,23 @@ describe("fleet sleep config (Step 24.1, decision 0.5.137)", () => {
|
||||||
expect(withSleep({ defaultHours: 12, optionsHours: [12] })).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ const validGameConfig = {
|
||||||
responseWindowRealMinutes: 30,
|
responseWindowRealMinutes: 30,
|
||||||
minTripFraction: 0.5,
|
minTripFraction: 0.5,
|
||||||
},
|
},
|
||||||
|
autoRepair: { defaultThresholdPct: 40, minPct: 10, maxPct: 90, stepPct: 5 },
|
||||||
hull: {
|
hull: {
|
||||||
floorPct: 5,
|
floorPct: 5,
|
||||||
ratingReductionPerPoint: 0.1,
|
ratingReductionPerPoint: 0.1,
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: content.config.events.shipCooldownTrips,
|
quietTrips: content.config.events.shipCooldownTrips,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct },
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastArrivalAt: now,
|
lastArrivalAt: now,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ export function buyShip(
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: content.config.events.shipCooldownTrips,
|
quietTrips: content.config.events.shipCooldownTrips,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: content.config.autoRepair.defaultThresholdPct },
|
||||||
fromDeal: false,
|
fromDeal: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastArrivalAt: now,
|
lastArrivalAt: now,
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,8 @@ export function makeOwner(
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: 2,
|
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.
|
// Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank.
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
|
|
@ -384,6 +386,8 @@ export function addShip(
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: 2,
|
quietTrips: 2,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: 40 },
|
||||||
fromDeal: false,
|
fromDeal: false,
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
lastArrivalAt: TEST_NOW,
|
lastArrivalAt: TEST_NOW,
|
||||||
|
|
|
||||||
|
|
@ -325,3 +325,18 @@ describe("hull wear (Step 26.9, decision 0.5.152)", () => {
|
||||||
expect(ship.hullConditionPct).toBeLessThan(100);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,26 @@ export const savedShipSchema = z.object({
|
||||||
hullConditionPct: z.number().min(0).max(100),
|
hullConditionPct: z.number().min(0).max(100),
|
||||||
/** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */
|
/** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */
|
||||||
quietTrips: z.number().int().nonnegative(),
|
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). */
|
/** Handed over in the deal rather than bought (CR-013). */
|
||||||
fromDeal: z.boolean(),
|
fromDeal: z.boolean(),
|
||||||
createdAt: timeSchema,
|
createdAt: timeSchema,
|
||||||
|
|
@ -187,7 +207,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 = 11;
|
export const CURRENT_SAVE_VERSION = 12;
|
||||||
|
|
||||||
export const saveDocumentSchema = z.object({
|
export const saveDocumentSchema = z.object({
|
||||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
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[] = [
|
export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
||||||
{ from: 1, migrate: addFuelTanks },
|
{ from: 1, migrate: addFuelTanks },
|
||||||
{ from: 2, migrate: manualRefueling },
|
{ from: 2, migrate: manualRefueling },
|
||||||
|
|
@ -531,6 +564,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
|
||||||
{ from: 8, migrate: fleetSleep },
|
{ from: 8, migrate: fleetSleep },
|
||||||
{ from: 9, migrate: logbook },
|
{ from: 9, migrate: logbook },
|
||||||
{ from: 10, migrate: tripEvents },
|
{ from: 10, migrate: tripEvents },
|
||||||
|
{ from: 11, migrate: serviceBookings },
|
||||||
];
|
];
|
||||||
|
|
||||||
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: 11,
|
saveVersion: 12,
|
||||||
saveId: "save-1",
|
saveId: "save-1",
|
||||||
lastTimeSeen: T + 3_600_000,
|
lastTimeSeen: T + 3_600_000,
|
||||||
player: {
|
player: {
|
||||||
|
|
@ -91,6 +91,8 @@ export function fullSave(): SaveDocument {
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: 2,
|
quietTrips: 2,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: 40 },
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
lastArrivalAt: T + 200_000,
|
lastArrivalAt: T + 200_000,
|
||||||
|
|
@ -114,6 +116,8 @@ export function fullSave(): SaveDocument {
|
||||||
shopRepair: false,
|
shopRepair: false,
|
||||||
hullConditionPct: 100,
|
hullConditionPct: 100,
|
||||||
quietTrips: 2,
|
quietTrips: 2,
|
||||||
|
booking: null,
|
||||||
|
autoRepair: { on: true, thresholdPct: 40 },
|
||||||
fromDeal: true,
|
fromDeal: true,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
lastArrivalAt: T,
|
lastArrivalAt: T,
|
||||||
|
|
|
||||||
|
|
@ -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(11);
|
expect(CURRENT_SAVE_VERSION).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(11);
|
expect(save.saveVersion).toBe(12);
|
||||||
// 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(11);
|
expect(save.saveVersion).toBe(12);
|
||||||
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
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,7 +207,7 @@ 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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
// 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);
|
||||||
|
|
@ -225,7 +225,7 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => {
|
||||||
delete old.company.sleepAfterHours;
|
delete old.company.sleepAfterHours;
|
||||||
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
expect(result.save.company.lastManualDecisionAt).toBeNull();
|
expect(result.save.company.lastManualDecisionAt).toBeNull();
|
||||||
expect(result.save.company.sleepAfterHours).toBe(24);
|
expect(result.save.company.sleepAfterHours).toBe(24);
|
||||||
expect(result.save).toEqual(before);
|
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;
|
for (const ship of old.ships) delete ship.shopOpen;
|
||||||
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
// The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up.
|
// 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.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]);
|
||||||
expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]);
|
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);
|
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(11);
|
expect(result.save.saveVersion).toBe(12);
|
||||||
expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]);
|
expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]);
|
||||||
expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]);
|
expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]);
|
||||||
// The deal is made, so the grace trips are behind it.
|
// 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);
|
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<string, unknown> & {
|
||||||
|
ships: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue