Files
Battle-for-Tismo/tests/game_state_test.js
T

478 lines
18 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { TRAINING_QUEUE_LIMIT } from "../shared/game_state.js";
import { ECONOMY } from "../shared/data.js";
import {
smallState,
adjacentLand,
seaTile,
cityOf,
unitsOf,
snapshotUnit,
grantBuilding,
} from "./framework/helpers.js";
export class GameStateTest extends TestCase {
test_configure_builds_the_world() {
const state = smallState();
this.assertSize(state.tiles, 24 * 16);
this.assertNotEmpty(state.landCells);
this.assertNotNull(state.topology);
this.assertTrue(state.topology.isCylindrical());
this.assertNotNull(state.terrainStats);
this.assertTrue(state.isConfigured());
}
test_configure_places_settlements_for_every_civilisation() {
const state = smallState();
this.assertSize(state.units, 2);
for (const civ of [0, 1]) {
this.assertEqual(state.cities.filter((c) => c.civ === civ && c.isCapital).length, 1, `civ ${civ} capital`);
this.assertEqual(unitsOf(state, civ), 1, `civ ${civ} starting unit`);
}
}
test_configure_is_deterministic_for_a_seed() {
const a = smallState();
const b = smallState();
this.assertEqual(a.landCells, b.landCells);
this.assertEqual(a.cities, b.cities);
this.assertEqual(a.units, b.units);
this.assertEqual(a.territory, b.territory);
}
// The generated world is cached and reused for a repeated seed, so the
// mutable pieces must be rebuilt per game rather than shared.
test_reused_worlds_are_independent() {
const a = smallState();
const b = smallState();
const roads = b.roads.size;
const population = b.cities[0].population;
a.roads.add("999999,999999");
a.cities[0].population = -1;
a.units[0].coords = { x: 0, y: 0 };
this.assertSize(b.roads, roads, "the cached roads are copied per game");
this.assertNotEqual(b.cities[0].population, -1, "cities are not shared");
this.assertNotEqual(b.units[0].coords, a.units[0].coords, "units are not shared");
this.assertEqual(b.cities[0].population, population, "the other game is untouched");
}
test_foreign_territory_is_a_wall_unless_targeted() {
const state = smallState(["france", "britain", "poland"]);
const mover = state.units.find((u) => u.civ === 0);
const tile = state.landCells.find(
(c) =>
!state.unitAt(c) &&
!state.cityAt(c) &&
(c.x !== mover.coords.x || c.y !== mover.coords.y)
);
state.territory.set(`${tile.x},${tile.y}`, 2);
this.assertFalse(
state._canUnitEnter(mover, tile, false, 1),
"a third party's land blocks transit"
);
this.assertTrue(
state._canUnitEnter(mover, tile, false, 2),
"the targeted civ's land may be crossed"
);
this.assertTrue(
state._canUnitEnter(mover, tile, true, 1),
"the goal tile itself is always enterable"
);
}
test_territory_only_claims_land() {
const state = smallState();
this.assertNotEmpty(state.territory);
for (const [k, owner] of state.territory) {
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
this.assertEqual(state.tiles[k].terrainClass, "Land", `tile ${k} owned by ${owner}`);
}
}
test_city_tile_population_matches_the_city() {
const state = smallState();
for (const city of state.cities) {
this.assertEqual(state.getTilePopulation(city.coords), city.population);
}
}
test_city_names_are_unique_across_the_world() {
const state = smallState(["france", "britain", "switzerland", "new_hampshire"]);
const names = state.cities.map((city) => city.name);
this.assertEqual(new Set(names).size, names.length, "no two cities share a name");
}
test_everything_starts_explored_and_units_are_visible() {
const state = smallState();
for (const k in state.tiles) {
const i = k.indexOf(",");
const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
this.assertTrue(state.isExplored(0, coords), `tile ${k} explored`);
}
for (const unit of state.units) {
this.assertTrue(state.isVisible(unit.civ, unit.coords));
}
}
test_player_figures_are_positive() {
const state = smallState();
this.assertGreater(state.getPlayerPopulation(0), 0);
this.assertGreater(state.getPlayerGdp(0), 0);
this.assertGreater(state.getPlayerUpkeep(0), 0);
this.assertApprox(
state.getBudget(0),
state.getPlayerGdp(0) * ECONOMY.startingBudgetFraction,
1e-6,
"a nation opens with a tenth of its starting GDP"
);
}
test_civilisations_start_with_equal_cities_population_and_gdp_per_capita() {
const state = smallState(["france", "britain", "slovenia"]);
const count = state.civilisations.length;
const cities = [];
const populations = [];
const perCapitas = [];
for (let civ = 0; civ < count; civ++) {
cities.push(state.cities.filter((c) => c.civ === civ).length);
const population = state.getPlayerPopulation(civ);
populations.push(population);
perCapitas.push(state.getPlayerGdp(civ) / population);
}
this.assertEqual(cities, [2, 2, 2], "every civ starts with the same number of cities");
this.assertEqual(populations, [populations[0], populations[0], populations[0]], "every civ starts with the same population");
for (const perCapita of perCapitas) {
this.assertApprox(perCapita, perCapitas[0], 1e-6, "every civ starts with the same GDP per capita");
}
}
test_can_enter_land_but_not_water() {
const state = smallState();
const proto = state.protoUnits[0];
const sea = seaTile(state);
this.assertNotNull(sea);
this.assertFalse(state.canEnter(0, proto, sea));
this.assertTrue(state.canEnter(0, proto, state.landCells[0]));
}
test_path_between_neighbours_is_valid() {
const state = smallState();
const unit = state.units[0];
const goal = adjacentLand(state, unit);
const path = state.findPath(unit, unit.coords, goal);
this.assertSize(path, 2);
this.assertEqual(path[0], unit.coords);
this.assertEqual(path[1], goal);
}
test_request_move_walks_the_unit_to_the_goal() {
const state = smallState();
const unit = state.units[0];
const goal = adjacentLand(state, unit);
this.assertTrue(state.requestMove(unit.id, goal));
for (let i = 0; i < 100 && unit.coords !== goal; i++) state.advanceHour();
this.assertEqual(unit.coords, goal);
this.assertEmpty(unit.path);
}
test_advance_movement_advances_part_of_a_segment() {
const state = smallState();
const unit = state.units[0];
const goal = adjacentLand(state, unit);
this.assertTrue(state.requestMove(unit.id, goal));
const start = { ...unit.coords };
const fraction = state._tileTravelHours(unit, goal) * 0.5;
state.advanceMovement(fraction);
this.assertEqual(unit.coords, start, "still on the same tile");
this.assertApprox(unit.progressHours, fraction);
this.assertEqual(state.totalHours, 0, "movement does not tick the clock");
}
test_snapshot_includes_per_segment_travel_times() {
const state = smallState();
const unit = state.units[0];
const goal = adjacentLand(state, unit);
state.requestMove(unit.id, goal);
const data = snapshotUnit(state, unit.id);
this.assertSize(data.segmentHours, data.path.length - 1);
this.assertApprox(data.segmentHours[0], state._tileTravelHours(unit, goal));
}
test_request_move_rejects_water_and_unknown_units() {
const state = smallState();
const unit = state.units[0];
this.assertFalse(state.requestMove(unit.id, seaTile(state)));
this.assertFalse(state.requestMove(99999, state.landCells[0]));
}
test_advance_hour_ticks_the_clock() {
const state = smallState();
for (let i = 0; i < 5; i++) state.advanceHour();
this.assertEqual(state.totalHours, 5);
}
test_training_starts_then_produces_a_unit() {
const state = smallState();
const city = state.cities[0];
grantBuilding(state, city, "barracks");
const before = state.units.length;
const beforeUnits = unitsOf(state, city.civ);
this.assertTrue(state.requestTrain(city.id, 0));
this.assertTrue(state.requestTrain(city.id, 0), "a second unit joins the queue");
const queue = state.getTraining(city.id);
this.assertNotNull(queue, "training queue started");
this.assertSize(queue, 2);
const total = queue.reduce((sum, entry) => sum + entry.totalHours, 0);
for (let i = 0; i < Math.ceil(total) + 1 && state.units.length < before + 2; i++) {
state.advanceHour();
}
this.assertEqual(state.units.length, before + 2);
this.assertEqual(unitsOf(state, city.civ), beforeUnits + 2);
this.assertNull(state.getTraining(city.id), "the queue empties once it has finished");
}
test_training_charges_the_budget_up_front() {
const state = smallState();
const city = state.cities[0];
grantBuilding(state, city, "barracks");
const proto = state.protoUnits[0];
const before = state.getBudget(city.civ);
this.assertTrue(state.requestTrain(city.id, 0));
this.assertApprox(state.getBudget(city.civ), before - proto.cost);
const queue = state.getTraining(city.id);
this.assertSize(queue, 1);
this.assertEqual(queue[0].kind, "unit");
}
test_training_queue_is_capped_at_one_hundred() {
const state = smallState();
const city = state.cities[0];
grantBuilding(state, city, "barracks");
state.budgets.set(city.civ, 1.0e15);
for (let i = 0; i < TRAINING_QUEUE_LIMIT; i++) {
this.assertTrue(state.requestTrain(city.id, 0), `order ${i} accepted`);
}
this.assertFalse(state.requestTrain(city.id, 0), "the queue is full");
this.assertSize(state.getTraining(city.id), TRAINING_QUEUE_LIMIT);
}
test_cancel_train_removes_only_that_unit_and_refunds_nothing() {
const state = smallState();
const city = state.cities[0];
grantBuilding(state, city, "barracks");
state.requestTrain(city.id, 0);
state.requestTrain(city.id, 0);
state.requestTrain(city.id, 0);
const budget = state.getBudget(city.civ);
this.assertTrue(state.cancelTrain(city.id, 1));
this.assertSize(state.getTraining(city.id), 2, "only one unit was cancelled");
this.assertEqual(state.getBudget(city.civ), budget, "nothing is refunded");
this.assertFalse(state.cancelTrain(city.id, 5), "out-of-range index is rejected");
this.assertTrue(state.cancelTrain(city.id, 0));
this.assertTrue(state.cancelTrain(city.id, 0));
this.assertNull(state.getTraining(city.id), "cancelling the last unit clears the queue");
}
test_training_rejects_unknown_targets() {
const state = smallState();
this.assertFalse(state.requestTrain(99999, 0));
this.assertFalse(state.requestTrain(state.cities[0].id, 99));
}
test_economy_and_population_tick_daily() {
const state = smallState();
const population = state.getPlayerPopulation(0);
for (let i = 0; i < 24; i++) state.advanceHour();
this.assertGreater(state.getPlayerPopulation(0), population, "population grows");
this.assertNotEqual(state.getBudget(0), 0, "budget moved");
}
test_snapshot_has_every_section() {
const state = smallState();
const snapshot = state.snapshot(0);
for (const key of [
"seed", "viewer", "totalHours", "civs", "protos", "buildings", "governments",
"technologies", "cities", "units", "territory", "population", "budgets",
"culture", "government", "researched", "focusTechnology", "training", "viewerStats",
"explored", "visible",
]) {
this.assertHas(snapshot, key);
}
this.assertEqual(snapshot.viewer, 0);
this.assertSize(snapshot.civs, 2);
this.assertSize(snapshot.units, 2);
this.assertSize(snapshot.units[0].coords, 2);
}
test_snapshot_viewer_stats_match_the_state() {
const state = smallState();
this.assertEqual(state.snapshot(0).viewerStats.population, state.getPlayerPopulation(0));
this.assertEqual(state.snapshot(1).viewerStats.population, state.getPlayerPopulation(1));
const stats = state.snapshot(0).viewerStats;
const expected = stats.population > 0 ? stats.gdp / stats.population : 0;
this.assertApprox(stats.gdpPerCapita, expected);
}
test_snapshot_visibility_is_per_viewer() {
const state = smallState();
const first = state.snapshot(0);
const second = state.snapshot(1);
this.assertNotEmpty(first.visible);
this.assertNotEmpty(second.visible);
this.assertNotEqual(first.visible, second.visible);
}
test_snapshot_shared_fields_are_viewer_independent() {
const state = smallState();
const first = state.snapshot(0);
const second = state.snapshot(1);
for (const key of [
"seed", "totalHours", "civs", "protos", "buildings", "governments", "technologies",
"cities", "units", "territory", "population", "budgets", "culture",
"government", "researched",
]) {
this.assertEqual(first[key], second[key], `key ${key} is shared`);
}
this.assertNotEqual(first.visible, second.visible, "visibility stays per viewer");
}
test_snapshot_only_shows_the_viewers_own_production() {
const state = smallState();
state.budgets.set(0, 1.0e15);
state.budgets.set(1, 1.0e15);
const mine = state.cities.find((c) => c.civ === 0);
const theirs = state.cities.find((c) => c.civ === 1);
grantBuilding(state, mine, "barracks");
grantBuilding(state, theirs, "barracks");
this.assertTrue(state.requestTrain(mine.id, 0));
this.assertTrue(state.requestTrain(theirs.id, 0));
const asMine = state.snapshot(0).training;
this.assertHas(asMine, mine.id);
this.assertFalse(
Object.prototype.hasOwnProperty.call(asMine, theirs.id),
"no enemy queue is shipped"
);
const asTheirs = state.snapshot(1).training;
this.assertHas(asTheirs, theirs.id);
this.assertFalse(
Object.prototype.hasOwnProperty.call(asTheirs, mine.id),
"the enemy sees nothing either"
);
this.assertEmpty(state.snapshot(-1).training, "a spectator sees no production");
}
test_explored_serialisation_cache_invalidates_on_discovery() {
const state = smallState();
state.explored.get(0).clear();
this.assertEmpty(state.snapshot(0).explored, "cache reflects the emptied set");
state._reveal(0, state.landCells[0], 1);
this.assertNotEmpty(state.snapshot(0).explored, "new ground is serialised");
}
test_territory_index_matches_territory() {
const state = smallState();
let total = 0;
for (const [civ, cells] of state._territoryByCiv) {
for (const coords of cells) {
this.assertEqual(state.civAt(coords), civ, "tile ownership");
total += 1;
}
}
this.assertEqual(total, state.territory.size);
}
test_find_unit_and_city_use_the_id_index() {
const state = smallState();
for (const unit of state.units) this.assertEqual(state.findUnit(unit.id), unit);
this.assertNull(state.findUnit(999));
for (const city of state.cities) this.assertEqual(state.findCity(city.id), city);
this.assertNull(state.findCity(999));
}
test_units_move_faster_in_their_own_territory() {
const state = smallState();
const unit = state.units[0];
const own = unit.coords;
let foreign = null;
for (const [k, owner] of state.territory) {
if (owner !== unit.civ) {
const i = k.indexOf(",");
foreign = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) };
break;
}
}
this.assertEqual(state.civAt(own), unit.civ, "capital tile is friendly");
this.assertNotNull(foreign, "found a foreign tile");
const base = state._unitSpeed(unit);
this.assertApprox(state._effectiveSpeedAt(unit, own), base * 1.5);
this.assertApprox(state._effectiveSpeedAt(unit, foreign), base);
this.assertGreater(state._effectiveSpeedAt(unit, own), state._effectiveSpeedAt(unit, foreign));
}
test_visibility_is_only_refreshed_when_a_unit_moves() {
const state = smallState();
const unit = state.units[0];
const goal = adjacentLand(state, unit);
this.assertTrue(state.requestMove(unit.id, goal));
state.visible = new Map();
state._visibilityDirty = false;
let arrived = false;
for (let i = 0; i < 100; i++) {
if (sameCoords(unit.coords, goal)) {
arrived = true;
break;
}
state.advanceHour();
if (!sameCoords(unit.coords, goal)) {
this.assertSize(state.visible, 0, "no refresh while only progress advances");
}
}
this.assertTrue(arrived, "unit reached the goal");
this.assertNotEmpty(state.visible, "arrival refreshed visibility");
}
test_city_at_indexes_every_city() {
const state = smallState();
for (const city of state.cities) this.assertEqual(state.cityAt(city.coords), city);
this.assertNull(state.cityAt({ x: 9999, y: 9999 }));
}
test_city_adjacency_cache_matches_brute_force() {
const state = smallState();
for (const civ of [0, 1]) {
for (const coords of state.landCells) {
this.assertEqual(
state._isNextToCity(coords, civ),
bruteForceNextToCity(state, coords, civ),
`tile ${coords.x},${coords.y} civ ${civ}`
);
}
}
}
test_get_tile_info_reports_owner_and_terrain() {
const state = smallState();
const capital = cityOf(state, 0);
const info = state.getTileInfo(capital.coords);
this.assertEqual(info.owner, 0);
this.assertEqual(info.terrainType, state.tiles[`${capital.coords.x},${capital.coords.y}`].terrainType);
this.assertEqual(info.population, capital.population);
}
}
function sameCoords(a, b) {
return a.x === b.x && a.y === b.y;
}
function bruteForceNextToCity(state, coords, owner) {
for (const city of state.cities) {
if (city.civ !== owner) continue;
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
if (neighbour.x === coords.x && neighbour.y === coords.y) return true;
}
}
return false;
}