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