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

334 lines
14 KiB
TypeScript
Raw Permalink 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 { describe, expect, it } from "vitest";
import {
applyMigrations,
CURRENT_SAVE_VERSION,
migrateSave,
saveDocumentSchema,
type SaveMigration,
} from "../src/index";
import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
expect(CURRENT_SAVE_VERSION).toBe(13);
});
it("rejects an invalid save with a readable error naming the field", () => {
const broken = fullSave() as unknown as { company: { credits: unknown } };
broken.company.credits = "lots";
const result = migrateSave(broken);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("The save is damaged");
expect(result.error).toContain("company.credits");
}
});
it("rejects things that aren't saves, and saves from a newer build", () => {
expect(migrateSave("hello")).toEqual({ ok: false, error: "The save isn't a save document." });
expect(migrateSave({ company: {} })).toEqual({
ok: false,
error: "The save has no valid saveVersion.",
});
const future = migrateSave({ ...fullSave(), saveVersion: CURRENT_SAVE_VERSION + 1 });
expect(future.ok).toBe(false);
if (!future.ok) expect(future.error).toContain("newer version");
});
it("reads a current save as it is", () => {
const result = migrateSave(JSON.parse(JSON.stringify(fullSave())));
expect(result).toEqual({ ok: true, save: fullSave() });
});
it("chains migrations in order, one version at a time", () => {
const seen: number[] = [];
const migrations: SaveMigration[] = [
{ from: 2, migrate: (s) => (seen.push(2), { ...s, extra: [...(s.extra as number[]), 2] }) },
{ from: 1, migrate: (s) => (seen.push(1), { ...s, extra: [1] }) },
];
const result = applyMigrations({ saveVersion: 1 }, migrations, 3);
expect(result).toEqual({ ok: true, save: { saveVersion: 3, extra: [1, 2] } });
expect(seen).toEqual([1, 2]);
expect(applyMigrations({ saveVersion: 1 }, [], 2)).toEqual({
ok: false,
error: "No migration from save version 1.",
});
});
});
/** The fixture as a version-1 save: before fuel tanks (CR-015). */
type Loose = Record<string, unknown> & {
ships: Record<string, unknown>[];
dispatches: { record: Record<string, unknown>; resolution: Record<string, unknown> | null }[];
};
function versionOne(): Record<string, unknown> {
const save = JSON.parse(JSON.stringify(fullSave())) as Loose;
save.saveVersion = 1;
for (const ship of save.ships) {
delete ship.fuelFu;
delete ship.fuelAvgPrice;
delete ship.refuelPolicy;
}
for (const dispatch of save.dispatches) {
delete dispatch.record.fuelBurnedFu;
if (dispatch.resolution) delete dispatch.resolution.topUp;
}
return save;
}
describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => {
it("fills every ship's tank and changes nothing else (then 2 → 3 puts it on Manual)", () => {
const result = migrateSave(versionOne());
if (!result.ok) throw new Error(result.error);
const { save } = result;
const before = fullSave();
expect(save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base
// price, 5 cr/fu since CR-018 (decision 0.5.110).
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" });
// The Clipper is at Earth: a full 1,170 fu at Earth's 8 cr/fu.
expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 8, refuelPolicy: "manual" });
// Earlier trips paid fuel in credits: nothing came out of a tank, nothing topped up.
for (const dispatch of save.dispatches) {
expect(dispatch.record.fuelBurnedFu).toBe(0);
if (dispatch.resolution) expect(dispatch.resolution.topUp).toBeNull();
}
// In-flight dispatches keep their arrival times; everything else is as it was.
expect(save.dispatches.map((d) => d.arrivalAt)).toEqual(
before.dispatches.map((d) => d.arrivalAt),
);
expect(save.company).toEqual(before.company);
expect(save.pilots).toEqual(before.pilots);
});
});
describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => {
it("puts every ship on Manual and leaves fuel and credits as they were", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
ships: Record<string, unknown>[];
};
old.saveVersion = 2;
old.ships[0]!.refuelPolicy = "auto";
old.ships[1]!.refuelPolicy = "auto";
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
const { save } = result;
expect(save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual"));
expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual(
before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]),
);
expect(save.company).toEqual(before.company);
// Boards go in 3 → 4 (CR-019); everything else is as it was.
expect({ ...save, ships: [], contractBoards: [] }).toEqual({
...before,
ships: [],
contractBoards: [],
});
});
});
describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
it("drops stored boards and leaves everything else as it was", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
contractBoards: Record<string, unknown>[];
};
old.saveVersion = 3;
// A version-3 board had no fleet snapshot.
for (const board of old.contractBoards) delete board.fleet;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.contractBoards).toEqual([]);
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
});
});
describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => {
it("puts every pilot on Manual with default orders, with no Dispatch Office", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
company: Record<string, unknown>;
pilots: Record<string, unknown>[];
};
old.saveVersion = 4;
delete old.company.dispatchOffice;
delete old.company.npcSpendingFloor;
for (const pilot of old.pilots) {
delete pilot.mode;
delete pilot.orders;
}
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
for (const pilot of result.save.pilots) {
expect(pilot.mode).toBe("manual");
expect(pilot.orders).toEqual({
destinationIds: null,
cargoClassIds: ["standard", "heavy", "high-value", "dangerous"],
takePriority: true,
minProfit: 0,
homeLocationId: "earth",
});
}
// The fixture already holds these defaults, so nothing else changes.
expect(result.save).toEqual(before);
});
});
describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
it("marks an existing company's welcome as seen and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
player: Record<string, unknown>;
};
old.saveVersion = 6;
delete old.player.welcomeSeen;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.player.welcomeSeen).toBe(true);
expect(result.save).toEqual(before);
});
});
describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => {
it("gives no ship a pilot waiting out the shop, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
ships: Record<string, unknown>[];
};
old.saveVersion = 7;
for (const ship of old.ships) delete ship.shopPilotId;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// Including the ship still in the shop: its upgrade finishes without the rest.
expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null));
expect(result.save).toEqual(before);
});
});
describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => {
it("sets the default threshold and leaves the last decision for the engine to stamp", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
company: Record<string, unknown>;
};
old.saveVersion = 8;
delete old.company.lastManualDecisionAt;
delete old.company.sleepAfterHours;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.company.lastManualDecisionAt).toBeNull();
expect(result.save.company.sleepAfterHours).toBe(24);
expect(result.save).toEqual(before);
});
});
describe("save migration 9 → 10: the logbook (CR-025 §4)", () => {
it("turns resolved dispatches into entries, leaves the shop open for catch-up, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
player: Record<string, unknown>;
ships: Record<string, unknown>[];
};
old.saveVersion = 9;
delete old.logbook;
for (const key of ["awaySince", "lastAbsence", "readyGoalsLogged"]) delete old.player[key];
for (const ship of old.ships) delete ship.shopOpen;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up.
expect(result.save.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]);
expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]);
expect(result.save).toEqual(before);
});
it("starts the absence at the last summary the player dismissed", () => {
const old = JSON.parse(JSON.stringify(fullSave())) as Record<string, unknown> & {
player: Record<string, unknown>;
dispatches: Record<string, unknown>[];
};
old.saveVersion = 9;
delete old.logbook;
old.dispatches[1]!.acknowledgedAt = 1_234;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.player.awaySince).toBe(1_234);
});
});
describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => {
it("gives every hull 100%, no event to flights in the air, and no first event yet", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
company: Record<string, unknown>;
ships: Record<string, unknown>[];
dispatches: Record<string, unknown>[];
};
old.saveVersion = 10;
for (const key of ["firstEventDone", "tripsSinceDeal"]) delete old.company[key];
for (const ship of old.ships) {
for (const key of ["shopRepair", "hullConditionPct", "quietTrips"]) delete ship[key];
}
for (const d of old.dispatches) {
delete d.event;
delete d.eventApplied;
}
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]);
expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]);
// The deal is made, so the grace trips are behind it.
expect(result.save.company).toMatchObject({ firstEventDone: false, tripsSinceDeal: 3 });
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(CURRENT_SAVE_VERSION);
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);
});
});
describe("save migration 12 → 13: seen entries (CR-028 Step 28.1)", () => {
it("marks every logbook entry already there as seen, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
logbook: Record<string, unknown>[];
};
old.saveVersion = 12;
for (const entry of old.logbook) delete entry.seen;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(13);
expect(result.save.logbook.map((e) => e.seen)).toEqual([true, true]);
expect(result.save).toEqual(before);
});
});