391 lines
15 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
}
|