import assert from "node:assert/strict"; import { createServer } from "node:http"; import test from "node:test"; import { WebSocket } from "ws"; import { attachDiceSyncServer } from "./dice-sync.mjs"; const createInbox = (socket) => { const messages = []; const waiters = []; socket.on("message", (raw) => { const message = JSON.parse(raw.toString()); const waiterIndex = waiters.findIndex( (waiter) => waiter.type === message.type && waiter.predicate(message), ); if (waiterIndex >= 0) { const [waiter] = waiters.splice(waiterIndex, 1); clearTimeout(waiter.timeout); waiter.resolve(message); } else { messages.push(message); } }); return { next(type, predicate = () => true) { const messageIndex = messages.findIndex( (message) => message.type === type && predicate(message), ); if (messageIndex >= 0) return Promise.resolve(messages.splice(messageIndex, 1)[0]); return new Promise((resolve, reject) => { const timeout = setTimeout( () => reject(new Error(`Timed out waiting for ${type}`)), 2000, ); waiters.push({ type, predicate, resolve, timeout }); }); }, }; }; const connect = (url) => new Promise((resolve, reject) => { const socket = new WebSocket(url); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); test("a host creates a room and broadcasts one authoritative roll", async () => { const httpServer = createServer(); const { wss } = attachDiceSyncServer(httpServer); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const address = httpServer.address(); const url = `ws://127.0.0.1:${address.port}/sync`; const host = await connect(url); const guest = await connect(url); const hostInbox = createInbox(host); const guestInbox = createInbox(guest); host.send(JSON.stringify({ type: "create" })); const hostState = await hostInbox.next("room-state"); const tokenMessage = await hostInbox.next("host-token"); assert.equal(hostState.isHost, true); assert.equal(tokenMessage.roomId, hostState.roomId); guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId })); const guestState = await guestInbox.next("room-state"); assert.equal(guestState.isHost, false); const hostRoll = hostInbox.next("rolled"); const guestRoll = guestInbox.next("rolled"); host.send(JSON.stringify({ type: "roll" })); const [hostMessage, guestMessage] = await Promise.all([hostRoll, guestRoll]); assert.deepEqual(guestMessage.roll, hostMessage.roll); assert.equal( hostMessage.roll.sum, hostMessage.roll.dice[0] + hostMessage.roll.dice[1], ); const forbidden = guestInbox.next("error"); guest.send(JSON.stringify({ type: "roll" })); assert.match((await forbidden).message, /Only the room host/); host.terminate(); guest.terminate(); await new Promise((resolve) => wss.close(resolve)); await new Promise((resolve) => httpServer.close(resolve)); }); test("level settings and re-rolls stay authoritative across a classroom", async () => { const httpServer = createServer(); const { wss } = attachDiceSyncServer(httpServer); await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve)); const address = httpServer.address(); const url = `ws://127.0.0.1:${address.port}/sync`; const host = await connect(url); const guest = await connect(url); const hostInbox = createInbox(host); const guestInbox = createInbox(guest); host.send( JSON.stringify({ type: "create", settings: { gridSize: 9, undoLimit: 0, rerollLimit: 2 }, }), ); const hostState = await hostInbox.next("room-state"); await hostInbox.next("host-token"); assert.deepEqual(hostState.settings, { gridSize: 9, undoLimit: 0, rerollLimit: 2, }); assert.equal(hostState.rerollsUsed, 0); guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId })); const guestState = await guestInbox.next("room-state"); assert.deepEqual(guestState.settings, hostState.settings); const configuredSettings = { gridSize: 8, undoLimit: 4, rerollLimit: 1, }; const hostSettings = hostInbox.next("settings"); const guestSettings = guestInbox.next("settings"); host.send( JSON.stringify({ type: "configure", settings: configuredSettings }), ); const [hostSettingsMessage, guestSettingsMessage] = await Promise.all([ hostSettings, guestSettings, ]); assert.deepEqual(hostSettingsMessage.settings, configuredSettings); assert.deepEqual(guestSettingsMessage.settings, configuredSettings); const forbiddenConfiguration = guestInbox.next("error"); guest.send( JSON.stringify({ type: "configure", settings: { gridSize: 5, undoLimit: 3, rerollLimit: 3 }, }), ); assert.match((await forbiddenConfiguration).message, /Only the room host/); const firstHostRoll = hostInbox.next("rolled"); const firstGuestRoll = guestInbox.next("rolled"); host.send(JSON.stringify({ type: "roll" })); const [hostRollMessage] = await Promise.all([firstHostRoll, firstGuestRoll]); const hostReroll = hostInbox.next("rerolled"); const guestReroll = guestInbox.next("rerolled"); host.send(JSON.stringify({ type: "reroll" })); const [hostRerollMessage, guestRerollMessage] = await Promise.all([ hostReroll, guestReroll, ]); assert.deepEqual(guestRerollMessage.roll, hostRerollMessage.roll); assert.equal(hostRerollMessage.roll.id, hostRollMessage.roll.id); assert.equal(hostRerollMessage.rerollsUsed, 1); const exhausted = hostInbox.next("error"); host.send(JSON.stringify({ type: "reroll" })); assert.match((await exhausted).message, /no re-rolls remaining/); const firstHostReset = hostInbox.next("round-reset"); const firstGuestReset = guestInbox.next("round-reset"); host.send(JSON.stringify({ type: "reset" })); const [resetMessage] = await Promise.all([firstHostReset, firstGuestReset]); assert.equal(resetMessage.rerollsUsed, 0); const secondHostRoll = hostInbox.next("rolled"); const secondGuestRoll = guestInbox.next("rolled"); host.send(JSON.stringify({ type: "roll" })); await Promise.all([secondHostRoll, secondGuestRoll]); const placedUpdate = hostInbox.next("leaderboard", (message) => message.leaderboard.some( (player) => player.id === guestState.playerId && player.placed === 1, ), ); guest.send( JSON.stringify({ type: "progress", score: 8, placed: 1, finished: false }), ); await placedUpdate; const alreadyPlaced = hostInbox.next("error"); host.send(JSON.stringify({ type: "reroll" })); assert.match((await alreadyPlaced).message, /already been placed/); const secondHostReset = hostInbox.next("round-reset"); const secondGuestReset = guestInbox.next("round-reset"); host.send(JSON.stringify({ type: "reset" })); await Promise.all([secondHostReset, secondGuestReset]); const newcomer = await connect(url); const newcomerInbox = createInbox(newcomer); newcomer.send(JSON.stringify({ type: "join", roomId: hostState.roomId })); const newcomerState = await newcomerInbox.next("room-state"); assert.deepEqual(newcomerState.settings, configuredSettings); assert.equal(newcomerState.rerollsUsed, 0); host.terminate(); guest.terminate(); newcomer.terminate(); await new Promise((resolve) => wss.close(resolve)); await new Promise((resolve) => httpServer.close(resolve)); });