275 lines
9.6 KiB
JavaScript
275 lines
9.6 KiB
JavaScript
// 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_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();
|
|
});
|
|
}
|
|
}
|