import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { createEngine } from "@nengine/core"; import type { ContentBundle } from "@nengine/core"; import { compileBundle } from "@nengine/pack"; import { createPlaytestSession, saveAllFlushes } from "../src/renderer/playtest-session.js"; const MOD_ID = "playtest-mod"; const MOD_CODE = [ "export default {", " manifest: { id: \"playtest-mod\", version: \"0.1.0\" },", " hooks: {", " onDayStart(ctx) {", " const { world } = ctx;", " let stats = world.get(\"mod:playtest-stats\");", " if (!stats) {", " stats = world.create({ id: \"mod:playtest-stats\", type: \"mod-state\", days: 0 });", " }", " stats.days += 1;", " },", " },", "};", "", ].join("\n"); const FIXTURE_FILES: Record = { "content/time.yaml": [ "id: default", "dayLengthTicks: 480", "seasons:", " - { name: spring, dayCount: 14 }", "start: { day: 1, season: spring }", "", ].join("\n"), "content/weather.yaml": [ "id: default", "defaults:", " sunny: { name: Sunny }", "seasons:", " - season: spring", " weathers:", " - { id: sunny, weight: 100 }", "", ].join("\n"), "content/interact.yaml": [ "id: default", "order:", " - harvest", " - water", " - sow", " - till", "", ].join("\n"), "content/maps/farm.yaml": [ "# Playtest fixture map: grass with a water pond; spawn at (2,2).", "id: farm", "width: 12", "height: 12", "tilesetRef: farm-tiles", "spawn: { x: 2, y: 2 }", "walkable:", " water: false", "layers:", " - name: ground", " data:", mapRows(), "", ].join("\n"), "content/items/hoe.yaml": [ "id: hoe", "name: Hoe", "kind: tool", "stackable: false", "price: 100", "use: tills a soil tile", "", ].join("\n"), "content/items/carrot-seed.yaml": [ "id: carrot-seed", "name: Carrot Seed", "kind: seed", "stackable: true", "price: 20", "use: plant in tilled soil", "", ].join("\n"), "content/items/carrot.yaml": [ "id: carrot", "name: Carrot", "kind: material", "stackable: true", "price: 35", "use: harvested from a carrot plant", "", ].join("\n"), "content/items/egg.yaml": [ "id: egg", "name: Egg", "kind: material", "stackable: true", "price: 25", "use: laid by a chicken", "", ].join("\n"), "content/crops/carrot.yaml": [ "id: carrot", "seedItem: carrot-seed", "stages:", " - { name: sprout, days: 1, waterRequired: true }", " - { name: mature, days: 1, waterRequired: false }", "seasons:", " - spring", "drops:", " - { itemId: carrot, qty: 1 }", "", ].join("\n"), "content/buildings/shed.yaml": [ "id: shed", "name: Shed", "footprint: { w: 2, h: 2 }", "buildable: any", "blocksMovement: true", "", ].join("\n"), "content/animals/chicken.yaml": [ "id: chicken", "stages:", " - { name: chick, days: 1, feedRequired: true }", "produces:", " - { itemId: egg, qty: 1 }", "", ].join("\n"), "content/scenes/farm.yaml": [ "id: farm", "entities:", " - { type: building, buildingId: shed, x: 4, y: 4 }", " - { type: npc, npcId: mayor, x: 6, y: 2, label: Mayor }", " - { type: animal, animalId: chicken, x: 8, y: 8 }", "", ].join("\n"), "mods/playtest-mod/index.mjs": MOD_CODE, }; /** A 12×12 map: a 2×2 water pond at the top-left, grass elsewhere. */ function mapRows(): string { const rows: string[] = []; for (let y = 0; y < 12; y++) { const row: string[] = []; for (let x = 0; x < 12; x++) { row.push(x < 2 && y < 2 ? "water" : "grass"); } rows.push(` - [${row.join(", ")}]`); } return rows.join("\n"); } async function fixtureDir(): Promise { const dir = await mkdtemp(join(tmpdir(), "editor-playtest-")); for (const [rel, content] of Object.entries(FIXTURE_FILES)) { const target = join(dir, rel); await mkdir(dirname(target), { recursive: true }); await writeFile(target, content, "utf8"); } return dir; } async function rmRecursive(dir: string): Promise { await rm(dir, { recursive: true, force: true }); } describe("playtest bundle boot (E5 fixture)", () => { it("compiles a project with a scene + mod and boots in bundle mode with the scene seeded", async () => { const dir = await fixtureDir(); try { const bundle = await compileBundle(join(dir, "content"), join(dir, "mods")); expect(bundle.mods?.[MOD_ID]).toBeDefined(); expect(typeof bundle.mods?.[MOD_ID]?.code).toBe("string"); expect(bundle.content["building"]).toBeDefined(); expect(bundle.content["animal"]).toBeDefined(); expect(bundle.content["scene"]).toBeDefined(); const engine = createEngine({ bundle }); await engine.start(); try { const mapEntity = engine.world.query({ type: "map" })[0]; expect(mapEntity?.id).toBe("farm"); expect(engine.world.get("player")).toMatchObject({ id: "player", x: 2, y: 2 }); const shed = engine.world.query({ type: "building" })[0]; expect(shed?.id).toBe("building:shed:4:4"); expect(shed?.x).toBe(4); expect(shed?.y).toBe(4); expect(shed?.w).toBe(2); expect(shed?.h).toBe(2); const mayor = engine.world.get("mayor"); expect(mayor?.type).toBe("npc"); expect(mayor?.label).toBe("Mayor"); const chicken = engine.world.query({ type: "animal" })[0]; expect(chicken?.id).toBe("animal:chicken:1"); expect(chicken?.x).toBe(8); expect(chicken?.y).toBe(8); expect(engine.mods).toHaveLength(1); expect(engine.mods[0]?.id).toBe(MOD_ID); expect(engine.mods[0]?.loaded).toBe(true); expect(engine.world.get("mod:playtest-stats")).toBeUndefined(); engine.tick(480); expect(engine.world.get("mod:playtest-stats")?.days).toBe(1); } finally { await engine.stop(); } } finally { await rmRecursive(dir); } }); it("save-all flush content lands in the compiled bundle (see what ships)", async () => { const dir = await fixtureDir(); try { const modified = FIXTURE_FILES["content/maps/farm.yaml"] as string; const result = await saveAllFlushes( [{ path: "content/maps/farm.yaml", content: modified.replace("spawn: { x: 2, y: 2 }", "spawn: { x: 5, y: 5 }") }], (path, content) => writeFile(join(dir, path), content, "utf8"), ); expect(result.failures).toEqual([]); expect(result.flushed).toEqual(["content/maps/farm.yaml"]); const bundle = await compileBundle(join(dir, "content")); const engine = createEngine({ bundle }); await engine.start(); try { expect(engine.world.get("player")?.x).toBe(5); expect(engine.world.get("player")?.y).toBe(5); } finally { await engine.stop(); } } finally { await rmRecursive(dir); } }); it("leaves bundle.mods undefined when the project has no mods dir (main handler parity)", async () => { const dir = await fixtureDir(); try { await rm(join(dir, "mods"), { recursive: true, force: true }); const modsDir = join(dir, "mods"); const bundle = await compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined); expect(bundle.mods).toBeUndefined(); } finally { await rmRecursive(dir); } }); it("the full playtest session orchestrates save-all → compile → boot and tears down", async () => { const dir = await fixtureDir(); try { let bootedBundle: ContentBundle | undefined; let tornDown = false; const session = createPlaytestSession({ collectFlushes: () => [], write: async (path, content) => writeFile(join(dir, path), content, "utf8"), compile: async () => { const modsDir = join(dir, "mods"); return compileBundle(join(dir, "content"), existsSync(modsDir) ? modsDir : undefined); }, boot: async (bundle) => { bootedBundle = bundle; }, teardown: async () => { tornDown = true; }, }); const result = await session.start(); expect(result.ok).toBe(true); expect(bootedBundle?.content["scene"]).toBeDefined(); expect(bootedBundle?.mods?.[MOD_ID]).toBeDefined(); await session.stop(); expect(tornDown).toBe(true); expect(session.phase).toBe("idle"); } finally { await rmRecursive(dir); } }); });