750 lines
30 KiB
JavaScript
750 lines
30 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, 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"
|
|
);
|
|
this.assertNull(state.findUnit(missile.id), "the rocket is consumed, not marched");
|
|
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);
|
|
}
|
|
}
|