ws-broadcast.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import { resolve } from "node:path";
  2. import { afterEach, describe, expect, it } from "vitest";
  3. import { createEngine, type TimeSystem } from "@nengine/core";
  4. import { WebSocket } from "ws";
  5. import { startWsBroadcast, type WsBroadcastHandle, type WsEventFrame, type WsSnapshotFrame } from "../src/ws-broadcast.js";
  6. const repoRoot = resolve(process.cwd(), "../..");
  7. const sampleGameDir = resolve(repoRoot, "examples", "sample-game");
  8. const contentDir = resolve(sampleGameDir, "content");
  9. const modsDir = resolve(sampleGameDir, "mods");
  10. interface Harness {
  11. engine: ReturnType<typeof createEngine>;
  12. ws: WsBroadcastHandle;
  13. client: WebSocket;
  14. next: (predicate?: (frame: unknown) => boolean) => Promise<unknown>;
  15. close: () => Promise<void>;
  16. }
  17. const openHandles: Harness[] = [];
  18. afterEach(async () => {
  19. while (openHandles.length > 0) {
  20. const harness = openHandles.pop();
  21. if (harness) await harness.close();
  22. }
  23. });
  24. async function setup(): Promise<Harness> {
  25. const engine = createEngine({ contentDir, modsDir });
  26. await engine.start();
  27. const ws = await startWsBroadcast(engine, 0);
  28. const client = new WebSocket(`ws://localhost:${ws.port}`);
  29. const messages: unknown[] = [];
  30. const waiters: ((frame: unknown) => void)[] = [];
  31. client.on("message", (data) => {
  32. const frame = JSON.parse(String(data)) as unknown;
  33. const waiter = waiters.shift();
  34. if (waiter) {
  35. waiter(frame);
  36. } else {
  37. messages.push(frame);
  38. }
  39. });
  40. await new Promise<void>((resolveOpen, rejectOpen) => {
  41. client.once("open", () => resolveOpen());
  42. client.once("error", (err) => rejectOpen(err));
  43. });
  44. const next = (predicate?: (frame: unknown) => boolean): Promise<unknown> => {
  45. const index = messages.findIndex((frame) => !predicate || predicate(frame));
  46. if (index !== -1) {
  47. return Promise.resolve(messages.splice(index, 1)[0]);
  48. }
  49. return new Promise((resolveMessage, rejectMessage) => {
  50. const timer = setTimeout(() => rejectMessage(new Error("timeout waiting for ws frame")), 2000);
  51. const waiter = (frame: unknown): void => {
  52. if (predicate && !predicate(frame)) {
  53. waiters.push(waiter);
  54. return;
  55. }
  56. clearTimeout(timer);
  57. resolveMessage(frame);
  58. };
  59. waiters.push(waiter);
  60. });
  61. };
  62. const harness: Harness = {
  63. engine,
  64. ws,
  65. client,
  66. next,
  67. close: async () => {
  68. client.close();
  69. await ws.close();
  70. await engine.stop();
  71. },
  72. };
  73. openHandles.push(harness);
  74. return harness;
  75. }
  76. describe("apps/mcp-server WebSocket world broadcast", () => {
  77. it("sends a {type:snapshot} frame with map + world + time on connect", async () => {
  78. const { next, close } = await setup();
  79. try {
  80. const frame = (await next()) as WsSnapshotFrame;
  81. expect(frame.type).toBe("snapshot");
  82. expect(frame.map).toMatchObject({ id: "farm", width: 20, height: 16 });
  83. const ids = (frame.world as { id: string }[]).map((e) => e.id);
  84. expect(ids).toContain("farm");
  85. expect(ids).toContain("player");
  86. expect(frame.time).toEqual({ day: 1, season: "spring", tickInDay: 0 });
  87. } finally {
  88. await close();
  89. }
  90. });
  91. it("reflects the current time state in fresh snapshot frames", async () => {
  92. const { engine, next, client, close } = await setup();
  93. try {
  94. const snapshot = (await next()) as WsSnapshotFrame;
  95. expect(snapshot.type).toBe("snapshot");
  96. const time = engine.systems.get<TimeSystem>("time");
  97. time!.advance(480);
  98. client.send(JSON.stringify({ type: "sync" }));
  99. const isSnapshot = (frame: unknown): boolean => (frame as WsSnapshotFrame).type === "snapshot";
  100. const refreshed = (await next(isSnapshot)) as WsSnapshotFrame;
  101. expect(refreshed.time).toEqual({ day: 2, season: "spring", tickInDay: 0 });
  102. } finally {
  103. await close();
  104. }
  105. });
  106. it("broadcasts {type:event} frames when time advances", async () => {
  107. const { engine, next, close } = await setup();
  108. try {
  109. const snapshot = (await next()) as WsSnapshotFrame;
  110. expect(snapshot.type).toBe("snapshot");
  111. const time = engine.systems.get<TimeSystem>("time");
  112. time!.advance(480);
  113. const isDayStart = (frame: unknown): boolean =>
  114. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
  115. const event = (await next(isDayStart)) as WsEventFrame;
  116. expect(event.type).toBe("event");
  117. expect(event.event).toBe("day-start");
  118. expect(event.payload).toEqual({ day: 2, season: "spring" });
  119. } finally {
  120. await close();
  121. }
  122. });
  123. it("sends a fresh snapshot in reply to a {type:sync} client message", async () => {
  124. const { engine, next, client, close } = await setup();
  125. try {
  126. const snapshot = (await next()) as WsSnapshotFrame;
  127. expect(snapshot.type).toBe("snapshot");
  128. engine.world.create({ id: "npc:test", type: "npc", x: 3, y: 4 });
  129. client.send(JSON.stringify({ type: "sync" }));
  130. const refreshed = (await next()) as WsSnapshotFrame;
  131. expect(refreshed.type).toBe("snapshot");
  132. expect((refreshed.world as { id: string }[]).some((e) => e.id === "npc:test")).toBe(true);
  133. } finally {
  134. await close();
  135. }
  136. });
  137. it("reaches two connected clients (broadcast fan-out)", async () => {
  138. const { engine, ws, next, close } = await setup();
  139. try {
  140. const second = new WebSocket(`ws://localhost:${ws.port}`);
  141. const messages2: unknown[] = [];
  142. second.on("message", (data) => {
  143. messages2.push(JSON.parse(String(data)) as unknown);
  144. });
  145. await new Promise<void>((resolveOpen, rejectOpen) => {
  146. second.once("open", () => resolveOpen());
  147. second.once("error", (err) => rejectOpen(err));
  148. });
  149. const firstSnapshot = (await next()) as WsSnapshotFrame;
  150. expect(firstSnapshot.type).toBe("snapshot");
  151. await waitFor(() => messages2.some((frame) => (frame as WsSnapshotFrame).type === "snapshot"));
  152. const time = engine.systems.get<TimeSystem>("time");
  153. time!.advance(480);
  154. const isDayStart = (frame: unknown): boolean =>
  155. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
  156. const firstEvent = (await next(isDayStart)) as WsEventFrame;
  157. expect(firstEvent.event).toBe("day-start");
  158. await waitFor(() =>
  159. messages2.some(
  160. (frame) => (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start",
  161. ),
  162. );
  163. second.close();
  164. } finally {
  165. await close();
  166. }
  167. });
  168. it("skips non-serializable event payloads without crashing the server", async () => {
  169. const { engine, next, client, close } = await setup();
  170. try {
  171. const snapshot = (await next()) as WsSnapshotFrame;
  172. expect(snapshot.type).toBe("snapshot");
  173. const circular: Record<string, unknown> = {};
  174. circular.self = circular;
  175. engine.events.emit("test:circular", circular);
  176. const time = engine.systems.get<TimeSystem>("time");
  177. time!.advance(480);
  178. const isDayStart = (frame: unknown): boolean =>
  179. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "day-start";
  180. const event = (await next(isDayStart)) as WsEventFrame;
  181. expect(event.type).toBe("event");
  182. expect(event.event).toBe("day-start");
  183. expect(client.readyState).toBe(WebSocket.OPEN);
  184. } finally {
  185. await close();
  186. }
  187. });
  188. it("moves the player in reply to a {type:intent} message and broadcasts player-intent", async () => {
  189. const { engine, next, client, close } = await setup();
  190. try {
  191. const snapshot = (await next()) as WsSnapshotFrame;
  192. expect(snapshot.type).toBe("snapshot");
  193. client.send(JSON.stringify({ type: "intent", action: "move", args: { tile: { x: 3, y: 4 } } }));
  194. const isPlayerIntent = (frame: unknown): boolean =>
  195. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
  196. const event = (await next(isPlayerIntent)) as WsEventFrame;
  197. expect(event.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true });
  198. await waitFor(() => {
  199. const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
  200. return player?.x === 3 && player?.y === 4;
  201. });
  202. } finally {
  203. await close();
  204. }
  205. });
  206. it("interacts with a tile in reply to a {type:intent} message (tills a plot)", async () => {
  207. const { engine, next, client, close } = await setup();
  208. try {
  209. const snapshot = (await next()) as WsSnapshotFrame;
  210. expect(snapshot.type).toBe("snapshot");
  211. client.send(JSON.stringify({ type: "intent", action: "interact", args: { tile: { x: 9, y: 4 } } }));
  212. const isPlayerIntent = (frame: unknown): boolean =>
  213. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
  214. const event = (await next(isPlayerIntent)) as WsEventFrame;
  215. expect(event.payload).toMatchObject({ action: "interact", tile: { x: 9, y: 4 }, ok: true, detail: "tilled plot" });
  216. await waitFor(() => engine.world.get("plot:9:4") !== undefined);
  217. } finally {
  218. await close();
  219. }
  220. });
  221. it("sends a player-intent error frame for invalid intent input without crashing", async () => {
  222. const { next, client, close } = await setup();
  223. try {
  224. const snapshot = (await next()) as WsSnapshotFrame;
  225. expect(snapshot.type).toBe("snapshot");
  226. client.send(JSON.stringify({ type: "intent", action: "dance", args: {} }));
  227. const isPlayerIntent = (frame: unknown): boolean =>
  228. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
  229. const event = (await next(isPlayerIntent)) as WsEventFrame;
  230. expect(event.payload).toMatchObject({ action: "dance", ok: false });
  231. expect((event.payload as { error?: string }).error).toMatch(/intent action must be one of "move", "interact", "buy", "sell", "talk", "choose", "select-seed"/);
  232. expect(client.readyState).toBe(WebSocket.OPEN);
  233. } finally {
  234. await close();
  235. }
  236. });
  237. it("includes the registered sprites and sounds catalogs in the snapshot frame", async () => {
  238. const { next, close } = await setup();
  239. try {
  240. const snapshot = (await next()) as WsSnapshotFrame;
  241. expect(snapshot.type).toBe("snapshot");
  242. expect(Array.isArray(snapshot.sprites)).toBe(true);
  243. expect(Array.isArray(snapshot.sounds)).toBe(true);
  244. const spriteIds = (snapshot.sprites as { id: string }[]).map((s) => s.id);
  245. expect(spriteIds).toEqual(
  246. expect.arrayContaining(["player", "parsnip-sprout", "parsnip-grown", "parsnip-mature", "coop", "fence"]),
  247. );
  248. const soundIds = (snapshot.sounds as { id: string }[]).map((s) => s.id);
  249. expect(soundIds).toEqual(expect.arrayContaining(["till", "water", "harvest", "coin"]));
  250. const shopIds = (snapshot.shops as { id: string }[]).map((s) => s.id);
  251. expect(shopIds).toEqual(["general-store"]);
  252. const animationIds = (snapshot.animations as { id: string }[]).map((a) => a.id);
  253. expect(animationIds).toEqual(["player"]);
  254. const playerAnim = (snapshot.animations as { id: string; states: { id: string; spriteRef: string }[] }[]).find(
  255. (a) => a.id === "player",
  256. );
  257. expect(playerAnim?.states.map((s) => s.spriteRef)).toEqual(["player", "player-walk"]);
  258. } finally {
  259. await close();
  260. }
  261. });
  262. it("ships a metadata-only assetsIndex in the snapshot (no base64 bytes per snapshot, D37)", async () => {
  263. const { next, close } = await setup();
  264. try {
  265. const snapshot = (await next()) as WsSnapshotFrame;
  266. expect(snapshot.type).toBe("snapshot");
  267. expect(Object.keys(snapshot.assetsIndex)).toEqual(
  268. expect.arrayContaining(["grass-1", "water-1", "tilled-1", "coop-1", "click-1"]),
  269. );
  270. expect(snapshot.assetsIndex["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
  271. expect(typeof snapshot.assetsIndex["grass-1"].size).toBe("number");
  272. expect(snapshot.assetsIndex["grass-1"].size).toBeGreaterThan(0);
  273. // metadata only — no base64 bytes travel in the snapshot
  274. expect((snapshot as unknown as { assets?: unknown }).assets).toBeUndefined();
  275. const serialized = JSON.stringify(snapshot);
  276. expect(serialized).not.toContain(";base64,");
  277. } finally {
  278. await close();
  279. }
  280. });
  281. it("replies to an assets-request with base64 asset entries (on-demand roundtrip, D37)", async () => {
  282. const { next, client, close } = await setup();
  283. try {
  284. const snapshot = (await next()) as WsSnapshotFrame;
  285. expect(snapshot.type).toBe("snapshot");
  286. client.send(JSON.stringify({ type: "assets-request", ids: ["grass-1", "click-1", "does-not-exist"] }));
  287. const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
  288. const frame = (await next(isAssets)) as { type: "assets"; assets: Record<string, { kind: string; mime: string; data?: string }> };
  289. expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
  290. expect(typeof frame.assets["grass-1"].data).toBe("string");
  291. expect(frame.assets["grass-1"].data?.length).toBeGreaterThan(0);
  292. expect(frame.assets["click-1"]).toMatchObject({ kind: "audio", mime: "audio/ogg" });
  293. // unknown ids are ignored gracefully
  294. expect(frame.assets["does-not-exist"]).toBeUndefined();
  295. expect(Object.keys(frame.assets).sort()).toEqual(["click-1", "grass-1"]);
  296. } finally {
  297. await close();
  298. }
  299. });
  300. it("replies to an empty assets-request with an empty assets frame", async () => {
  301. const { next, client, close } = await setup();
  302. try {
  303. const snapshot = (await next()) as WsSnapshotFrame;
  304. expect(snapshot.type).toBe("snapshot");
  305. client.send(JSON.stringify({ type: "assets-request", ids: [] }));
  306. const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
  307. const frame = (await next(isAssets)) as { type: "assets"; assets: unknown };
  308. expect(frame.assets).toEqual({});
  309. } finally {
  310. await close();
  311. }
  312. });
  313. it("truncates an assets-request above the 64-id cap and rejects non-string ids", async () => {
  314. const { next, client, close } = await setup();
  315. try {
  316. const snapshot = (await next()) as WsSnapshotFrame;
  317. expect(snapshot.type).toBe("snapshot");
  318. // grass-1 first (within the cap); 42 is non-string; fake-0..fake-98 fill the rest past the cap
  319. const ids: unknown[] = ["grass-1", 42, ...Array.from({ length: 99 }, (_, i) => `fake-${i}`)];
  320. client.send(JSON.stringify({ type: "assets-request", ids }));
  321. const isAssets = (frame: unknown): boolean => (frame as { type?: string }).type === "assets";
  322. const frame = (await next(isAssets)) as { type: "assets"; assets: Record<string, unknown> };
  323. // grass-1 is within the first 64 string ids and known → returned; 42 (non-string) dropped;
  324. // fake ids are unknown → ignored; ids past the 64-id cap are truncated and never answered
  325. expect(frame.assets["grass-1"]).toMatchObject({ kind: "image", mime: "image/png" });
  326. expect(frame.assets["fake-0"]).toBeUndefined();
  327. expect(frame.assets["fake-63"]).toBeUndefined();
  328. expect(Object.keys(frame.assets)).toHaveLength(1);
  329. } finally {
  330. await close();
  331. }
  332. });
  333. it("emits an input event frame in reply to a {type:input} message", async () => {
  334. const { engine, next, client, close } = await setup();
  335. try {
  336. const snapshot = (await next()) as WsSnapshotFrame;
  337. expect(snapshot.type).toBe("snapshot");
  338. client.send(JSON.stringify({ type: "input", input: "key:Space" }));
  339. const isInput = (frame: unknown): boolean =>
  340. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
  341. const event = (await next(isInput)) as WsEventFrame;
  342. expect(event.payload).toEqual({ input: "key:Space" });
  343. const player = engine.world.get("player") as { x: number; y: number };
  344. expect(player).toMatchObject({ x: 2, y: 4 });
  345. } finally {
  346. await close();
  347. }
  348. });
  349. it("maps key:Arrow* inputs to a one-tile IntentSystem.move from the player's current position", async () => {
  350. const { engine, next, client, close } = await setup();
  351. try {
  352. const snapshot = (await next()) as WsSnapshotFrame;
  353. expect(snapshot.type).toBe("snapshot");
  354. client.send(JSON.stringify({ type: "input", input: "key:ArrowRight" }));
  355. const isInput = (frame: unknown): boolean =>
  356. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
  357. const inputEvent = (await next(isInput)) as WsEventFrame;
  358. expect(inputEvent.payload).toEqual({ input: "key:ArrowRight" });
  359. const isPlayerIntent = (frame: unknown): boolean =>
  360. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "player-intent";
  361. const intentEvent = (await next(isPlayerIntent)) as WsEventFrame;
  362. expect(intentEvent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 4 }, ok: true });
  363. await waitFor(() => {
  364. const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
  365. return player?.x === 3 && player?.y === 4;
  366. });
  367. client.send(JSON.stringify({ type: "input", input: "key:ArrowUp" }));
  368. const secondIntent = (await next(isPlayerIntent)) as WsEventFrame;
  369. expect(secondIntent.payload).toMatchObject({ action: "move", tile: { x: 3, y: 3 }, ok: true });
  370. await waitFor(() => {
  371. const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
  372. return player?.x === 3 && player?.y === 3;
  373. });
  374. } finally {
  375. await close();
  376. }
  377. });
  378. it("does not move the player for non-arrow key inputs (broadcast-only)", async () => {
  379. const { engine, next, client, close } = await setup();
  380. try {
  381. const snapshot = (await next()) as WsSnapshotFrame;
  382. expect(snapshot.type).toBe("snapshot");
  383. client.send(JSON.stringify({ type: "input", input: "pointer:click:10:10" }));
  384. const isInput = (frame: unknown): boolean =>
  385. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
  386. const event = (await next(isInput)) as WsEventFrame;
  387. expect(event.payload).toEqual({ input: "pointer:click:10:10" });
  388. await new Promise((resolveWait) => setTimeout(resolveWait, 100));
  389. const player = engine.world.get("player") as { x: number; y: number };
  390. expect(player).toMatchObject({ x: 2, y: 4 });
  391. } finally {
  392. await close();
  393. }
  394. });
  395. it("never crashes on malformed input messages", async () => {
  396. const { engine, next, client, close } = await setup();
  397. try {
  398. const snapshot = (await next()) as WsSnapshotFrame;
  399. expect(snapshot.type).toBe("snapshot");
  400. client.send(JSON.stringify({ type: "input", input: "" }));
  401. const isInput = (frame: unknown): boolean =>
  402. (frame as WsEventFrame).type === "event" && (frame as WsEventFrame).event === "input";
  403. const bad = (await next(isInput)) as WsEventFrame;
  404. expect(bad.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
  405. client.send(JSON.stringify({ type: "input", input: 42 }));
  406. const badNumber = (await next(isInput)) as WsEventFrame;
  407. expect(badNumber.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
  408. client.send(JSON.stringify({ type: "input" }));
  409. const badMissing = (await next(isInput)) as WsEventFrame;
  410. expect(badMissing.payload).toMatchObject({ ok: false, error: "input must be a non-empty string" });
  411. client.send(JSON.stringify({ type: "input", input: "key:ArrowLeft" }));
  412. const moved = (await next(isInput)) as WsEventFrame;
  413. expect(moved.payload).toEqual({ input: "key:ArrowLeft" });
  414. await waitFor(() => {
  415. const player = engine.world.get("player") as { x?: number; y?: number } | undefined;
  416. return player?.x === 1 && player?.y === 4;
  417. });
  418. expect(client.readyState).toBe(WebSocket.OPEN);
  419. } finally {
  420. await close();
  421. }
  422. });
  423. });
  424. async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
  425. const start = Date.now();
  426. while (!condition()) {
  427. if (Date.now() - start > timeoutMs) throw new Error("timeout waiting for condition");
  428. await new Promise((resolveWait) => setTimeout(resolveWait, 10));
  429. }
  430. }