GDP per capita had no natural growth, so a nation's economy only moved when population, buildings or war did. The summary stats also offered inconsistent hover text: a native browser tooltip for popularity and none at all for the budget. - Every land tile now gains a constant 0.01% of the GDP per capita it opened with, once a day. Because the amount is fixed the relative growth rate tapers over the years. Each tile's opening figure is snapshotted once and shipped to the browser, so the tile panel and the GDP-per-capita map mode stay in step with the server. - The population, GDP and GDP/capita tooltips show the relative growth: against 1 January 2000 through the first year, then year on year. The monthly captures now keep a rolling thirteen months for the baseline. - Every summary stat explains itself through the same custom bubble. Popularity's native title moved onto it, and Budget forecasts the next calendar month at the current hourly net, as a compact figure such as "+€3.28B/mo" (hoursInNextMonth follows the real calendar). - The tile GDP breakdown shows gains with a plus sign (natural growth, ports) rather than the old minus. Added tests for the constant daily gain, the falling relative rate, the first-year and year-on-year baselines, the snapshot baseline, the budget forecast and the calendar month length.
602 lines
24 KiB
JavaScript
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[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[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, "britain");
|
|
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[0], "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);
|
|
}
|
|
}
|