587 lines
21 KiB
TypeScript
587 lines
21 KiB
TypeScript
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 { appliedEventSchema, savedTripEventSchema } from "./events";
|
||
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(),
|
||
/** The shop job is a hull repair, not an upgrade (decision 0.5.148). */
|
||
shopRepair: z.boolean(),
|
||
/** Hull condition, 5–100% (decisions 0.5.147, 0.5.148). */
|
||
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(),
|
||
/** 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 trip's event, rolled at dispatch (decision 0.5.143); null for a quiet trip. */
|
||
event: savedTripEventSchema.nullable(),
|
||
/** What the event changed once applied; null until then. */
|
||
eventApplied: appliedEventSchema.nullable(),
|
||
});
|
||
|
||
/** The save version this build writes. Bump it with a new migration. */
|
||
export const CURRENT_SAVE_VERSION = 11;
|
||
|
||
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(),
|
||
/** The company has had its first event, the derelict pod (decision 0.5.145). */
|
||
firstEventDone: z.boolean(),
|
||
/** Trips sent since the deal: the first few are quiet (decision 0.5.145). */
|
||
tripsSinceDeal: z.number().int().nonnegative(),
|
||
}),
|
||
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<typeof saveDocumentSchema>;
|
||
|
||
// The saved shapes are the sim's own: a change on either side fails the build.
|
||
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
|
||
const _sameContract: Same<z.infer<typeof contractSchema>, SimContract> = true;
|
||
const _sameRecord: Same<z.infer<typeof dispatchRecordSchema>, DispatchRecord> = true;
|
||
const _sameResolution: Same<z.infer<typeof resolutionSchema>, Resolution> = true;
|
||
const _sameFleetMember: Same<z.infer<typeof fleetMemberSchema>, 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<string, unknown>): Record<string, unknown>;
|
||
}
|
||
|
||
type Doc = Record<string, unknown>;
|
||
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<string, number>;
|
||
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,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Version 10 → 11 (CR-026 §6): hulls at 100%, every ship past its event
|
||
* cooldown, no event on a flight already in the air, and the company yet to
|
||
* have its first event (past the grace trips if it has made the deal).
|
||
*/
|
||
function tripEvents(save: Doc): Doc {
|
||
const { events } = content.config;
|
||
const player = save.player as Doc;
|
||
return {
|
||
...save,
|
||
company: {
|
||
...(save.company as Doc),
|
||
firstEventDone: false,
|
||
tripsSinceDeal: player.dealAccepted ? events.graceTripsAfterDeal : 0,
|
||
},
|
||
ships: records(save.ships).map((ship) => ({
|
||
...ship,
|
||
shopRepair: false,
|
||
hullConditionPct: 100,
|
||
quietTrips: events.shipCooldownTrips,
|
||
})),
|
||
dispatches: records(save.dispatches).map((d) => ({ ...d, event: null, eventApplied: null })),
|
||
};
|
||
}
|
||
|
||
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 },
|
||
{ from: 10, migrate: tripEvents },
|
||
];
|
||
|
||
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };
|
||
type Stepped = { ok: true; save: Record<string, unknown> } | { 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<string, unknown>;
|
||
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)}`,
|
||
};
|
||
}
|