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 } from "@nengine/core"; import { connectLocal } from "../src/sync/local.js"; import type { RenderSnapshot, ViewportHandle } from "../src/viewport.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: 5", "height: 5", 'tilesetRef: "tiles"', "spawn: { x: 0, y: 0 }", "layers:", " - name: ground", " data:", " - [grass, grass, water, water, water]", " - [grass, grass, water, water, water]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", " - [grass, grass, grass, grass, grass]", "", ].join("\n"); function fakeViewport(): { viewport: ViewportHandle; renders: RenderSnapshot[] } { const renders: RenderSnapshot[] = []; const viewport: ViewportHandle = { render(snapshot?: RenderSnapshot): void { if (snapshot) renders.push(snapshot); }, tileAt(): { x: number; y: number } | null { return null; }, resize(): void {}, destroy(): void {}, }; return { viewport, renders }; } interface Setup { engine: ReturnType; close: () => Promise; } async function setup(): Promise { const tmp = await mkdtemp(join(tmpdir(), "farm-sync-local-")); 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 }); await engine.start(); return { engine, close: async () => { await engine.stop(); await rm(tmp, { recursive: true, force: true }); }, }; } describe("connectLocal sync handle", () => { it("renders a full snapshot (map + world entities) on connect", async () => { const { engine, close } = await setup(); try { const { viewport, renders } = fakeViewport(); connectLocal(engine, viewport); expect(renders).toHaveLength(1); expect(renders[0].map).toMatchObject({ id: "farm", width: 5, height: 5 }); const ids = renders[0].world.map((entity) => entity.id); expect(ids).toContain("player"); } finally { await close(); } }); it("intent() delegates to the engine IntentSystem and re-renders the viewport", async () => { const { engine, close } = await setup(); try { const { viewport, renders } = fakeViewport(); const sync = connectLocal(engine, viewport); const before = renders.length; sync.intent("move", { tile: { x: 3, y: 3 } }); expect(engine.world.get("player")).toMatchObject({ x: 3, y: 3 }); expect(renders.length).toBeGreaterThan(before); const last = renders[renders.length - 1]; const player = last.world.find((entity) => entity.id === "player") as { x?: number; y?: number } | undefined; expect(player).toMatchObject({ x: 3, y: 3 }); } finally { await close(); } }); it("setTimeLabel reports the current time state", async () => { const { engine, close } = await setup(); try { const { viewport } = fakeViewport(); const sync = connectLocal(engine, viewport); const labels: string[] = []; sync.setTimeLabel((label) => labels.push(label)); expect(labels[labels.length - 1]).toBe("Day 1, spring"); } finally { await close(); } }); it("input() maps key:Arrow* to a move intent one tile from the player", async () => { const { engine, close } = await setup(); try { const { viewport, renders } = fakeViewport(); const sync = connectLocal(engine, viewport); const before = renders.length; sync.input("key:ArrowDown"); expect(engine.world.get("player")).toMatchObject({ x: 0, y: 1 }); expect(renders.length).toBeGreaterThan(before); sync.input("key:ArrowRight"); expect(engine.world.get("player")).toMatchObject({ x: 1, y: 1 }); } finally { await close(); } }); it("input() ignores non-arrow inputs", async () => { const { engine, close } = await setup(); try { const { viewport } = fakeViewport(); const sync = connectLocal(engine, viewport); sync.input("key:Space"); sync.input("pointer:click:1:2"); expect(engine.world.get("player")).toMatchObject({ x: 0, y: 0 }); } finally { await close(); } }); it("intent() passes the extended actions through (select-seed sets player.selectedSeed)", async () => { const { engine, close } = await setup(); try { engine.registry.register("item", { id: "parsnip-seed", name: "Parsnip Seed", kind: "seed", stackable: true }); const { viewport, renders } = fakeViewport(); const sync = connectLocal(engine, viewport); const before = renders.length; sync.intent("select-seed", { seedItemId: "parsnip-seed" }); expect(engine.world.get("player")).toMatchObject({ selectedSeed: "parsnip-seed" }); expect(renders.length).toBeGreaterThan(before); sync.intent("buy", { shopId: "nope", itemId: "parsnip-seed" }); expect(renders.length).toBeGreaterThan(before); } finally { await close(); } }); }); describe("connectLocal sound wiring (bug-fix round)", () => { it("forwards bgm-play and bgm-stop engine events to the sound player", async () => { const { engine, close } = await setup(); try { const calls: { event: string; payload: unknown }[] = []; const sound = { setCatalog(): void {}, setAssets(): void {}, onEvent: (frame: { event: string; payload: unknown }) => calls.push(frame), } as never; connectLocal(engine, fakeViewport().viewport, { sound }); engine.events.emit("bgm-play", { soundId: "bgm-spring", fadeMs: 1500 }); engine.events.emit("bgm-stop", { fadeMs: 800 }); expect(calls).toEqual([ { event: "bgm-play", payload: { soundId: "bgm-spring", fadeMs: 1500 } }, { event: "bgm-stop", payload: { fadeMs: 800 } }, ]); } finally { await close(); } }); });