- Five resources (energy, steel, food, luxury, high-tech) with per-city stores, a world market, delivery and material upkeep. - Resource-producing tile works built outside cities; every nation opens with the works its economy needs. - Real game-icons.net art replacing the dummy icons for the new resources and works, with the credits updated.
256 lines
9.6 KiB
JavaScript
256 lines
9.6 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 { parseKey, 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|