import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine, CURRENT_SAVE_VERSION, SAVE_FORMAT, type InventorySystem, type PlantingSystem, type SaveSystem, type TimeSystem, type WorldSnapshot, } from "../src/index.js"; const repoRoot = resolve(process.cwd(), "../.."); const contentDir = resolve(repoRoot, "content"); const modsDir = resolve(repoRoot, "mods"); function engineSetup() { return createEngine({ contentDir, modsDir }); } function saveOf(engine: ReturnType): SaveSystem { const save = engine.systems.get("save"); if (!save) throw new Error("save system not booted"); return save; } function inventoryOf(engine: ReturnType): InventorySystem { const inv = engine.systems.get("inventory"); if (!inv) throw new Error("inventory system not booted"); return inv; } function plantingOf(engine: ReturnType): PlantingSystem { const planting = engine.systems.get("planting"); if (!planting) throw new Error("planting system not booted"); return planting; } describe("SaveSystem", () => { it("snapshot after start serializes the seeded world with format and version", async () => { const engine = engineSetup(); await engine.start(); try { const snap = saveOf(engine).snapshot(); expect(snap.format).toBe(SAVE_FORMAT); expect(snap.version).toBe(CURRENT_SAVE_VERSION); expect(typeof snap.savedAt).toBe("string"); expect(snap.savedAt.length).toBeGreaterThan(0); expect(snap.world.map((e) => e.type).sort()).toEqual(["map", "player", "weather"]); expect(snap.world.find((e) => e.type === "map")).toMatchObject({ id: "farm", width: 12, height: 12 }); expect(snap.world.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 2, y: 2 }); } finally { await engine.stop(); } }); it("snapshot returns entity copies, not aliases into the live world", async () => { const engine = engineSetup(); await engine.start(); try { const snap = saveOf(engine).snapshot(); const playerInSnap = snap.world.find((e) => e.id === "player"); if (!playerInSnap) throw new Error("player entity not in snapshot"); playerInSnap.x = 999; expect(engine.world.get("player")?.x).toBe(2); } finally { await engine.stop(); } }); it("snapshot reflects world mutations: inventory, gold, and plot entities", async () => { const engine = engineSetup(); await engine.start(); try { inventoryOf(engine).give("parsnip-seed", 5); plantingOf(engine).till(6, 6); plantingOf(engine).sow(6, 6, "parsnip-seed"); const player = engine.world.get("player"); if (!player) throw new Error("player entity not seeded"); player.gold = 1234; const snap = saveOf(engine).snapshot(); expect(snap.world.find((e) => e.id === "player")).toMatchObject({ gold: 1234, inventory: { "parsnip-seed": 4 }, }); expect(snap.world.find((e) => e.id === "plot:6:6")).toMatchObject({ type: "plot", x: 6, y: 6, tilled: true, watered: false, cropItemId: "parsnip", stageIndex: 0, daysInStage: 0, }); } finally { await engine.stop(); } }); it("restore replaces all world entities, preserving inventory, gold, and plot fields", async () => { const engineA = engineSetup(); await engineA.start(); try { inventoryOf(engineA).give("parsnip", 2); inventoryOf(engineA).give("parsnip-seed", 4); const playerA = engineA.world.get("player"); if (!playerA) throw new Error("player entity not seeded"); playerA.gold = 77; plantingOf(engineA).till(3, 3); plantingOf(engineA).sow(3, 3, "parsnip-seed"); plantingOf(engineA).water(3, 3); const snap = saveOf(engineA).snapshot(); const engineB = engineSetup(); await engineB.start(); try { const result = saveOf(engineB).restore(snap); expect(result).toEqual({ restored: snap.world.length }); expect(engineB.world.query()).toHaveLength(snap.world.length); expect(engineB.world.get("player")).toMatchObject({ gold: 77, inventory: { parsnip: 2, "parsnip-seed": 3 }, x: 2, y: 2, }); expect(engineB.world.get("plot:3:3")).toMatchObject({ type: "plot", x: 3, y: 3, tilled: true, watered: true, cropItemId: "parsnip", stageIndex: 0, daysInStage: 0, }); expect(engineB.world.get("farm")).toMatchObject({ type: "map", width: 12, height: 12 }); } finally { await engineB.stop(); } } finally { await engineA.stop(); } }); it("restore with a malformed snapshot throws", async () => { const engine = engineSetup(); await engine.start(); try { const save = saveOf(engine); expect(() => save.restore({ format: "nengine-save" })).toThrow(/invalid snapshot/); expect(() => save.restore(null)).toThrow(/invalid snapshot/); expect(() => save.restore("nope")).toThrow(/invalid snapshot/); expect(() => save.restore({ format: "nengine-save", version: 1, savedAt: "x", world: [{ id: "", type: "player" }] }), ).toThrow(/invalid snapshot/); expect(() => save.restore({ format: "nengine-save", version: 1, savedAt: "x", world: [{ id: "player" }] }), ).toThrow(/invalid snapshot/); } finally { await engine.stop(); } }); it("restore rejects a snapshot from a future save version and changes nothing", async () => { const engine = engineSetup(); await engine.start(); try { const save = saveOf(engine); const snap = save.snapshot(); const future = { ...snap, version: CURRENT_SAVE_VERSION + 1 }; expect(() => save.restore(future)).toThrow(/future save version/); expect(engine.world.query()).toHaveLength(3); } finally { await engine.stop(); } }); it("migrate no-ops when versions are equal and returns the same snapshot", async () => { const engine = engineSetup(); await engine.start(); try { const save = saveOf(engine); const snap = save.snapshot(); expect(save.migrate(snap, CURRENT_SAVE_VERSION, CURRENT_SAVE_VERSION)).toBe(snap); expect(save.migrate(snap)).toBe(snap); } finally { await engine.stop(); } }); it("migrate with an empty registry leaves the snapshot unchanged but validates it", async () => { const engine = engineSetup(); await engine.start(); try { const save = saveOf(engine); const snap = save.snapshot(); const forward = save.migrate(snap, 1, CURRENT_SAVE_VERSION + 2); expect(forward).toEqual(snap); expect(forward.version).toBe(CURRENT_SAVE_VERSION); expect(save.validate(forward)).toEqual({ ok: true }); expect(() => save.migrate(snap, 2, 1)).toThrow(/backwards/); expect(() => save.migrate(snap, 0, 1)).toThrow(/positive integer/); expect(() => save.migrate({ format: "nengine-save" }, 1, 2)).toThrow(/invalid snapshot/); } finally { await engine.stop(); } }); it("validate reports valid snapshots and collects structural errors for invalid ones", async () => { const engine = engineSetup(); await engine.start(); try { const save = saveOf(engine); expect(save.validate(save.snapshot())).toEqual({ ok: true }); const missingWorld = save.validate({ format: "nengine-save", version: 1, savedAt: "x" }); expect(missingWorld.ok).toBe(false); expect(missingWorld.errors?.length).toBeGreaterThan(0); const emptySavedAt = save.validate({ format: "nengine-save", version: 1, savedAt: "", world: [] }); expect(emptySavedAt.ok).toBe(false); const zeroVersion = save.validate({ format: "nengine-save", version: 0, savedAt: "x", world: [] }); expect(zeroVersion.ok).toBe(false); expect(save.validate(null).ok).toBe(false); expect(save.validate("nope").ok).toBe(false); } finally { await engine.stop(); } }); }); describe("SaveSystem time state (D33)", () => { function timeOf(engine: ReturnType): TimeSystem { const time = engine.systems.get("time"); if (!time) throw new Error("time system not booted"); return time; } it("snapshot carries time {day, season, tickInDay} matching TimeSystem", async () => { const engine = engineSetup(); await engine.start(); try { timeOf(engine).advance(120); const snap = saveOf(engine).snapshot(); expect(snap.time).toEqual(timeOf(engine).get()); expect(snap.time).toEqual({ day: 1, season: "spring", tickInDay: 120 }); } finally { await engine.stop(); } }); it("restore sets the clock to the snapshot's time", async () => { const engineA = engineSetup(); await engineA.start(); let snap: WorldSnapshot | undefined; try { timeOf(engineA).set(2, "summer", 60); snap = saveOf(engineA).snapshot(); expect(snap.time).toEqual({ day: 2, season: "summer", tickInDay: 60 }); } finally { await engineA.stop(); } const engineB = engineSetup(); await engineB.start(); try { expect(timeOf(engineB).get()).toEqual({ day: 1, season: "spring", tickInDay: 0 }); saveOf(engineB).restore(snap!); expect(timeOf(engineB).get()).toEqual({ day: 2, season: "summer", tickInDay: 60 }); } finally { await engineB.stop(); } }); it("a legacy snapshot without time leaves the current clock unchanged", async () => { const engine = engineSetup(); await engine.start(); try { const legacy = { format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [{ id: "player", type: "player" }], }; expect(saveOf(engine).validate(legacy)).toEqual({ ok: true }); timeOf(engine).set(3, "summer", 10); saveOf(engine).restore(legacy); expect(timeOf(engine).get()).toEqual({ day: 3, season: "summer", tickInDay: 10 }); } finally { await engine.stop(); } }); it("migrate passthrough preserves the time field", async () => { const engine = engineSetup(); await engine.start(); try { timeOf(engine).set(7, "spring", 30); const snap = saveOf(engine).snapshot(); const forward = saveOf(engine).migrate(snap, CURRENT_SAVE_VERSION, CURRENT_SAVE_VERSION + 2); expect(forward.time).toEqual(snap.time); expect(saveOf(engine).validate(forward)).toEqual({ ok: true }); } finally { await engine.stop(); } }); it("validate rejects a malformed time field", async () => { const engine = engineSetup(); await engine.start(); try { const bad = saveOf(engine).validate({ format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [], time: { day: 1, season: "", tickInDay: 0 }, }); expect(bad.ok).toBe(false); const badTick = saveOf(engine).validate({ format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [], time: { day: 1, season: "spring", tickInDay: -1 }, }); expect(badTick.ok).toBe(false); } finally { await engine.stop(); } }); }); describe("SaveSystem rngState (D28)", () => { function saveEngineSetup(seed?: number): ReturnType { return createEngine({ contentDir, modsDir, seed }); } it("snapshot carries rngState {seed, calls} from the engine rng", async () => { const engine = saveEngineSetup(99); await engine.start(); try { engine.rng.next(); const snap = saveOf(engine).snapshot(); expect(snap.rngState).toEqual({ seed: 99, calls: 1 }); } finally { await engine.stop(); } }); it("restore sets the rng seed from rngState (calls reset to 0) and a later snapshot replays", async () => { const engineA = saveEngineSetup(123); await engineA.start(); let snap: WorldSnapshot | undefined; try { engineA.rng.next(); engineA.rng.next(); snap = saveOf(engineA).snapshot(); expect(snap.rngState).toEqual({ seed: 123, calls: 2 }); } finally { await engineA.stop(); } const engineB = saveEngineSetup(777); await engineB.start(); try { engineB.rng.next(); expect(engineB.rng.state()).toEqual({ seed: 777, calls: 1 }); saveOf(engineB).restore(snap!); expect(engineB.rng.state()).toEqual({ seed: 123, calls: 0 }); const replay = saveOf(engineB).snapshot(); expect(replay.rngState).toEqual({ seed: 123, calls: 0 }); } finally { await engineB.stop(); } }); it("old snapshots without rngState remain valid and restore leaves the rng untouched", async () => { const engine = saveEngineSetup(5); await engine.start(); try { const legacy = { format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [{ id: "player", type: "player" }], }; expect(saveOf(engine).validate(legacy)).toEqual({ ok: true }); engine.rng.next(); saveOf(engine).restore(legacy); expect(engine.rng.state()).toEqual({ seed: 5, calls: 1 }); } finally { await engine.stop(); } }); it("validate rejects a malformed rngState", async () => { const engine = saveEngineSetup(); await engine.start(); try { const badCalls = saveOf(engine).validate({ format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [], rngState: { seed: 1, calls: -1 }, }); expect(badCalls.ok).toBe(false); const badSeed = saveOf(engine).validate({ format: SAVE_FORMAT, version: CURRENT_SAVE_VERSION, savedAt: "x", world: [], rngState: { seed: "one" }, }); expect(badSeed.ok).toBe(false); } finally { await engine.stop(); } }); });