Files
Battle-for-Tismo/tests/siege_test.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

242 lines
9.2 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, mapLayers, SEED, grantBuilding, settleInlandCity } from "./framework/helpers.js";
import { key } 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");
}
}
test_a_gap_in_the_ring_frees_the_unit() {
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], "a full ring encircles");
// Opening one land tile -- an allied unit or an empty tile -- breaks it.
state._destroyUnit(blockers[0]);
state._refreshStatuses();
this.assertEmpty(unit.statuses, "one open land tile frees the unit");
}
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_be_besieged() {
const state = smallState(["france"], SEED, { citiesPerCiv: 1 });
// A portless, landlocked city: a coastal one would keep its sea lane open.
const city = settleInlandCity(state, 0, (coords) => {
const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
});
this.assertNotNull(city);
surroundTile(state, city.coords, 1);
state._refreshStatuses();
this.assertEqual(city.statuses, [STATUS_BESIEGED], "a surrounded portless city is 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_a_port_survives_a_land_ring_and_falls_to_a_blockade() {
const state = smallState();
const city = findCoastalClearCity(state, 0);
this.assertNotNull(city, "found a coastal city with a clear land ring");
grantBuilding(state, city, "port");
surroundTile(state, city.coords, 1);
state._refreshStatuses();
this.assertEmpty(city.statuses, "the port supplies the city by sea");
// Enemy warships now hold every sea tile around the city.
for (const neighbour of state.topology.neighbours(city.coords.x, city.coords.y)) {
const tile = state.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") state._spawnUnit(neighbour, 1, navalProto());
}
state._refreshStatuses();
this.assertEqual(city.statuses, [STATUS_BESIEGED], "the blockade cuts the sea lane");
}
test_an_allied_naval_unit_does_not_break_a_land_ring() {
const state = smallState();
const tile = findSurroundableTile(state);
const unit = state._spawnUnit(tile, 0, state.protoUnits[0]);
surroundTile(state, tile, 1);
state._spawnUnit(tile, 0, navalProto()); // a friendly hull on the same land tile
state._refreshStatuses();
this.assertEqual(unit.statuses, [STATUS_ENCIRCLED], "only a port breaks the ring");
}
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) {
const clear = (coords) => {
const land = state.topology.neighbours(coords.x, coords.y).filter((n) => state._isLand(n));
return land.length > 0 && !land.some((n) => state.unitAt(n) || state.cityAt(n));
};
for (const city of state.cities) {
if (city.civ !== civ) continue;
// A coastal city now starts with a port, which keeps it supplied; besiege
// an inland city instead.
if (state.isCoastalCity(city)) continue;
if (clear(city.coords)) return city;
}
// The strong coastal preference can leave the fixture with no inland city;
// settle one on a clear inland tile when it has.
return settleInlandCity(state, civ, clear);
}
// A coastal city whose land ring is clear, so a port's sea lane can be tested.
function findCoastalClearCity(state, civ) {
for (const city of state.cities) {
if (city.civ !== civ) continue;
if (!state.isCoastalCity(city)) 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;
}