Files
Battle-for-Tismo/tests/siege_test.js
T
adrien 3e429ccb64 Gave production buildings their own economy and fed island regions
Production buildings are now independent private-sector agents: they keep what their output sells for, run only to the demand they can actually sell, and spend that cash on steel and high-tech upgrades when their demand grows. Grid power is a paid private flow between plants and their consumers, and regions synthesise the food their own harvest and the reachable market cannot supply.

Island regions no longer famine: a region with no reachable supplier grows its whole shortfall from energy, and grid power now feeds cities and food before the converters, so a famine never waits on a factory.

Bundles the in-progress tree cleanup that was already present in the working tree.
2026-09-22 14:34:14 +02:00

235 lines
8.7 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, mapLayers, SEED, grantBuilding } 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 });
const city = state.cities[0];
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) {
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;
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 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;
}