import { resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createEngine, type TimeSystem } from "@nengine/core"; import { WebSocket } from "ws"; import { startWsBroadcast, type WsBroadcastHandle, type WsEventFrame, type WsSnapshotFrame } from "../src/ws-broadcast.js"; const repoRoot = resolve(process.cwd(), "../.."); const sampleGameDir = resolve(repoRoot, "examples", "sample-game"); const contentDir = resolve(sampleGameDir, "content"); const modsDir = resolve(sampleGameDir, "mods"); interface Harness { engine: ReturnType; ws: WsBroadcastHandle; client: WebSocket; next: (predicate?: (frame: unknown) => boolean) => Promise; close: () => Promise; } const openHandles: Harness[] = []; afterEach(async () => { while (openHandles.length > 0) { const harness = openHandles.pop(); if (harness) await harness.close(); } }); async function setup(): Promise { const engine = createEngine({ contentDir, modsDir }); await engine.start(); const ws = await startWsBroadcast(engine, 0); const client = new WebSocket(`ws://localhost:${ws.port}`); const messages: unknown[] = []; const waiters: ((frame: unknown) => void)[] = []; client.on("message", (data) => { const frame = JSON.parse(String(data)) as unknown; const waiter = waiters.shift(); if (waiter) { waiter(frame); } else { messages.push(frame); } }); await new Promise((resolveOpen, rejectOpen) => { client.once("open", () => resolveOpen()); client.once("error", (err) => rejectOpen(err)); }); const next = (predicate?: (frame: unknown) => boolean): Promise => { const index = messages.findIndex((frame) => !predicate || predicate(frame)); if (index !== -1) { return Promise.resolve(messages.splice(index, 1)[0]); } return new Promise((resolveMessage, rejectMessage) => { const timer = setTimeout(() => rejectMessage(new Error("timeout waiting for ws frame")), 2000); const waiter = (frame: unknown): void => { if (predicate && !predicate(frame)) { waiters.push(waiter); return; } clearTimeout(timer); resolveMessage(frame); }; waiters.push(waiter); }); }; const harness: Harness = { engine, ws, client, next, close: async () => { client.close(); await ws.close(); await engine.stop(); }, }; openHandles.push(harness); return harness; } describe("apps/mcp-server WebSocket world broadcast", () => { it("sends a {type:snapshot} frame with map + world + time on connect", async () => { const { next, close } = await setup(); try { const frame = (await next()) as WsSnapshotFrame; expect(frame.type).toBe("snapshot"); expect(frame.map).toMatchObject({ id: "farm", width: 20, height: 16 }); const ids = (frame.world as { id: string }[]).map((e) => e.id); expect(ids).toContain("farm"); expect(ids).toContain("player"); expect(frame.time).toEqual({ day: 1, season: "spring", tickInDay: 0 }); } finally { await close(); } }); it("reflects the current time state in fresh snapshot frames", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); const time = engine.systems.get("time"); time!.advance(480); client.send(JSON.stringify({ type: "sync" })); const isSnapshot = (frame: unknown): boolean => (frame as WsSnapshotFrame).type === "snapshot"; const refreshed = (await next(isSnapshot)) as WsSnapshotFrame; expect(refreshed.time).toEqual({ day: 2, season: "spring", tickInDay: 0 }); } finally { await close(); } }); it("broadcasts {type:event} frames when time advances", async () => { const { engine, next, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); const time = engine.systems.get("time"); time!.advance(480); const isDayStart = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start"; const event = (await next(isDayStart)) as WsEventFrame; expect(event.type).toBe("event"); expect(event.event).toBe("day-start"); expect(event.payload).toEqual({ day: 2, season: "spring" }); } finally { await close(); } }); it("sends a fresh snapshot in reply to a {type:sync} client message", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); engine.world.create({ id: "npc:test", type: "npc", x: 3, y: 4 }); client.send(JSON.stringify({ type: "sync" })); const refreshed = (await next()) as WsSnapshotFrame; expect(refreshed.type).toBe("snapshot"); expect((refreshed.world as { id: string }[]).some((e) => e.id === "npc:test")).toBe(true); } finally { await close(); } }); it("reaches two connected clients (broadcast fan-out)", async () => { const { engine, ws, next, close } = await setup(); try { const second = new WebSocket(`ws://localhost:${ws.port}`); const messages2: unknown[] = []; second.on("message", (data) => { messages2.push(JSON.parse(String(data)) as unknown); }); await new Promise((resolveOpen, rejectOpen) => { second.once("open", () => resolveOpen()); second.once("error", (err) => rejectOpen(err)); }); const firstSnapshot = (await next()) as WsSnapshotFrame; expect(firstSnapshot.type).toBe("snapshot"); await waitFor(() => messages2.some((frame) => (frame as WsSnapshotFrame).type === "snapshot")); const time = engine.systems.get("time"); time!.advance(480); const isDayStart = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start"; const firstEvent = (await next(isDayStart)) as WsEventFrame; expect(firstEvent.event).toBe("day-start"); await waitFor(() => messages2.some( (frame) => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start", ), ); second.close(); } finally { await close(); } }); it("skips non-serializable event payloads without crashing the server", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); const circular: Record = {}; circular.self = circular; engine.events.emit("test:circular", circular); const time = engine.systems.get("time"); time!.advance(480); const isDayStart = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start"; const event = (await next(isDayStart)) as WsEventFrame; expect(event.type).toBe("event"); expect(event.event).toBe("day-start"); expect(client.readyState).toBe(WebSocket.OPEN); } finally { await close(); } }); it("moves the player in reply to a {type:intent} message and broadcasts player-intent", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "intent", action: "move", args: { tile: { x: 3, y: 4 } } })); const isPlayerIntent = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent"; const event = (await next(isPlayerIntent)) as WsEventFrame; expect(event.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true }); await waitFor(() => { const player = engine.world.get("player") as { x?: number; y?: number } | undefined; return player?.x === 3 && player?.y === 4; }); } finally { await close(); } }); it("interacts with a tile in reply to a {type:intent} message (tills a plot)", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "intent", action: "interact", args: { tile: { x: 9, y: 4 } } })); const isPlayerIntent = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent"; const event = (await next(isPlayerIntent)) as WsEventFrame; expect(event.payload).toMatchObject({ action: "interact", tile: { x: 9, y: 4 }, ok: true, detail: "tilled plot" }); await waitFor(() => engine.world.get("plot:9:4") !== undefined); } finally { await close(); } }); it("sends a player-intent error frame for invalid intent input without crashing", async () => { const { next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "intent", action: "dance", args: {} })); const isPlayerIntent = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent"; const event = (await next(isPlayerIntent)) as WsEventFrame; expect(event.payload).toMatchObject({ action: "dance", ok: false }); expect((event.payload as { error?: string }).error).toMatch(/intent action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/); expect(client.readyState).toBe(WebSocket.OPEN); } finally { await close(); } }); it("includes the registered sprites and sounds catalogs in the snapshot frame", async () => { const { next, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); expect(Array.isArray(snapshot.sprites)).toBe(true); expect(Array.isArray(snapshot.sounds)).toBe(true); const spriteIds = (snapshot.sprites as { id: string }[]).map((s) => s.id); expect(spriteIds).toEqual( expect.arrayContaining(["player", "parsnip-sprout", "parsnip-grown", "parsnip-mature", "coop", "fence"]), ); const soundIds = (snapshot.sounds as { id: string }[]).map((s) => s.id); expect(soundIds).toEqual(expect.arrayContaining(["till", "water", "harvest", "coin"])); const shopIds = (snapshot.shops as { id: string }[]).map((s) => s.id); expect(shopIds).toEqual(["general-store"]); const animationIds = (snapshot.animations as { id: string }[]).map((a) => a.id); expect(animationIds).toEqual(["player"]); const playerAnim = (snapshot.animations as { id: string; states: { id: string; spriteRef: string }[] }[]).find( (a) => a.id === "player", ); expect(playerAnim?.states.map((s) => s.spriteRef)).toEqual(["player", "player-walk"]); } finally { await close(); } }); it("ships a metadata-only assetsIndex in the snapshot (no base64 bytes per snapshot, D37)", async () => { const { next, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); expect(Object.keys(snapshot.assetsIndex)).toEqual( expect.arrayContaining(["grass-1", "water-1", "tilled-1", "coop-1", "click-1"]), ); expect(snapshot.assetsIndex["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" }); expect(typeof snapshot.assetsIndex["grass-1"].size).toBe("number"); expect(snapshot.assetsIndex["grass-1"].size).toBeGreaterThan(0); // metadata only — no base64 bytes travel in the snapshot expect((snapshot as unknown as { assets?: unknown }).assets).toBeUndefined(); const serialized = JSON.stringify(snapshot); expect(serialized).not.toContain(";base64,"); } finally { await close(); } }); it("replies to an assets-request with base64 asset entries (on-demand roundtrip, D37)", async () => { const { next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "assets-request", ids: ["grass-1", "click-1", "does-not-exist"] })); const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets"; const frame = (await next(isAssets)) as { type: "assets"; assets: Record }; expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" }); expect(typeof frame.assets["grass-1"].data).toBe("string"); expect(frame.assets["grass-1"].data?.length).toBeGreaterThan(0); expect(frame.assets["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" }); // unknown ids are ignored gracefully expect(frame.assets["does-not-exist"]).toBeUndefined(); expect(Object.keys(frame.assets).sort()).toEqual(["click-1", "grass-1"]); } finally { await close(); } }); it("replies to an empty assets-request with an empty assets frame", async () => { const { next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "assets-request", ids: [] })); const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets"; const frame = (await next(isAssets)) as { type: "assets"; assets: unknown }; expect(frame.assets).toEqual({}); } finally { await close(); } }); it("truncates an assets-request above the 64-id cap and rejects non-string ids", async () => { const { next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); // grass-1 first (within the cap); 42 is non-string; fake-0..fake-98 fill the rest past the cap const ids: unknown[] = ["grass-1", 42, ...Array.from({ length: 99 }, (_, i) => `fake-${i}`)]; client.send(JSON.stringify({ type: "assets-request", ids })); const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets"; const frame = (await next(isAssets)) as { type: "assets"; assets: Record }; // grass-1 is within the first 64 string ids and known → returned; 42 (non-string) dropped; // fake ids are unknown → ignored; ids past the 64-id cap are truncated and never answered expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" }); expect(frame.assets["fake-0"]).toBeUndefined(); expect(frame.assets["fake-63"]).toBeUndefined(); expect(Object.keys(frame.assets)).toHaveLength(1); } finally { await close(); } }); it("emits an input event frame in reply to a {type:input} message", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "input", input: "key:Space" })); const isInput = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input"; const event = (await next(isInput)) as WsEventFrame; expect(event.payload).toEqual({ input: "key:Space" }); const player = engine.world.get("player") as { x: number; y: number }; expect(player).toMatchObject({ x: 2, y: 4 }); } finally { await close(); } }); it("maps key:Arrow* inputs to a one-tile IntentSystem.move from the player's current position", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "input", input: "key:ArrowRight" })); const isInput = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input"; const inputEvent = (await next(isInput)) as WsEventFrame; expect(inputEvent.payload).toEqual({ input: "key:ArrowRight" }); const isPlayerIntent = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent"; const intentEvent = (await next(isPlayerIntent)) as WsEventFrame; expect(intentEvent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true }); await waitFor(() => { const player = engine.world.get("player") as { x?: number; y?: number } | undefined; return player?.x === 3 && player?.y === 4; }); client.send(JSON.stringify({ type: "input", input: "key:ArrowUp" })); const secondIntent = (await next(isPlayerIntent)) as WsEventFrame; expect(secondIntent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 3 }, ok: true }); await waitFor(() => { const player = engine.world.get("player") as { x?: number; y?: number } | undefined; return player?.x === 3 && player?.y === 3; }); } finally { await close(); } }); it("does not move the player for non-arrow key inputs (broadcast-only)", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "input", input: "pointer:click:10:10" })); const isInput = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input"; const event = (await next(isInput)) as WsEventFrame; expect(event.payload).toEqual({ input: "pointer:click:10:10" }); await new Promise((resolveWait) => setTimeout(resolveWait, 100)); const player = engine.world.get("player") as { x: number; y: number }; expect(player).toMatchObject({ x: 2, y: 4 }); } finally { await close(); } }); it("never crashes on malformed input messages", async () => { const { engine, next, client, close } = await setup(); try { const snapshot = (await next()) as WsSnapshotFrame; expect(snapshot.type).toBe("snapshot"); client.send(JSON.stringify({ type: "input", input: "" })); const isInput = (frame: unknown): boolean => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input"; const bad = (await next(isInput)) as WsEventFrame; expect(bad.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" }); client.send(JSON.stringify({ type: "input", input: 42 })); const badNumber = (await next(isInput)) as WsEventFrame; expect(badNumber.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" }); client.send(JSON.stringify({ type: "input" })); const badMissing = (await next(isInput)) as WsEventFrame; expect(badMissing.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" }); client.send(JSON.stringify({ type: "input", input: "key:ArrowLeft" })); const moved = (await next(isInput)) as WsEventFrame; expect(moved.payload).toEqual({ input: "key:ArrowLeft" }); await waitFor(() => { const player = engine.world.get("player") as { x?: number; y?: number } | undefined; return player?.x === 1 && player?.y === 4; }); expect(client.readyState).toBe(WebSocket.OPEN); } finally { await close(); } }); }); async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise { const start = Date.now(); while (!condition()) { if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for condition"); await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } }