Files
Battle-for-Tismo/tests/server_test.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

308 lines
12 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 = "";
// 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");
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");
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);
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");
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();
});
}
}