| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- 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<typeof createEngine>;
- ws: WsBroadcastHandle;
- client: WebSocket;
- next: (predicate?: (frame: unknown) => boolean) => Promise<unknown>;
- close: () => Promise<void>;
- }
- const openHandles: Harness[] = [];
- afterEach(async () => {
- while (openHandles.length > 0) {
- const harness = openHandles.pop();
- if (harness) await harness.close();
- }
- });
- async function setup(): Promise<Harness> {
- 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<void>((resolveOpen, rejectOpen) => {
- client.once("open", () => resolveOpen());
- client.once("error", (err) => rejectOpen(err));
- });
- const next = (predicate?: (frame: unknown) => boolean): Promise<unknown> => {
- 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: 16, 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<TimeSystem>("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<TimeSystem>("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<void>((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<TimeSystem>("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<string, unknown> = {};
- circular.self = circular;
- engine.events.emit("test:circular", circular);
- const time = engine.systems.get<TimeSystem>("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<string, { kind: string; mime: string; data?: string }> };
- 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<string, unknown> };
- // 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<void> {
- 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));
- }
- }
|