Files
Battle-for-Tismo/tests/game_server_test.js
T
adrien e7ee5dbf0e Added the resource economy, its works and their icons
- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep.
- Resource-producing tile works built outside cities; every nation opens with the works its economy needs.
- Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
2026-09-22 05:27:11 +02:00

602 lines
24 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { GameServer } from "../server/game_server.js";
import { CIVILISATIONS } from "../shared/data.js";
import { adjacentLand, SEED, grantBuilding } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
function fakeNetwork() {
return {
peers: new Set(),
sent: [],
getPeerIds() {
return Array.from(this.peers);
},
send(peerId, message) {
this.sent.push({ peerId, message });
},
};
}
function makeServer(civIds = ["france", "britain"]) {
const network = fakeNetwork();
const server = new GameServer(network);
server.configureGame({ seed: SEED, player_civ: 0, civilisations: civIds });
return { server, network };
}
function emptyLandPair(state) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
throw new Error("no empty land pair");
}
export class GameServerTest extends TestCase {
test_configure_game_builds_a_state() {
const { server } = makeServer();
this.assertTrue(server.isConfigured());
this.assertNotNull(server.state);
this.assertEqual(server.hostCiv, 0);
this.assertSize(server.state.units, 2);
this.assertSize(server.state.civilisations, 2);
}
test_configure_game_ignores_a_second_call() {
const { server } = makeServer();
const firstSeed = server.state.seed;
server.configureGame({ seed: 999, player_civ: 1, civilisations: [] });
this.assertEqual(server.state.seed, firstSeed);
}
test_snapshots_are_delta_encoded_after_the_first() {
const { server, network } = makeServer();
network.peers.add(1);
server.peerCiv.set(1, 0);
server._broadcastState();
const full = network.sent[network.sent.length - 1].message.state;
this.assertFalse(full.delta, "the first snapshot to a peer is full");
this.assertNotNull(full.territory, "it carries the whole territory");
this.assertTrue(Array.isArray(full.explored), "and the explored list");
server._broadcastState();
const delta = network.sent[network.sent.length - 1].message.state;
this.assertTrue(delta.delta, "later snapshots are deltas");
this.assertNull(delta.territory, "unchanged territory is left out");
this.assertNull(delta.regions, "unchanged regions are left out");
this.assertNull(delta.population, "unchanged population is left out");
this.assertNull(delta.gdpBaseline, "the static growth baseline is left out");
this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out");
this.assertNull(delta.cityStats, "unchanged city stats are left out");
this.assertNull(delta.visible, "unchanged visibility is left out");
this.assertNull(delta.explored, "no explored tiles are re-sent");
this.assertNull(delta.exploredAdded, "and none were added");
this.assertNotNull(delta.units, "the always-changing fields still ship");
// A real territory change must ship the array again.
server.state._reindexTerritory();
server._broadcastState();
const after = network.sent[network.sent.length - 1].message.state;
this.assertNotNull(after.territory, "changed territory is sent");
// A visibility refresh ships the visible set again.
server.state._refreshVisibility();
server._broadcastState();
const vision = network.sent[network.sent.length - 1].message.state;
this.assertNotNull(vision.visible, "changed visibility is sent");
// An economy tick ships the city stats again.
server.state._tickPopulation();
server._broadcastState();
const economy = network.sent[network.sent.length - 1].message.state;
this.assertNotNull(economy.cityStats, "changed city stats are sent");
// Discovering a new tile ships only the tail of the explored list. Reset
// the viewer's exploration so there is a fresh tile to reveal.
server.state.explored.set(0, new Set());
server.state._serializedExplored.delete(0);
server._peerExploredSent.set(1, 0);
const [fx, fy] = Object.keys(server.state.tiles)[0].split(",").map(Number);
server.state._reveal(0, { x: fx, y: fy }, 0);
server._broadcastState();
const grown = network.sent[network.sent.length - 1].message.state;
this.assertNull(grown.explored, "the full explored list is not re-sent");
this.assertNotNull(grown.exploredAdded, "only the newly explored tiles are");
this.assertGreaterOrEqual(grown.exploredAdded.length, 1, "the tail holds the new tile");
}
test_city_economy_memo_is_scoped_to_one_broadcast() {
const { server } = makeServer();
const state = server.state;
const city = state.cities[0];
state.beginSnapshotCache();
const within = state.getCityEconomy(city);
// While the snapshot is being built the same figure is handed back...
this.assert(state.getCityEconomy(city) === within, "the memo is reused in scope");
state.tilePopulation.set(key(city.coords.x, city.coords.y), 999999);
this.assert(state.getCityEconomy(city) === within, "and is stable even as data shifts");
state.endSnapshotCache();
// ...but a simulation read afterwards always derives a fresh figure.
this.assertNotEqual(state.getCityEconomy(city), within, "ticks never read a stale figure");
}
test_a_civ_change_forces_a_full_snapshot() {
const { server, network } = makeServer();
network.peers.add(1);
server.peerCiv.set(1, 0);
server._broadcastState();
server._broadcastState();
const delta = network.sent[network.sent.length - 1].message.state;
this.assertTrue(delta.delta, "steady state is a delta");
this.assertNull(delta.visible, "the visible set is omitted while unchanged");
// The visible set is per-viewer, so switching nation must resend it.
server.peerCiv.set(1, 1);
server._broadcastState();
const full = network.sent[network.sent.length - 1].message.state;
this.assertFalse(full.delta, "a civ change resends the whole state");
this.assertNotNull(full.visible, "including the per-viewer visible set");
}
test_move_order_for_own_unit_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
}
test_server_advances_movement_between_hourly_ticks() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
for (let i = 0; i < 30; i++) server.advanceMovement(1 / 60);
this.assertGreater(unit.progressHours, 0, "movement advanced between hourly ticks");
this.assertEqual(server.state.totalHours, 0, "does not tick the clock");
}
test_state_changes_are_flushed_in_one_broadcast() {
const { server, network } = makeServer();
server.peerCiv.set(1, 0);
network.peers.add(1);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
const before = network.sent.length;
server.flushIfDirty();
this.assertEqual(network.sent.length, before + 1, "one snapshot was sent");
this.assertEqual(network.sent[network.sent.length - 1].message.t, "game_state");
server.flushIfDirty();
this.assertEqual(network.sent.length, before + 1, "second flush is a no-op");
}
test_queued_orders_wait_for_the_simulation_tick() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
server.enqueueOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
this.assertEqual(unit.path.length, 0, "the socket callback did not run the order");
this.assertEqual(server.processOrders(), 1, "the tick drains the queue");
this.assertGreaterOrEqual(unit.path.length, 2, "the queued route was computed");
}
test_order_queue_drains_within_the_time_budget() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
for (let i = 0; i < 5; i++) {
server.enqueueOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
}
// A zero budget still runs one order and leaves the rest for later ticks,
// so a burst can never monopolise the loop.
this.assertEqual(server.processOrders(0), 1);
this.assertEqual(server.processOrders(0), 1);
this.assertEqual(server.processOrders(0), 1);
}
test_departing_players_orders_are_dropped() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
server.enqueueOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] });
server.onPeerLeft(1);
this.assertEqual(server.processOrders(0), 0, "the departed player's order is gone");
this.assertEqual(unit.path.length, 0, "and it never ran");
}
test_large_wire_move_orders_are_split_across_ticks() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const sample = state.units.find((u) => u.civ === 0);
const proto = state.unitProto(sample);
const ids = [];
for (const cell of state.landCells) {
if (ids.length >= 8) break;
if (state.unitAt(cell)) continue;
ids.push(state._spawnUnit(cell, 0, proto).id);
}
const goal = adjacentLand(state, sample);
server.enqueueOrder(1, { type: "move", units: ids, coords: [goal.x, goal.y] });
this.assertEqual(server._orderQueue.length, 2, "eight units split into two chunks");
const [first, second] = server._orderQueue.map((entry) => entry.order);
this.assertSize(first.units, 4);
this.assertSize(second.units, 4);
this.assertEqual(first.speed, second.speed, "the chunks share one formation pace");
this.assertEqual(first.speed, state._unitSpeed(sample), "the pace is the slowest member's");
}
test_move_order_for_enemy_unit_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const enemy = server.state.units.find((u) => u.civ !== 0);
const goal = adjacentLand(server.state, enemy);
this.assertFalse(server.handleOrder(1, { type: "move", unit: enemy.id, coords: [goal.x, goal.y] }));
}
test_train_orders_queue_for_own_city() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
grantBuilding(server.state, city, "barracks");
const order = { type: "train", city: city.id, proto: 0 };
this.assertTrue(server.handleOrder(1, order));
this.assertTrue(server.handleOrder(1, order), "a second unit joins the queue");
this.assertSize(server.state.getTraining(city.id), 2);
}
test_cancel_train_order_for_own_city_is_accepted() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const city = server.state.cities.find((c) => c.civ === 0);
grantBuilding(server.state, city, "barracks");
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
server.handleOrder(1, { type: "train", city: city.id, proto: 0 });
this.assertTrue(server.handleOrder(1, { type: "cancel_train", city: city.id, index: 0 }));
this.assertSize(server.state.getTraining(city.id), 1);
}
test_train_order_for_enemy_city_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const enemy = server.state.cities.find((c) => c.civ !== 0);
this.assertFalse(server.handleOrder(1, { type: "train", city: enemy.id, proto: 0 }));
}
test_orders_from_unknown_peers_are_rejected() {
const { server } = makeServer();
this.assertFalse(server.handleOrder(42, { type: "move", unit: 1, coords: [0, 0] }));
}
test_unknown_order_types_are_ignored() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
this.assertFalse(server.handleOrder(1, { type: "dance" }));
}
test_orders_before_configuration_are_ignored() {
const server = new GameServer(fakeNetwork());
this.assertFalse(server.handleOrder(1, { type: "move", unit: 1, coords: [0, 0] }));
}
test_empty_civilisation_list_falls_back_to_the_project() {
const server = new GameServer(fakeNetwork());
server.configureGame({ seed: SEED, player_civ: 0, civilisations: [] });
this.assertEqual(server.state.civilisations.length, CIVILISATIONS.length);
}
}
export class GameServerOrdersTest extends TestCase {
test_build_and_demolish_orders() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
server.state.budgets.set(0, 1.0e15);
const city = server.state.cities.find((c) => c.civ === 0);
this.assertTrue(server.handleOrder(1, { type: "build", city: city.id, building: 0 }));
for (let i = 0; i < 100000 && server.state.training.size > 0; i++) server.state.advanceHour();
this.assertTrue(server.handleOrder(1, { type: "demolish", city: city.id, building: 0 }));
}
test_build_order_for_enemy_city_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
server.state.budgets.set(0, 1.0e15);
const enemy = server.state.cities.find((c) => c.civ !== 0);
this.assertFalse(server.handleOrder(1, { type: "build", city: enemy.id, building: 0 }));
}
test_government_order() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
server.state.budgets.set(0, 1.0e15);
this.assertTrue(server.handleOrder(1, { type: "government", government: 1 }));
this.assertEqual(server.state.getGovernment(0), 1);
}
test_focus_technology_order() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
this.assertTrue(server.handleOrder(1, { type: "focus_technology", technology: 0 }));
this.assertEqual(server.state.getFocusedTechnology(0), 0);
}
test_move_order_with_bad_coords_is_rejected_cleanly() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const result = server.handleOrder(1, { type: "move", unit: server.state.units[0].id, coords: null });
this.assertEqual(typeof result, "boolean");
}
test_attack_order_targets_an_adjacent_enemy() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const pair = emptyLandPair(server.state);
const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]);
server.state._spawnUnit(pair.b, 1, server.state.protoUnits[0]);
this.assertTrue(
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] })
);
}
test_ranged_attack_order_bombs_without_closing_in() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const state = server.state;
const pair = emptyLandPair(state);
const artillery = state.protoUnits.find((u) => u.id === "artillery");
const attacker = state._spawnUnit(pair.a, 0, artillery);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] }),
"the ranged attack is accepted"
);
this.assertEqual(attacker.coords, pair.a, "the artillery held its ground");
this.assertEqual(defender.hp, defender.maxHp, "the first volley waits for the hour");
state.tickHour();
this.assertLess(defender.hp, defender.maxHp, "the enemy stack was bombed");
}
test_attack_order_without_a_target_is_rejected() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const pair = emptyLandPair(server.state);
const attacker = server.state._spawnUnit(pair.a, 0, server.state.protoUnits[0]);
this.assertFalse(
server.handleOrder(1, { type: "attack", unit: attacker.id, coords: [pair.b.x, pair.b.y] })
);
}
test_schedule_order_queues_destinations() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
const unit = server.state.units.find((u) => u.civ === 0);
const goal = adjacentLand(server.state, unit);
const payload = { type: "schedule", unit: unit.id, goals: [[goal.x, goal.y]], append: true };
this.assertTrue(server.handleOrder(1, payload));
this.assertTrue(server.handleOrder(1, payload));
this.assertSize(unit.waypoints, 1, "the appended destination is queued");
}
test_extra_players_become_spectators() {
const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
this.assertTrue(server.claimCiv(1, 0));
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
this.assertTrue(server.claimCiv(2, 1));
server.authenticate(3, "Cat", "pw");
server.onPeerJoined(3, "Cat");
this.assertEqual(server.peerCiv.get(1), 0);
this.assertEqual(server.peerCiv.get(2), 1);
this.assertEqual(server.peerCiv.get(3), -1, "a third player has no nation left");
this.assertEmpty(server.availableCivs());
}
test_joining_player_is_offered_the_unclaimed_civilisations() {
const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
server.claimCiv(1, 0);
network.sent.length = 0;
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
const choose = network.sent.find((m) => m.peerId === 2 && m.message.t === "choose_civ");
this.assertNotNull(choose, "the joiner is offered a choice");
this.assertEqual(choose.message.civs.map((c) => c.index), [1]);
this.assertEqual(choose.message.civs[0].id, server.state.civilisations[1].id);
this.assertEqual(server.peerCiv.get(2), -1, "no nation until the player picks");
}
test_claiming_a_taken_or_unknown_civilisation_is_rejected() {
const { server } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
this.assertTrue(server.claimCiv(1, 0));
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
this.assertFalse(server.claimCiv(2, 0), "already claimed");
this.assertFalse(server.claimCiv(2, 99), "out of range");
this.assertFalse(server.claimCiv(2, -1), "unknown index");
this.assertEqual(server.peerCiv.get(2), -1);
}
test_lifecycle_calls_are_safe_before_configuration() {
const network = fakeNetwork();
const server = new GameServer(network);
server.tickHour();
server.advanceMovement(1);
server.onPeerJoined(1, "Alice");
this.assertFalse(server.isConfigured());
this.assertEmpty(network.sent, "no snapshot is sent before configuration");
}
test_tick_hour_advances_the_state() {
const { server } = makeServer();
server.tickHour();
this.assertEqual(server.state.totalHours, 1);
}
}
export class GameServerLoginTest extends TestCase {
test_first_player_becomes_admin() {
const { server } = makeServer();
const result = server.authenticate(1, "Alice", "pw");
this.assertTrue(result.ok);
this.assertTrue(result.admin);
this.assertEqual(server.adminName, "Alice");
}
test_later_players_are_not_admin() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
const result = server.authenticate(2, "Bob", "pw");
this.assertTrue(result.ok);
this.assertFalse(result.admin);
}
test_missing_credentials_are_rejected() {
const { server } = makeServer();
this.assertFalse(server.authenticate(1, "", "pw").ok);
this.assertFalse(server.authenticate(1, "Alice", "").ok);
}
test_duplicate_online_name_is_rejected() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
const result = server.authenticate(2, "Alice", "pw");
this.assertFalse(result.ok);
}
test_wrong_password_is_rejected() {
const { server } = makeServer();
server.authenticate(1, "Alice", "correct");
server.onPeerLeft(1);
const result = server.authenticate(2, "Alice", "wrong");
this.assertFalse(result.ok);
this.assertFalse(server.peerNames.has(2));
}
test_new_name_sets_its_own_password() {
const { server } = makeServer();
this.assertTrue(server.authenticate(1, "Alice", "anything").ok);
}
test_admin_identity_survives_reconnect() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerLeft(1);
server.authenticate(2, "Bob", "pw");
const result = server.authenticate(3, "Alice", "pw");
this.assertTrue(result.ok);
this.assertTrue(result.admin, "the founder is still admin after rejoining");
}
test_returning_player_reclaims_their_civilisation() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
this.assertTrue(server.claimCiv(1, 0));
const firstCiv = server.peerCiv.get(1);
server.onPeerLeft(1);
this.assertEmpty(server.peerCiv);
this.assertTrue(server.authenticate(2, "Alice", "pw").ok);
server.onPeerJoined(2, "Alice");
this.assertEqual(server.peerCiv.get(2), firstCiv);
}
test_simulation_continues_with_no_players() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
server.claimCiv(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
server.onPeerLeft(1);
for (let i = 0; i < 30; i++) server.advanceMovement(1 / 60);
this.assertGreater(unit.progressHours, 0, "the server advanced with nobody connected");
}
test_build_improvement_order_lays_a_road_on_controlled_land() {
const { server } = makeServer();
server.peerCiv.set(1, 0);
server.state.budgets.set(0, 10_000_000_000);
// A land tile France owns but where the pre-generated network left no road.
const coords = server.state.landCells.find((cell) => {
const k = `${cell.x},${cell.y}`;
return server.state.civAt(cell) === 0 && !server.state.roads.has(k);
});
this.assertNotNull(coords, "the map has a bare owned tile");
this.assertTrue(
server.handleOrder(1, {
type: "build_improvement",
coords: [coords.x, coords.y],
improvement: "road",
})
);
const k = `${coords.x},${coords.y}`;
this.assertTrue(server.state.roads.has(k), "the road is laid");
// The owner may replace it with a railway.
this.assertTrue(
server.handleOrder(1, {
type: "build_improvement",
coords: [coords.x, coords.y],
improvement: "railway",
})
);
this.assertTrue(server.state.railways.has(k));
this.assertFalse(server.state.roads.has(k), "the road was replaced");
// A player who owns nothing cannot build there.
server.peerCiv.set(2, 1);
this.assertFalse(
server.handleOrder(2, {
type: "build_improvement",
coords: [coords.x, coords.y],
improvement: "road",
})
);
}
}
export class GameServerLobbyTest extends TestCase {
test_unconfigured_server_lists_no_games() {
const server = new GameServer(fakeNetwork());
this.assertEmpty(server.gameList());
}
test_configured_server_lists_one_game() {
const { server } = makeServer();
const games = server.gameList();
this.assertSize(games, 1);
const game = games[0];
this.assertEqual(game.seed, SEED);
this.assertEqual(game.id, SEED);
this.assertSize(game.civs, 2);
this.assertEqual(game.civs.slice().sort(), ["Britain", "France"]);
this.assertEqual(game.players, 0);
this.assertTrue(game.date.includes("2000"));
}
test_player_count_follows_logins() {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.authenticate(2, "Bob", "pw");
this.assertEqual(server.gameList()[0].players, 2);
server.onPeerLeft(1);
this.assertEqual(server.gameList()[0].players, 1);
}
}