import { content } from "@deadhead/content"; import { effectiveStats, fuelPriceAt, type Contract as SimContract, type DispatchRecord, type FleetMember, type Resolution, } from "@deadhead/sim"; import { z } from "zod"; import { logEntrySchema } from "./logbook"; import { contractSchema, idSchema, pilotModeSchema, pilotOrdersSchema, timeSchema, } from "./entities"; /** * The save document (CR-014): everything the game database held, for the one * company on this device. Validated on every load and every save; each * `saveVersion` change ships with a migration (see `migrateSave`). */ const credits = z.number().int(); const activity = z.enum(["idle", "in-flight"]); /** The sim's dispatch record, stored as it was created. */ export const dispatchRecordSchema = z.object({ id: idSchema, shipId: idSchema, pilotId: idSchema, contract: contractSchema.nullable(), originId: idSchema, destinationId: idSchema, tutorial: z.boolean(), departureAt: timeSchema, arrivalAt: timeSchema, distanceMillionKm: z.number().nonnegative(), /** Fuel taken from the tank at departure (CR-015); 0 for tutorial runs and pre-tank trips. */ fuelBurnedFu: z.number().nonnegative(), fuelCost: credits.nonnegative(), expectedPayout: credits, seed: z.number().int().nonnegative(), }); /** A ship's tank (decision 0.5.94): fuel and its average purchase price. */ const tankSchema = z.object({ fuelFu: z.number().nonnegative(), avgPrice: z.number().nonnegative(), }); /** What resolving a dispatch did, kept for the "while you were away" summary. */ export const resolutionSchema = z.object({ credits, /** Auto top-up on landing (decision 0.5.97). */ topUp: z .object({ locationId: idSchema, boughtFu: z.number().positive(), price: z.number().positive(), cost: credits.nonnegative(), tank: tankSchema, }) .nullable(), pilotPay: credits.nonnegative(), loanRepayment: credits.nonnegative(), pilotXp: z.number().int().nonnegative(), shipLocationId: idSchema, discoversDestination: z.boolean(), pilotDutyHours: z.number().nonnegative(), pilotRestEndsAt: timeSchema.nullable(), }); export const savedShipSchema = z.object({ id: idSchema, modelId: idSchema, name: z.string().min(1), status: activity, /** Null while in flight. */ locationId: idSchema.nullable(), /** Installed upgrade levels (CR-009); a missing upgrade is level 0. */ upgrades: z.record(idSchema, z.number().int().positive()), /** In the upgrade shop until this time (decision 0.5.66); null or past: not. */ inShopUntil: timeSchema.nullable(), /** The latest shop job, kept until the player has seen it. */ shopUpgradeId: idSchema.nullable(), shopLevel: z.number().int().positive().nullable(), /** * The pilot aboard when the upgrade started, while they stay with the ship: * they come out rested (decision 0.5.135). Cleared when they leave it, and * when the shop time ends. */ shopPilotId: idSchema.nullable(), /** * An upgrade's shop time hasn't been through catch-up yet: its end logs * "upgrade finished" (decision 0.5.141) and settles the shop rest. */ shopOpen: z.boolean(), /** Handed over in the deal rather than bought (CR-013). */ fromDeal: z.boolean(), createdAt: timeSchema, /** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */ lastArrivalAt: timeSchema, /** Fuel on board, in fu (decision 0.5.94). */ fuelFu: z.number().nonnegative(), /** Credits per fu, weighted by what was bought where (decision 0.5.98). */ fuelAvgPrice: z.number().nonnegative(), /** Auto top-up on landing, or Manual (decision 0.5.97). */ refuelPolicy: z.enum(["auto", "manual"]), }); export const savedPilotSchema = z.object({ id: idSchema, name: z.string().min(1), isAvatar: z.boolean(), isVeteran: z.boolean(), xp: z.number().int().nonnegative(), skills: z.record(idSchema, z.number().int().nonnegative()), skillPointsSpent: z.number().int().nonnegative(), assignedShipId: idSchema.nullable(), /** Only unassigned pilots store a location (decision 0.5.27). */ locationId: idSchema.nullable(), status: activity, dutyHours: z.number().nonnegative(), restEndsAt: timeSchema.nullable(), createdAt: timeSchema, /** Manual or Independent, and orders (decision 0.5.124). */ mode: pilotModeSchema, orders: pilotOrdersSchema, /** An Independent pilot's next decision point (CR-021 §2); null while flying or Manual. */ nextDecisionAt: timeSchema.nullable(), /** Board periods in a row an Independent pilot found nothing to take (decision 0.5.128). */ emptyPeriods: z.number().int().nonnegative(), }); /** One ship-and-pilot pair's lasting capabilities (decision 0.5.115). */ export const fleetMemberSchema = z.object({ modelId: idSchema, skills: z.record(idSchema, z.number().int().nonnegative()), upgrades: z.record(idSchema, z.number().int().positive()).optional(), fuelFu: z.number().nonnegative().optional(), }); export const savedBoardSchema = z.object({ id: idSchema, kind: z.enum(["location", "tutorial"]), locationId: idSchema, periodIndex: z.number().int(), /** Tutorial boards: runs completed when generated; 0 for location boards. */ tutorialRun: z.number().int().nonnegative(), contracts: z.array(contractSchema), /** * Location boards: the qualifying fleet they were generated for (CR-019), * so a board can be reproduced. Null for tutorial and hand-made boards. */ fleet: z.array(fleetMemberSchema).nullable(), createdAt: timeSchema, }); export const savedDispatchSchema = z.object({ id: idSchema, /** Null for a tutorial run (the loaner is not an owned ship). */ shipId: idSchema.nullable(), pilotId: idSchema, tutorial: z.boolean(), /** Flown by an Independent pilot's own choice (CR-021): no notification, in the logbook. */ independent: z.boolean(), record: dispatchRecordSchema, arrivalAt: timeSchema, status: z.enum(["in-flight", "resolved"]), resolution: resolutionSchema.nullable(), resolvedAt: timeSchema.nullable(), acknowledgedAt: timeSchema.nullable(), }); /** The save version this build writes. Bump it with a new migration. */ export const CURRENT_SAVE_VERSION = 10; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), /** Identifies this company's save (debug info, notifications). */ saveId: idSchema, /** The latest time the game has seen (decision 0.5.88): the clock never goes below it. */ lastTimeSeen: timeSchema, player: z.object({ /** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */ worldSeed: z.number().int().nonnegative().max(0xffff_ffff), tutorialActive: z.boolean(), loanerLocationId: idSchema.nullable(), tutorialRunsCompleted: z.number().int().nonnegative(), dealAccepted: z.boolean(), createdAt: timeSchema, /** This company's welcome cards have been seen (decision 0.5.131). */ welcomeSeen: z.boolean(), /** * When "While you were away" was last closed: the current absence runs * from here (decision 0.5.140). */ awaySince: timeSchema, /** The absence the last closed pop-up covered, for the Logbook's "This absence". */ lastAbsence: z.object({ from: timeSchema, to: timeSchema }).nullable(), /** Goals already logged as ready to claim, so each is logged once. */ readyGoalsLogged: z.array(idSchema), }), company: z.object({ id: idSchema, name: z.string().min(1), credits, loanBalance: credits.nonnegative(), /** The Dispatch Office is bought (decision 0.5.123). */ 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), bays: z.array(z.object({ id: idSchema, locationId: idSchema, createdAt: timeSchema })), discoveredLocations: z.array(z.object({ locationId: idSchema, discoveredAt: timeSchema })), contractBoards: z.array(savedBoardSchema), dispatches: z.array(savedDispatchSchema), acceptedContracts: z.array( z.object({ contractId: idSchema, periodIndex: z.number().int(), dispatchId: idSchema, acceptedAt: timeSchema, }), ), claimedGoals: z.array(z.object({ goalId: idSchema, claimedAt: timeSchema })), /** * Lifetime counts for goals, kept apart from `dispatches` so old resolved * dispatches can be pruned (CR-014 §2) without undoing progress. */ stats: z.object({ /** Contracts (not tutorial runs or deadheads) delivered. */ contractDeliveries: z.number().int().nonnegative(), priorityDeliveries: z.number().int().nonnegative(), }), /** The last 7 days of what happened (decision 0.5.141), oldest first. */ logbook: z.array(logEntrySchema), }); export type SaveDocument = z.infer; // The saved shapes are the sim's own: a change on either side fails the build. type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _sameContract: Same, SimContract> = true; const _sameRecord: Same, DispatchRecord> = true; const _sameResolution: Same, Resolution> = true; const _sameFleetMember: Same, FleetMember> = true; void [_sameContract, _sameRecord, _sameResolution, _sameFleetMember]; /** One step: a save of version `from` to version `from + 1`. */ export interface SaveMigration { from: number; migrate(save: Record): Record; } type Doc = Record; const records = (value: unknown): Doc[] => (Array.isArray(value) ? (value as Doc[]) : []); /** * Version 1 → 2 (CR-015 §5, fuel tanks): every ship gets a full tank, bought at * the price where it sits (the base price while in flight), on Auto top-up. * Earlier trips paid fuel in credits, so their records burned no tank fuel and * their resolutions had no top-up. Arrival times are untouched. */ function addFuelTanks(save: Doc): Doc { const { config } = content; const ships = records(save.ships).map((ship) => { const model = content.shipModel(String(ship.modelId)); const upgrades = (ship.upgrades ?? {}) as Record; const capacity = model ? effectiveStats(model, upgrades, content).tankCapacityFu : 0; const at = ship.locationId ? content.location(String(ship.locationId)) : undefined; return { ...ship, fuelFu: capacity, fuelAvgPrice: at ? fuelPriceAt(at, config) : config.baseFuelPricePerFu, refuelPolicy: "auto", }; }); const dispatches = records(save.dispatches).map((dispatch) => ({ ...dispatch, record: { ...(dispatch.record as Doc), fuelBurnedFu: 0 }, resolution: dispatch.resolution ? { ...(dispatch.resolution as Doc), topUp: null } : null, })); return { ...save, ships, dispatches }; } /** * Version 2 → 3 (CR-018 §3): every ship goes to Manual refueling. Auto was the * default, not a choice; fuel, prices and credits are untouched. */ function manualRefueling(save: Doc): Doc { const ships = records(save.ships).map((ship) => ({ ...ship, refuelPolicy: "manual" })); return { ...save, ships }; } /** * Every migration, in order: `from` → `from + 1`. A format change adds one * here and bumps `CURRENT_SAVE_VERSION`. */ /** * Version 3 → 4 (CR-019 §3): stored boards are dropped. They were made before * boards kept only jobs the fleet can complete, and regenerate on next view. */ function dropBoards(save: Doc): Doc { return { ...save, contractBoards: [] }; } /** * Version 4 → 5 (CR-021 §3): every pilot Manual with default orders (home at * Earth, where the deal's bay is), no Dispatch Office, the default spending floor. */ function independentPilots(save: Doc): Doc { const orders = { destinationIds: null, cargoClassIds: content.cargoTypes.map((c) => c.id), takePriority: true, minProfit: 0, homeLocationId: content.config.deal.bayLocationId, }; return { ...save, company: { ...(save.company as Doc), dispatchOffice: false, npcSpendingFloor: content.config.independentPilots.defaultSpendingFloor, }, pilots: records(save.pilots).map((pilot) => ({ ...pilot, mode: "manual", orders: { ...orders, cargoClassIds: [...orders.cargoClassIds] }, })), }; } /** * Version 5 → 6 (CR-021 Step 21.4): catch-up state. No pilot has a decision * pending or empty periods counted, and no dispatch was an Independent choice. */ function catchUpState(save: Doc): Doc { return { ...save, pilots: records(save.pilots).map((pilot) => ({ ...pilot, nextDecisionAt: null, emptyPeriods: 0, })), dispatches: records(save.dispatches).map((dispatch) => ({ ...dispatch, independent: false })), }; } /** * Version 6 → 7 (CR-022 §3): companies from before the welcome cards count as * having seen them, so nobody gets them mid-game. */ function welcomeSeen(save: Doc): Doc { return { ...save, player: { ...(save.player as Doc), welcomeSeen: true } }; } /** * Version 7 → 8 (CR-023): no ship has a pilot waiting out the shop. An upgrade * already under way finishes as before, without the rest. */ function shopPilot(save: Doc): Doc { return { ...save, ships: records(save.ships).map((ship) => ({ ...ship, shopPilotId: null })), }; } /** * 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, }, }; } /** * Version 9 → 10 (CR-025 §4): the logbook. Resolved dispatches still in the * save become flight entries, with their top-ups, rests and discoveries; * level-ups can't be rebuilt and are dropped. An upgrade still showing in the * shop is left open, so catch-up logs its end. The absence runs from the last * time a summary was dismissed. */ function logbook(save: Doc): Doc { const entries: Doc[] = []; let seen = 0; for (const d of records(save.dispatches)) { const r = d.resolution as Doc | null; if (d.status !== "resolved" || !r) continue; const record = d.record as Doc; const contract = record.contract as Doc | null; const at = (d.resolvedAt as number | null) ?? (d.arrivalAt as number); if (typeof d.acknowledgedAt === "number") seen = Math.max(seen, d.acknowledgedAt); const who = { pilotId: d.pilotId, shipId: d.shipId ?? null }; const pilotPay = r.pilotPay as number; const loanRepayment = r.loanRepayment as number; entries.push({ id: `${d.id}:flight`, at, ...who, kind: "flight", dispatchId: d.id, deadhead: contract === null, tutorial: d.tutorial, independent: d.independent, originId: record.originId, destinationId: record.destinationId, goodId: contract?.goodId ?? null, massTonnes: contract?.massTonnes ?? null, shipper: contract?.shipper ?? null, payout: Math.max(0, (r.credits as number) + pilotPay + loanRepayment), pilotPay, fuelCost: record.fuelCost, loanRepayment, xp: r.pilotXp, scriptedId: contract?.scriptedId ?? null, }); const topUp = r.topUp as Doc | null; if (topUp && (topUp.boughtFu as number) > 0) { entries.push({ id: `${d.id}:top-up`, at, ...who, kind: "refuel", locationId: topUp.locationId, boughtFu: topUp.boughtFu, cost: topUp.cost, source: "top-up", }); } if (typeof r.pilotRestEndsAt === "number") { entries.push({ id: `${d.id}:rest`, at, ...who, kind: "rest", until: r.pilotRestEndsAt }); } if (r.discoversDestination) { entries.push({ id: `${d.id}:discovery`, at, ...who, kind: "discovery", locationId: record.destinationId, }); } } entries.sort((a, b) => (a.at as number) - (b.at as number)); const player = save.player as Doc; return { ...save, player: { ...player, awaySince: seen > 0 ? seen : player.createdAt, lastAbsence: null, readyGoalsLogged: [], }, ships: records(save.ships).map((ship) => ({ ...ship, shopOpen: ship.inShopUntil !== null && ship.shopUpgradeId !== null, })), logbook: entries, }; } export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, { from: 3, migrate: dropBoards }, { from: 4, migrate: independentPilots }, { from: 5, migrate: catchUpState }, { from: 6, migrate: welcomeSeen }, { from: 7, migrate: shopPilot }, { from: 8, migrate: fleetSleep }, { from: 9, migrate: logbook }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; type Stepped = { ok: true; save: Record } | { ok: false; error: string }; /** * Steps a save from its version up to `current`, one migration at a time. * Doesn't validate the result; `migrateSave` does. */ export function applyMigrations( raw: unknown, migrations: readonly SaveMigration[], current: number, ): Stepped { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { return { ok: false, error: "The save isn't a save document." }; } let save = raw as Record; let version = save.saveVersion; if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { return { ok: false, error: "The save has no valid saveVersion." }; } if (version > current) { return { ok: false, error: `The save is from a newer version of the game (save version ${version}, this build reads up to ${current}).`, }; } while (version < current) { const step = migrations.find((m) => m.from === version); if (!step) return { ok: false, error: `No migration from save version ${version}.` }; save = { ...step.migrate(save), saveVersion: version + 1 }; version += 1; } return { ok: true, save }; } /** * Migrates a save of any known version to the current one and validates it. * Never throws: an unknown or broken save gives a readable error. */ export function migrateSave(raw: unknown): SaveParse { const stepped = applyMigrations(raw, SAVE_MIGRATIONS, CURRENT_SAVE_VERSION); if (!stepped.ok) return stepped; const parsed = saveDocumentSchema.safeParse(stepped.save); return parsed.success ? { ok: true, save: parsed.data } : { ok: false, error: `The save is damaged: ${z.prettifyError(parsed.error)}`, }; }