| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276 |
- import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
- import { tmpdir } from "node:os";
- import { join } from "node:path";
- import { describe, expect, it } from "vitest";
- import {
- createEngine,
- CURRENT_SAVE_VERSION,
- SAVE_FORMAT,
- type InventorySystem,
- type PlantingSystem,
- } from "@nengine/core";
- import { createEngineNamespaces, McpGateway } from "../src/index.js";
- const TIME_YAML = [
- "id: default",
- "dayLengthTicks: 480",
- "seasons:",
- " - { name: spring, dayCount: 28 }",
- "start: { day: 1, season: spring }",
- "",
- ].join("\n");
- const FARM_YAML = [
- "id: farm",
- "width: 2",
- "height: 2",
- 'tilesetRef: "tiles"',
- "spawn: { x: 0, y: 0 }",
- "layers:",
- " - name: ground",
- " data:",
- " - [a, b]",
- " - [b, a]",
- "",
- ].join("\n");
- interface Setup {
- engine: ReturnType<typeof createEngine>;
- gateway: McpGateway;
- tmp: string;
- close: () => Promise<void>;
- }
- async function setup(): Promise<Setup> {
- const tmp = await mkdtemp(join(tmpdir(), "nengine-save-"));
- await mkdir(join(tmp, "maps"), { recursive: true });
- await writeFile(join(tmp, "time.yaml"), TIME_YAML);
- await writeFile(join(tmp, "maps", "farm.yaml"), FARM_YAML);
- const engine = createEngine({ contentDir: tmp });
- const gateway = new McpGateway();
- for (const namespace of createEngineNamespaces(engine, { gateway })) {
- gateway.register(namespace);
- }
- await engine.start();
- return {
- engine,
- gateway,
- tmp,
- close: async () => {
- await engine.stop();
- await rm(tmp, { recursive: true, force: true });
- },
- };
- }
- async function callText(gateway: McpGateway, name: string, args: Record<string, unknown> = {}): Promise<{
- ok: boolean;
- text: string;
- }> {
- const result = await gateway.callTool(name, args);
- return { ok: result.isError !== true, text: result.content[0]?.text ?? "" };
- }
- function registerItems(engine: ReturnType<typeof createEngine>): void {
- engine.registry.register("item", { id: "hoe", name: "Hoe", kind: "tool", stackable: false });
- engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true });
- engine.registry.register("item", { id: "parsnip", name: "Parsnip", kind: "material", stackable: true });
- }
- function registerCrop(engine: ReturnType<typeof createEngine>): void {
- engine.registry.register("crop", {
- id: "parsnip",
- seedItem: "parsnip-seed",
- stages: [
- { name: "sprout", days: 2, waterRequired: true },
- { name: "grown", days: 2, waterRequired: true },
- { name: "mature", days: 1, waterRequired: false },
- ],
- seasons: ["spring"],
- drops: [{ itemId: "parsnip", qty: 1 }],
- });
- }
- 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("save namespace", () => {
- it("save.snapshot serializes the full world state as a WorldSnapshot", async () => {
- const { gateway, close } = await setup();
- try {
- const snap = await callText(gateway, "save.snapshot");
- expect(snap.ok).toBe(true);
- const parsed = JSON.parse(snap.text) as {
- format: string;
- version: number;
- savedAt: string;
- world: { id: string; type: string }[];
- };
- expect(parsed.format).toBe(SAVE_FORMAT);
- expect(parsed.version).toBe(CURRENT_SAVE_VERSION);
- expect(typeof parsed.savedAt).toBe("string");
- expect(parsed.world.map((e) => e.type).sort()).toEqual(["map", "player"]);
- expect(parsed.world.find((e) => e.type === "map")).toMatchObject({ id: "farm", width: 2, height: 2 });
- expect(parsed.world.find((e) => e.id === "player")).toMatchObject({ type: "player", x: 0, y: 0 });
- } finally {
- await close();
- }
- });
- it("save.validate reports valid and invalid snapshots as normal results", async () => {
- const { gateway, close } = await setup();
- try {
- const snap = await callText(gateway, "save.snapshot");
- const snapshot = JSON.parse(snap.text);
- const valid = await callText(gateway, "save.validate", { snapshot });
- expect(valid.ok).toBe(true);
- expect(JSON.parse(valid.text)).toEqual({ ok: true });
- const invalid = await callText(gateway, "save.validate", { snapshot: { format: "nengine-save" } });
- expect(invalid.ok).toBe(true);
- const report = JSON.parse(invalid.text) as { ok: boolean; errors?: string[] };
- expect(report.ok).toBe(false);
- expect(report.errors?.length).toBeGreaterThan(0);
- const nonObject = await callText(gateway, "save.validate", { snapshot: "nonsense" });
- expect((JSON.parse(nonObject.text) as { ok: boolean }).ok).toBe(false);
- } finally {
- await close();
- }
- });
- it("save.restore replaces world state and a subsequent snapshot matches", async () => {
- const { engine, gateway, close } = await setup();
- try {
- registerItems(engine);
- registerCrop(engine);
- inventoryOf(engine).give("parsnip", 2);
- inventoryOf(engine).give("parsnip-seed", 4);
- plantingOf(engine).till(1, 1);
- plantingOf(engine).sow(1, 1, "parsnip-seed");
- plantingOf(engine).water(1, 1);
- const player = engine.world.get("player");
- if (!player) throw new Error("player entity not seeded");
- player.gold = 42;
- const snap = await callText(gateway, "save.snapshot");
- const snapshot = JSON.parse(snap.text) as { world: unknown[] };
- expect(snapshot.world).toHaveLength(3);
- const restored = await callText(gateway, "save.restore", { snapshot });
- expect(restored.ok).toBe(true);
- expect(JSON.parse(restored.text)).toEqual({ restored: 3 });
- const after = await callText(gateway, "save.snapshot");
- const afterParsed = JSON.parse(after.text) as { world: { id: string }[] };
- expect(afterParsed.world).toEqual(snapshot.world);
- expect(afterParsed.world.find((e) => e.id === "plot:1:1")).toMatchObject({
- type: "plot",
- x: 1,
- y: 1,
- tilled: true,
- watered: true,
- cropItemId: "parsnip",
- stageIndex: 0,
- daysInStage: 0,
- });
- } finally {
- await close();
- }
- });
- it("save.restore rejects malformed snapshots with an error result", async () => {
- const { gateway, close } = await setup();
- try {
- const bad = await callText(gateway, "save.restore", { snapshot: { format: "nengine-save" } });
- expect(bad.ok).toBe(false);
- expect(bad.text).toMatch(/invalid snapshot/);
- const nullSnap = await callText(gateway, "save.restore", { snapshot: null });
- expect(nullSnap.ok).toBe(false);
- expect(nullSnap.text).toMatch(/invalid snapshot/);
- } finally {
- await close();
- }
- });
- it("save.migrate returns the snapshot unchanged with an empty registry and rejects bad args", async () => {
- const { gateway, close } = await setup();
- try {
- const snap = await callText(gateway, "save.snapshot");
- const snapshot = JSON.parse(snap.text);
- const same = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 1 });
- expect(same.ok).toBe(true);
- expect(JSON.parse(same.text)).toEqual(snapshot);
- const forward = await callText(gateway, "save.migrate", { snapshot, fromVersion: 1, toVersion: 2 });
- expect(forward.ok).toBe(true);
- expect(JSON.parse(forward.text)).toEqual(snapshot);
- const badSnap = await callText(gateway, "save.migrate", { snapshot: { format: "nengine-save" } });
- expect(badSnap.ok).toBe(false);
- expect(badSnap.text).toMatch(/invalid snapshot/);
- const backwards = await callText(gateway, "save.migrate", { snapshot, fromVersion: 2, toVersion: 1 });
- expect(backwards.ok).toBe(false);
- expect(backwards.text).toMatch(/backwards/);
- const badVersion = await callText(gateway, "save.migrate", { snapshot, fromVersion: 0, toVersion: 1 });
- expect(badVersion.ok).toBe(false);
- expect(badVersion.text).toMatch(/positive integer/);
- } finally {
- await close();
- }
- });
- it("save://state resource returns the latest snapshot and reflects live mutations", async () => {
- const { engine, gateway, close } = await setup();
- try {
- const state = await gateway.readResource("save://state");
- const parsed = JSON.parse(state.contents[0]?.text ?? "{}") as {
- format: string;
- version: number;
- world: { id: string }[];
- };
- expect(parsed.format).toBe(SAVE_FORMAT);
- expect(parsed.version).toBe(CURRENT_SAVE_VERSION);
- expect(parsed.world.map((e) => e.id)).toEqual(expect.arrayContaining(["farm", "player"]));
- inventoryOf(engine).give("parsnip", 1);
- const updated = await gateway.readResource("save://state");
- const updatedParsed = JSON.parse(updated.contents[0]?.text ?? "{}") as {
- world: { id: string; inventory: Record<string, number> }[];
- };
- expect(updatedParsed.world.find((e) => e.id === "player")?.inventory).toEqual({ parsnip: 1 });
- } finally {
- await close();
- }
- });
- it("serves the save.authoring-prompt prompt", async () => {
- const { gateway, close } = await setup();
- try {
- const prompt = await gateway.getPrompt("save.authoring-prompt", {});
- const text = prompt.messages[0]?.content.text ?? "";
- expect(text).toContain("nengine-save");
- expect(text).toContain("version");
- expect(text).toContain("migration");
- expect(text).toContain("save.snapshot");
- expect(text).toContain("CURRENT_SAVE_VERSION");
- } finally {
- await close();
- }
- });
- });
|