Files
Battle-for-Tismo/tests/server_test.js

391 lines
15 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));
// `--warmup 0`: this checks the CLI binds and reports its URL, not the
// settle; skipping it keeps the test quick.
const child = spawn(process.execPath, [script, "--port", "0", "--warmup", "0"], { stdio: ["ignore", "pipe", "pipe"] });
try {
const line = await new Promise((resolve, reject) => {
let out = "";
// The CLI builds the full world (60% land, 18 nations) before it
// listens, which is slow when the suite runs one worker per core.
const timer = setTimeout(() => reject(new Error("the CLI did not start")), 20000);
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_the_server_configures_its_game_before_listening() {
await withServer(async (port, server) => {
this.assertTrue(server.gameServer.isConfigured(), "the game exists before anyone connects");
this.assertEqual(server.gameServer.state.seed, SEED);
this.assertSize(server.gameServer.state.civilisations, 2);
});
}
async test_the_testing_flag_reaches_the_game() {
await withServer(async (port, server) => {
this.assertTrue(server.gameServer.state.testing, "the auto-created game is a test game");
}, { testing: true });
}
async test_step_advances_the_clock_after_a_real_second() {
await withServer(async (port, server) => {
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) => {
// 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_lists_the_game_and_lets_a_player_claim_a_nation() {
await withServer(async (port, server) => {
const client = makeClient(`ws://127.0.0.1:${port}/ws`);
await client.open;
client.send({ t: "request_games" });
const listed = await client.next("game_list");
this.assertSize(listed.games, 1, "the game exists before anyone joins");
this.assertEqual(listed.games[0].seed, SEED);
this.assertSize(listed.games[0].civs, 2);
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");
const choose = await client.next("choose_civ");
this.assertSize(choose.civs, 2, "every unclaimed nation is offered");
client.send({ t: "claim_civ", civ: 0 });
const civAssigned = await client.next("civ_assigned");
this.assertEqual(civAssigned.civ, 0);
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 after = await client.next("game_list");
this.assertEqual(after.games[0].players, 1, "the connected player is counted");
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");
await client.next("choose_civ");
client.send({ t: "claim_civ", civ: 0 });
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");
// The broadcast is a delta now, so the changed unit rides in `unitsUpdated`
// (or in the full `units` list on a peer's first snapshot).
const units = snapshot.state.unitsUpdated || snapshot.state.units || [];
const data = 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");
await client.next("choose_civ");
client.send({ t: "claim_civ", civ: 0 });
await client.next("game_state");
const state = server.gameServer.state;
state.budgets.set(0, 1_000_000_000_000);
for (const city of state.cities) {
if (city.civ !== 0) continue;
const stock = state.getCityResourceStock(city);
stock.steel = 1e9;
stock.hightech = 1e6;
stock.energy = 1e9;
}
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}`;
const sites = snapshot.state.constructionSites[0] || [];
this.assertTrue(
sites.some((site) => site.coords[0] === coords.x && site.coords[1] === coords.y),
"the construction site is in the snapshot"
);
this.assertNotNull(state.constructionSiteAt(coords), "the server state carries the site");
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");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
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_a_taken_nation_cannot_be_claimed() {
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");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
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");
const choose = await guest.next("choose_civ");
this.assertEqual(choose.civs.map((c) => c.index), [1], "only the free nation is offered");
guest.send({ t: "claim_civ", civ: 0 });
guest.send({ t: "claim_civ", civ: 1 });
const civAssigned = await guest.next("civ_assigned");
this.assertEqual(civAssigned.civ, 1, "the taken nation is refused, the free one is granted");
host.close();
guest.close();
});
}
async test_treaty_orders_flow_through_the_server() {
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");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
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");
await guest.next("choose_civ");
guest.send({ t: "claim_civ", civ: 1 });
await guest.next("game_state");
host.send({ t: "order", order: { type: "propose_treaty", to: 1, treaty: "peace" } });
await new Promise((resolve) => setTimeout(resolve, 20));
server.step(0.05);
const state = server.gameServer.state;
this.assertSize(state.treatyProposals, 1, "the offer reached the model");
const proposal = state.treatyProposals[0];
this.assertEqual(proposal.from, 0);
this.assertEqual(proposal.to, 1);
guest.send({
t: "order",
order: { type: "respond_treaty", proposal: proposal.id, accept: true },
});
await new Promise((resolve) => setTimeout(resolve, 20));
server.step(0.05);
this.assertSize(state.treaties, 1, "accepting signs the treaty");
this.assertEqual(state.treaties[0].type, "peace");
host.close();
guest.close();
});
}
async test_chat_relays_to_every_signed_in_player() {
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");
await host.next("choose_civ");
host.send({ t: "claim_civ", civ: 0 });
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");
await guest.next("choose_civ");
guest.send({ t: "claim_civ", civ: 1 });
await guest.next("game_state");
host.send({ t: "chat", text: "hello world" });
const received = await guest.next("chat");
this.assertEqual(received.name, "Host", "the sender is named");
this.assertEqual(received.civ, 0, "and their nation travels too");
this.assertEqual(received.text, "hello world", "the line arrives intact");
host.close();
guest.close();
});
}
}