import { TestCase } from "./framework/test_case.js"; import { GameServer } from "../server/game_server.js"; import { CIVILISATIONS } from "../shared/data.js"; import { adjacentLand, SEED, grantBuilding, smallConfig } from "./framework/helpers.js"; import { STARTING_FORCES } from "../shared/game_state/scenario.js"; import { key } from "../shared/hex.js"; // What the opening order of battle should total on the fixture's world. function expectedStartingUnits(state) { let total = 0; for (const city of state.cities) { total += STARTING_FORCES.infantryPerCity + STARTING_FORCES.artilleryPerCity; if (state.hasCityBuilding(city, "port")) { total += STARTING_FORCES.launchesPerPort + STARTING_FORCES.battleshipsPerPort; } if (city.isCapital) { total += STARTING_FORCES.fightersPerCapital + STARTING_FORCES.bombersPerCapital; } } return total; } 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, mapConfig: smallConfig() }); 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, expectedStartingUnits(server.state)); 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.productionBaseline, "the static growth baseline is left out"); this.assertNull(delta.tileEthnicity, "unchanged ethnicity is left out"); this.assertNull(delta.cities, "unchanged cities are 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.assertNull(delta.units, "the unchanged army is not re-sent"); this.assertNull(delta.unitsUpdated, "no unit changed in a quiet broadcast"); this.assertNull(delta.unitsRemoved, "and none was lost"); // 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"); // A city whose own fields moved ships the city list again. server.state.cities[0].population += 1; server._broadcastState(); const cityList = network.sent[network.sent.length - 1].message.state; this.assertNotNull(cityList.cities, "a changed city list is 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_only_changed_units_ship_in_a_delta() { 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.assertNotNull(full.units, "the first snapshot carries the whole army"); // A quiet broadcast ships no units at all. server._broadcastState(); const quiet = network.sent[network.sent.length - 1].message.state; this.assertNull(quiet.units, "the army is left out"); this.assertNull(quiet.unitsUpdated, "and nothing changed"); // Damaging one unit ships only that unit. const unit = server.state.units[0]; unit.hp -= 1; server._broadcastState(); const changed = network.sent[network.sent.length - 1].message.state; this.assertNull(changed.units, "the whole army is not re-sent"); this.assertSize(changed.unitsUpdated, 1, "only the damaged unit is"); this.assertEqual(changed.unitsUpdated[0].id, unit.id, "and it is the right one"); // Losing a unit ships its removal. server.state._destroyUnit(unit); server._broadcastState(); const lost = network.sent[network.sent.length - 1].message.state; this.assertNull(lost.units, "the whole army is not re-sent"); this.assertSize(lost.unitsRemoved, 1, "the lost unit is reported"); this.assertEqual(lost.unitsRemoved[0], unit.id, "by id"); } 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_an_attack_with_a_missile_is_routed_to_the_missile_strike() { const { server } = makeServer(); server.peerCiv.set(1, 0); const state = server.state; const capital = state.cities.find((city) => city.civ === 0 && city.isCapital); const missile = state._spawnUnit( capital.coords, 0, state.protoUnits.find((proto) => proto.id === "missile") ); const target = state.landCells.find( (cell) => !state.unitAt(cell) && !state.cityAt(cell) ); const enemy = state._spawnUnit(target, 1, state.protoUnits[0]); this.assertTrue( server.handleOrder(1, { type: "attack", unit: missile.id, coords: [target.x, target.y] }), "the server accepts the missile order" ); // The rocket flies a straight line to the target and is consumed when the // warhead goes off, not the moment it is launched. for (let hour = 0; hour < 200 && state.findUnit(missile.id); hour++) { server.advanceMovement(1.0); server.tickHour(); } this.assertNull(state.findUnit(missile.id), "the rocket is consumed on arrival"); this.assertNull(state.findUnit(enemy.id), "and the target is destroyed"); } 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_spy_city_order_for_own_spy_is_accepted() { const { server } = makeServer(); server.peerCiv.set(1, 0); const state = server.state; const city = state.cities.find((c) => c.civ === 1); const spy = state._spawnUnit( { x: city.coords.x, y: city.coords.y }, 0, state.protoUnits.find((u) => u.id === "spy") ); this.assertTrue( server.handleOrder(1, { type: "spy_city", units: [spy.id], coords: [city.coords.x, city.coords.y] }) ); this.assertSize(state.spyReports.get(0), 1, "the report reaches the spy's nation"); } test_spy_order_for_another_nations_unit_is_rejected() { const { server } = makeServer(); server.peerCiv.set(1, 0); const state = server.state; const enemySpy = state.units.find((u) => u.civ === 1); this.assertFalse( server.handleOrder(1, { type: "spy_comms", units: [enemySpy.id], coords: [enemySpy.coords.x, enemySpy.coords.y] }) ); } test_launch_satellite_order_needs_the_centre() { const { server } = makeServer(); server.peerCiv.set(1, 0); const state = server.state; const city = state.cities.find((c) => c.civ === 0); this.assertFalse( server.handleOrder(1, { type: "launch_satellite", city: city.id }), "no centre, no launch" ); grantBuilding(state, city, "space_launch_center", 1); this.assertTrue(server.handleOrder(1, { type: "launch_satellite", city: city.id })); this.assertSize(state.satellites, 1); } test_unlock_technology_is_a_testing_only_shortcut() { const { server } = makeServer(); server.peerCiv.set(1, 0); const tech = server.state.technologies.findIndex((t) => t.id === "nuclear_weapons"); this.assertFalse( server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true }), "refused outside a testing game" ); server.testing = true; this.assertTrue( server.handleOrder(1, { type: "unlock_technology", technology: tech, free: true }) ); this.assertTrue(server.state.hasTechnology(0, "nuclear_weapons")); } 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); for (const city of server.state.cities) { if (city.civ !== 0) continue; const stock = server.state.getCityResourceStock(city); stock.steel = 1e9; stock.hightech = 1e6; stock.energy = 1e9; } // 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.assertNotNull(server.state.constructionSiteAt(coords), "the work is queued"); for (let i = 0; i < 50000 && server.state.constructionSites.size > 0; i++) { server.state.advanceHour(); } 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", }) ); for (let i = 0; i < 50000 && server.state.constructionSites.size > 0; i++) { server.state.advanceHour(); } 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); } }