// Integration tests for the Node server: static hosting plus the WebSocket // protocol (lobby, login, configuration and orders) driven by Node's built-in // WebSocket client. This also exercises the hand-rolled frame codec with the // real, large state snapshots. import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { TestCase } from "./framework/test_case.js"; import { adjacentLand, withServer, SEED } from "./framework/helpers.js"; function makeClient(url) { const ws = new WebSocket(url); const queue = []; const waiters = []; ws.addEventListener("message", (event) => { const message = JSON.parse(event.data); for (let i = waiters.length - 1; i >= 0; i--) { if (waiters[i].type === message.t) { waiters[i].resolve(message); waiters.splice(i, 1); return; } } queue.push(message); }); return { ws, open: new Promise((resolve) => ws.addEventListener("open", resolve)), send(message) { ws.send(JSON.stringify(message)); }, next(type, timeout = 4000) { const index = queue.findIndex((m) => m.t === type); if (index >= 0) return Promise.resolve(queue.splice(index, 1)[0]); return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`timed out waiting for ${type}`)), timeout); waiters.push({ type, resolve: (message) => { clearTimeout(timer); resolve(message); }, }); }); }, close() { ws.close(); }, }; } export class ServerStaticTest extends TestCase { async test_serves_the_client_and_redirects_root() { await withServer(async (port) => { const response = await fetch(`http://127.0.0.1:${port}/`); this.assertTrue(response.ok); const body = await response.text(); this.assertTrue(body.includes("Battle for 'Tismo"), "serves the client page"); }); } async test_serves_shared_modules_for_the_browser() { await withServer(async (port) => { const response = await fetch(`http://127.0.0.1:${port}/shared/hex.js`); this.assertTrue(response.ok); this.assertTrue((response.headers.get("content-type") || "").includes("javascript")); }); } async test_missing_files_are_404() { await withServer(async (port) => { const response = await fetch(`http://127.0.0.1:${port}/nope.txt`); this.assertEqual(response.status, 404); }); } async test_cli_starts_and_reports_its_url() { const script = fileURLToPath(new URL("../server/server.js", import.meta.url)); const child = spawn(process.execPath, [script, "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] }); try { const line = await new Promise((resolve, reject) => { let out = ""; const timer = setTimeout(() => reject(new Error("the CLI did not start")), 5000); child.stdout.on("data", (chunk) => { out += chunk.toString(); if (out.includes("listening on")) { clearTimeout(timer); resolve(out); } }); child.on("error", reject); }); this.assertTrue(line.includes("listening on")); this.assertTrue(line.includes("/ws")); } finally { child.kill("SIGINT"); } } } export class ServerLoopTest extends TestCase { async test_step_advances_the_clock_after_a_real_second() { await withServer(async (port, server) => { server.gameServer.configureGame({ seed: SEED, player_civ: 0, civilisations: ["france", "britain"], }); this.assertEqual(server.gameServer.state.totalHours, 0); for (let i = 0; i < 40; i++) server.step(0.05); this.assertGreater(server.gameServer.state.totalHours, 0, "an hour struck after a second"); }); } async test_snapshots_are_throttled_between_ticks() { await withServer(async (port, server) => { server.gameServer.configureGame({ seed: SEED, player_civ: 0, civilisations: ["france", "britain"], }); // Count how often the loop actually flushes over one second of 50ms ticks. const flush = server.gameServer.flushIfDirty.bind(server.gameServer); let flushes = 0; server.gameServer.flushIfDirty = () => { flushes += 1; flush(); }; for (let i = 0; i < 20; i++) server.step(0.05); this.assertLess(flushes, 20, "not one snapshot per tick"); this.assertGreaterOrEqual(flushes, 9, "about one snapshot per 100ms"); }); } } export class ServerProtocolTest extends TestCase { async test_lobby_login_and_configure() { await withServer(async (port, server) => { const client = makeClient(`ws://127.0.0.1:${port}/ws`); await client.open; client.send({ t: "request_games" }); const empty = await client.next("game_list"); this.assertEmpty(empty.games, "no game before configuration"); client.send({ t: "login", name: "Host", password: "pw" }); const assigned = await client.next("assigned"); this.assertTrue(assigned.admin); this.assertEqual(assigned.name, "Host"); await client.next("players"); client.send({ t: "configure_game", setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] }, }); await client.next("game_configured"); const stateMessage = await client.next("game_state"); this.assertEqual(stateMessage.state.seed, SEED); this.assertEqual(stateMessage.state.viewer, 0); this.assertSize(stateMessage.state.civs, 2); client.send({ t: "request_games" }); const listed = await client.next("game_list"); this.assertSize(listed.games, 1); this.assertEqual(listed.games[0].seed, SEED); this.assertEqual(listed.games[0].players, 1); client.close(); }); } async test_move_order_is_applied_and_broadcast() { await withServer(async (port, server) => { const client = makeClient(`ws://127.0.0.1:${port}/ws`); await client.open; client.send({ t: "login", name: "Host", password: "pw" }); await client.next("assigned"); client.send({ t: "configure_game", setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] }, }); await client.next("game_state"); const state = server.gameServer.state; const unit = state.units[0]; const goal = adjacentLand(state, unit); client.send({ t: "order", order: { type: "move", unit: unit.id, coords: [goal.x, goal.y] } }); const snapshot = await client.next("game_state"); const data = snapshot.state.units.find((u) => u.id === unit.id); this.assertNotNull(data); this.assertEqual(data.path.length >= 2 || data.pathIndex > 0, true, "the unit has a route"); this.assertEqual(data.coords.length, 2); client.close(); }); } async test_build_improvement_order_is_applied_and_broadcast() { await withServer(async (port, server) => { const client = makeClient(`ws://127.0.0.1:${port}/ws`); await client.open; client.send({ t: "login", name: "Host", password: "pw" }); await client.next("assigned"); client.send({ t: "configure_game", setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] }, }); await client.next("game_state"); const state = server.gameServer.state; state.budgets.set(0, 1_000_000_000_000); const coords = state.landCells.find((cell) => { const k = `${cell.x},${cell.y}`; return state.civAt(cell) === 0 && !state.roads.has(k) && !state.railways.has(k); }); this.assertNotNull(coords, "the map has a bare owned land tile"); client.send({ t: "order", order: { type: "build_improvement", coords: [coords.x, coords.y], improvement: "road" }, }); const snapshot = await client.next("game_state"); const k = `${coords.x},${coords.y}`; this.assertTrue( snapshot.state.roads.some(([x, y]) => x === coords.x && y === coords.y), "the built road is in the snapshot" ); this.assertTrue(state.roads.has(k), "the server state carries the road"); client.close(); }); } async test_wrong_password_is_rejected() { await withServer(async (port) => { const host = makeClient(`ws://127.0.0.1:${port}/ws`); await host.open; host.send({ t: "login", name: "Host", password: "pw" }); await host.next("assigned"); host.close(); await new Promise((r) => setTimeout(r, 100)); const impostor = makeClient(`ws://127.0.0.1:${port}/ws`); await impostor.open; impostor.send({ t: "login", name: "Host", password: "wrong" }); const rejected = await impostor.next("login_rejected"); this.assertTrue(rejected.reason.length > 0); impostor.close(); }); } async test_second_player_gets_another_civilisation() { await withServer(async (port) => { const host = makeClient(`ws://127.0.0.1:${port}/ws`); await host.open; host.send({ t: "login", name: "Host", password: "pw" }); await host.next("assigned"); host.send({ t: "configure_game", setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] }, }); await host.next("game_state"); const guest = makeClient(`ws://127.0.0.1:${port}/ws`); await guest.open; guest.send({ t: "login", name: "Carol", password: "pw" }); const assigned = await guest.next("assigned"); this.assertFalse(assigned.admin); const choose = await guest.next("choose_civ"); this.assertEqual(choose.civs.map((c) => c.index), [1], "the free civilisation is offered"); guest.send({ t: "claim_civ", civ: 1 }); const civAssigned = await guest.next("civ_assigned"); this.assertEqual(civAssigned.civ, 1); const state = await guest.next("game_state"); this.assertEqual(state.state.viewer, 1, "the guest is assigned the free civilisation"); this.assertTrue(state.state.units.some((u) => u.civ === 1), "the guest has a unit"); host.close(); guest.close(); }); } async test_non_admin_cannot_configure() { await withServer(async (port, server) => { const host = makeClient(`ws://127.0.0.1:${port}/ws`); await host.open; host.send({ t: "login", name: "Host", password: "pw" }); await host.next("assigned"); host.send({ t: "configure_game", setup: { seed: SEED, player_civ: 0, civilisations: ["france", "britain"] }, }); await host.next("game_state"); const guest = makeClient(`ws://127.0.0.1:${port}/ws`); await guest.open; guest.send({ t: "login", name: "Carol", password: "pw" }); await guest.next("assigned"); guest.send({ t: "configure_game", setup: { seed: 999, player_civ: 1, civilisations: [] }, }); await new Promise((r) => setTimeout(r, 150)); this.assertEqual(server.gameServer.state.seed, SEED, "the game was not reset"); host.close(); guest.close(); }); } }