Files
Battle-for-Tismo/tests/siege_test.js
T
adrien 7ed6fe065f Sped up the test suite with a parallel runner and a world cache
- 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
2026-09-18 21:25:02 +02:00

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;
}