| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- 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<typeof createEngine>): SaveSystem {
- const save = engine.systems.get<SaveSystem>("save");
- if (!save) throw new Error("save system not booted");
- return save;
- }
- function inventoryOf(engine: ReturnType<typeof createEngine>): InventorySystem {
- const inv = engine.systems.get<InventorySystem>("inventory");
- if (!inv) throw new Error("inventory system not booted");
- return inv;
- }
- function plantingOf(engine: ReturnType<typeof createEngine>): PlantingSystem {
- const planting = engine.systems.get<PlantingSystem>("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<typeof createEngine>): TimeSystem {
- const time = engine.systems.get<TimeSystem>("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<typeof createEngine> {
- 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();
- }
- });
- });
|