268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
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(10);
|
||
});
|
||
|
||
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(10);
|
||
// 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(10);
|
||
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(10);
|
||
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(10);
|
||
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(10);
|
||
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(10);
|
||
// 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(10);
|
||
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(10);
|
||
// 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);
|
||
});
|
||
});
|