- run_tests.js discovers and runs each *_test.js in its own worker process (--jobs N to cap the pool, --file <name> for a single suite) - GameState caches the deterministic terrain, city sites and road network by (seed, mapConfig, civ count), rebuilding only the mutable per-game state - the snapshot ships mapConfig and the client rebuilds terrain from it, so the DOM tests can use the small map fixture and a non-default config renders right - shortestPaths can stop once every target city is settled
270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
import { TestCase } from "./framework/test_case.js";
|
|
import { setupDom, teardownDom } from "./framework/dom.js";
|
|
import { smallState, mapLayers, SEED } from "./framework/helpers.js";
|
|
import { parseKey } from "../shared/hex.js";
|
|
import { CIVILISATIONS, PROTO_UNITS, statusById, STATUS_ENCIRCLED, STATUS_BESIEGED } from "../shared/data.js";
|
|
import { MapView } from "../client/js/map_view.js";
|
|
|
|
const DAY = 24;
|
|
|
|
export class SiegeTest extends TestCase {
|
|
test_the_status_catalogue_defines_attrition() {
|
|
const encircled = statusById(STATUS_ENCIRCLED);
|
|
const besieged = statusById(STATUS_BESIEGED);
|
|
this.assertNotNull(encircled);
|
|
this.assertNotNull(besieged);
|
|
this.assertApprox(encircled.hpLossPerDay, 0.10);
|
|
this.assertApprox(encircled.cityHpLossPerDay, 0.02);
|
|
this.assertEqual(encircled.icon, "encirclement.svg");
|
|
this.assertEqual(besieged.icon, "encirclement.svg");
|
|
this.assertNull(statusById("nope"));
|
|
}
|
|
|
|
test_a_supplied_unit_carries_no_status() {
|
|
const state = smallState();
|
|
state._refreshStatuses();
|
|
for (const unit of state.units) {
|
|
this.assertEmpty(unit.statuses, "the starting unit is supplied");
|
|
}
|
|
}
|
|
|
|
// The supply check only runs when a hostile is adjacent: the daily refresh
|
|
// never calls into the search otherwise.
|
|
test_the_check_only_triggers_with_an_adjacent_hostile() {
|
|
const state = smallState();
|
|
const unit = state.units[0];
|
|
const neighbours = state.topology.neighbours(unit.coords.x, unit.coords.y).filter(
|
|
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
|
);
|
|
this.assertGreaterOrEqual(neighbours.length, 2, "the capital has open land around it");
|
|
|
|
let calls = 0;
|
|
const original = state._unitStatuses.bind(state);
|
|
state._unitStatuses = (...args) => {
|
|
calls += 1;
|
|
return original(...args);
|
|
};
|
|
|
|
state._refreshStatuses();
|
|
this.assertEqual(calls, 0, "no hostile, no supply search");
|
|
|
|
state._spawnUnit(neighbours[0], 0, state.protoUnits[0]);
|
|
state._refreshStatuses();
|
|
this.assertEqual(calls, 0, "a friendly neighbour does not trigger it");
|
|
|
|
state._spawnUnit(neighbours[1], 1, state.protoUnits[0]);
|
|
state._refreshStatuses();
|
|
this.assertGreater(calls, 0, "a hostile neighbour triggers the check");
|
|
this.assertEmpty(unit.statuses, "the unit is still supplied");
|
|
}
|
|
|
|
test_a_cut_off_unit_with_a_hostile_neighbour_is_encircled() {
|
|
const state = smallState();
|
|
const tile = findSurroundableTile(state);
|
|
this.assertNotNull(tile, "found an interior tile to surround");
|
|
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
|
surroundTile(state, tile, 1);
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
|
|
}
|
|
|
|
test_a_friendly_neighbour_does_not_trigger_encirclement() {
|
|
const state = smallState();
|
|
const unit = state.units[0];
|
|
const neighbour = state.topology.neighbours(unit.coords.x, unit.coords.y).find(
|
|
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
|
);
|
|
this.assertNotNull(neighbour, "found an open land neighbour");
|
|
state._spawnUnit(neighbour, 0, state.protoUnits[0]);
|
|
this.assertFalse(state._hasAdjacentHostile(unit.coords, unit.civ));
|
|
state._refreshStatuses();
|
|
this.assertEmpty(unit.statuses);
|
|
}
|
|
|
|
test_an_encircled_unit_loses_ten_percent_a_day() {
|
|
const state = smallState();
|
|
const tile = findSurroundableTile(state);
|
|
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
|
surroundTile(state, tile, 1);
|
|
state._refreshStatuses();
|
|
const before = unit.hp;
|
|
state._applyDailyStatusEffects();
|
|
this.assertApprox(unit.hp, before - unit.maxHp * 0.10);
|
|
}
|
|
|
|
test_a_garrisoned_unit_loses_only_two_percent_a_day() {
|
|
const state = smallState();
|
|
const city = findBesiegableCity(state, 0);
|
|
this.assertNotNull(city, "found a city to besiege");
|
|
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
|
|
surroundTile(state, city.coords, 1);
|
|
state._refreshStatuses();
|
|
this.assertEqual(city.statuses, [STATUS_BESIEGED]);
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
|
|
const before = unit.hp;
|
|
state._applyDailyStatusEffects();
|
|
this.assertApprox(unit.hp, before - unit.maxHp * 0.02);
|
|
}
|
|
|
|
test_a_lone_city_can_never_be_besieged() {
|
|
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
|
|
const city = state.cities[0];
|
|
this.assertNotNull(city);
|
|
surroundTile(state, city.coords, 1);
|
|
state._refreshStatuses();
|
|
this.assertEmpty(city.statuses, "a city with no sibling is not besieged");
|
|
}
|
|
|
|
test_status_is_refreshed_once_a_day_not_every_snapshot() {
|
|
const state = smallState();
|
|
const tile = findSurroundableTile(state);
|
|
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
|
|
const blockers = surroundTile(state, tile, 1);
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
|
|
|
|
// Breaking the ring does not change the cached status until the next day.
|
|
state._destroyUnit(blockers[0]);
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "still the cached status");
|
|
this.assertEqual(
|
|
state.snapshot(0).units.find((u) => u.id === unit.id).statuses,
|
|
[STATUS_ENCIRCLED],
|
|
"the snapshot reads the cached status"
|
|
);
|
|
|
|
for (let hour = 0; hour < DAY; hour++) state.tickHour();
|
|
this.assertEmpty(unit.statuses, "the daily refresh freed the unit");
|
|
}
|
|
|
|
test_an_allied_naval_unit_lifts_a_siege_across_water() {
|
|
const state = smallState();
|
|
const spot = findCoastalPair(state);
|
|
this.assertNotNull(spot, "found a sea tile between two land tiles");
|
|
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
|
|
surroundTile(state, spot.l1, 1);
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "cut off by land");
|
|
|
|
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
|
|
state._rebuildCityIndex();
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "no lane without a ship");
|
|
|
|
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 0, navalProto());
|
|
state._refreshStatuses();
|
|
this.assertEmpty(unit.statuses, "the friendly navy reopens the lane");
|
|
}
|
|
|
|
test_an_enemy_naval_unit_does_not_open_a_lane() {
|
|
const state = smallState();
|
|
const spot = findCoastalPair(state);
|
|
const unit = state._spawnUnit(spot.l1, 0, state.protoUnits[0]);
|
|
surroundTile(state, spot.l1, 1);
|
|
state._spawnCity({ civIndex: 0, coords: { x: spot.l2.x, y: spot.l2.y }, isCapital: false });
|
|
state._rebuildCityIndex();
|
|
state._spawnUnit({ x: spot.s.x, y: spot.s.y }, 1, navalProto());
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "an enemy fleet is no supply line");
|
|
}
|
|
|
|
async test_the_client_draws_status_icons_over_units_and_cities() {
|
|
const state = smallState();
|
|
const city = findBesiegableCity(state, 0);
|
|
this.assertNotNull(city, "found a city to besiege");
|
|
const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]);
|
|
surroundTile(state, city.coords, 1);
|
|
state._refreshStatuses();
|
|
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED]);
|
|
|
|
const env = await setupDom();
|
|
try {
|
|
const map = new MapView(env.$("#map-viewport"), env.$("#map-world"), mapLayers(env.$));
|
|
map.$viewport.width = () => 800;
|
|
map.$viewport.height = () => 600;
|
|
map.setCatalogue(CIVILISATIONS, PROTO_UNITS);
|
|
map.applySnapshot(state.snapshot(0));
|
|
|
|
const unitIcon = env.$("#layer-entities .unit .unit-statuses .status-icon");
|
|
this.assertGreater(unitIcon.length, 0, "the encircled unit shows an icon");
|
|
this.assertTrue(unitIcon.first().attr("src").endsWith("encirclement.svg"));
|
|
|
|
const cityIcon = env.$("#layer-entities .city .city-statuses .status-icon");
|
|
this.assertGreater(cityIcon.length, 0, "the besieged city shows an icon");
|
|
} finally {
|
|
teardownDom(env);
|
|
}
|
|
}
|
|
}
|
|
|
|
function navalProto() {
|
|
return {
|
|
id: "test_naval",
|
|
name: "Test naval",
|
|
maxHp: 100,
|
|
cost: 0,
|
|
upkeep: 0,
|
|
moveable: true,
|
|
speed: 0.5,
|
|
military: true,
|
|
attack: 10,
|
|
defense: 10,
|
|
range: 1,
|
|
traversableTerrains: ["Sea"],
|
|
icon: "person.png",
|
|
};
|
|
}
|
|
|
|
function findSurroundableTile(state) {
|
|
for (const cell of state.landCells) {
|
|
if (state.unitAt(cell) || state.cityAt(cell)) continue;
|
|
const neighbours = state.topology.neighbours(cell.x, cell.y);
|
|
const clear = neighbours.every(
|
|
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
|
);
|
|
if (clear) return { x: cell.x, y: cell.y };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function surroundTile(state, coords, civ) {
|
|
const placed = [];
|
|
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
|
|
if (!state._isLand(neighbour)) continue;
|
|
placed.push(state._spawnUnit(neighbour, civ, state.protoUnits[0]));
|
|
}
|
|
return placed;
|
|
}
|
|
|
|
function findBesiegableCity(state, civ) {
|
|
for (const city of state.cities) {
|
|
if (city.civ !== civ) continue;
|
|
if (state.unitAt(city.coords)) continue;
|
|
const land = state.topology.neighbours(city.coords.x, city.coords.y).filter((n) => state._isLand(n));
|
|
if (land.length === 0) continue;
|
|
if (land.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
|
|
return city;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// A sea tile with two non-adjacent land neighbours: l1 for the cut-off unit,
|
|
// l2 for the coastal destination city.
|
|
function findCoastalPair(state) {
|
|
for (const k in state.tiles) {
|
|
if (state.tiles[k].terrainClass !== "Sea") continue;
|
|
const s = parseKey(k);
|
|
const land = state.topology.neighbours(s.x, s.y).filter(
|
|
(n) => state._isLand(n) && !state.unitAt(n) && !state.cityAt(n)
|
|
);
|
|
for (let i = 0; i < land.length; i++) {
|
|
for (let j = i + 1; j < land.length; j++) {
|
|
if (state.topology.tileDistance(land[i], land[j]) <= 1) continue;
|
|
const neighbours = state.topology.neighbours(land[i].x, land[i].y).filter((n) => state._isLand(n));
|
|
if (neighbours.some((n) => state.unitAt(n) || state.cityAt(n))) continue;
|
|
return { s: { x: s.x, y: s.y }, l1: { x: land[i].x, y: land[i].y }, l2: { x: land[j].x, y: land[j].y } };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|