deadhead-space-freight-tycoon/packages/schema/src/save.ts

587 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)}`,
};
}