Files
Battle-for-Tismo/tests/combat_test.js
T
adrien e7ee5dbf0e Added the resource economy, its works and their icons
- 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.
2026-09-22 05:27:11 +02:00

773 lines
32 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { smallState, mapLayers } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { PROTO_UNITS, CIVILISATIONS, STATUS_BATTLE, BATTLE } from "../shared/data.js";
import { MapView } from "../client/js/map_view.js";
const DAY = 24;
export class CombatTest extends TestCase {
test_military_units_carry_combat_stats() {
const infantry = PROTO_UNITS.find((u) => u.id === "modern_infantry");
const worker = PROTO_UNITS.find((u) => u.id === "worker");
this.assertTrue(infantry.military, "infantry is military");
this.assertGreater(infantry.attack, 0, "infantry attacks");
this.assertGreater(infantry.defense, 0, "infantry defends");
this.assertFalse(worker.military, "worker is not military");
}
test_an_attacker_charges_onto_the_defender_tile() {
const state = smallState();
const { attacker, defender, a, b } = engagement(state);
this.assertEqual(attacker.coords, b, "the attacker joins the defender's tile");
this.assertEqual(defender.coords, b, "the defender holds its tile");
this.assertSize(state.unitsAt(b), 2, "the two stacks share the tile");
this.assertEqual(state._combatTarget(attacker), defender);
this.assertEqual(state._combatTarget(defender), attacker);
this.assertNotEqual(a, b);
const battles = state.snapshot(0).battles;
this.assertSize(battles, 1, "one battlefield is reported");
this.assertEqual(battles[0].slice(0, 2), [b.x, b.y], "it is on the contested tile");
this.assertEqual(battles[0][3], 0, "the charging civ is the attacker");
this.assertEqual(battles[0][2], 1, "the occupant defends");
}
test_pathfinding_rejects_friendly_and_non_military_targets() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
// Friendly units may stack on the same tile.
const friend = state._spawnUnit(pair.b, 0, state.protoUnits[0]);
this.assertTrue(state._canUnitEnter(mover, pair.b), "a friendly unit does not block");
this.assertTrue(state.requestMove(mover.id, pair.b), "a unit can join a friendly stack");
state.advanceMovement(1000);
this.assertEqual(state.unitsAt(pair.b).length, 2, "both units share the tile");
// An enemy worker blocks too: military only fights military.
const other = findAnotherEmptyLandPair(state, pair);
this.assertNotNull(other, "found a second empty pair");
const enemyWorker = state._spawnUnit(other.b, 1, state.protoUnits[1]);
const secondMover = state._spawnUnit(other.a, 0, state.protoUnits[0]);
this.assertFalse(state.requestMove(secondMover.id, other.b), "no move onto a worker");
this.assertFalse(state._canUnitEnter(secondMover, other.b));
this.assertTrue(state.isMilitary(friend));
this.assertFalse(state.isMilitary(enemyWorker));
}
test_hostile_tiles_are_only_entered_as_the_goal() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertFalse(
state._canUnitEnter(mover, pair.b, false),
"an enemy unit is avoided in transit"
);
this.assertTrue(
state._canUnitEnter(mover, pair.b, true),
"an enemy unit is allowed as the goal"
);
const city = state.cities.find((c) => c.civ === 1 && !state.unitAt(c.coords));
this.assertNotNull(city, "found an undefended enemy city");
this.assertFalse(
state._canUnitEnter(mover, city.coords, false),
"an enemy city is avoided in transit"
);
this.assertTrue(
state._canUnitEnter(mover, city.coords, true),
"an enemy city is allowed as the goal"
);
}
test_charging_does_not_tick_damage_before_the_daily_round() {
const state = smallState();
const { attacker, defender, b } = engagement(state);
const attackerHp = attacker.hp;
const defenderHp = defender.hp;
state.advanceMovement(48);
this.assertEqual(attacker.coords, b, "the attacker is on the battlefield");
this.assertEqual(defender.coords, b, "the defender cannot slip away");
this.assertEqual(attacker.hp, attackerHp, "no damage before the daily round");
this.assertEqual(defender.hp, defenderHp, "no damage before the daily round");
}
test_fighting_units_carry_the_battle_status() {
const state = smallState();
const { attacker, defender } = engagement(state);
const units = state.snapshot(0).units;
const attackerData = units.find((u) => u.id === attacker.id);
const defenderData = units.find((u) => u.id === defender.id);
this.assertTrue(attackerData.statuses.includes(STATUS_BATTLE), "the attacker is in battle");
this.assertTrue(defenderData.statuses.includes(STATUS_BATTLE), "the defender is in battle");
}
test_battle_triples_the_fighting_unit_upkeep() {
const state = smallState();
const { attacker } = engagement(state);
const battle = state._battleUnitIds();
this.assertTrue(battle.has(attacker.id), "the attacker is in battle");
const multiplier = state._statusUpkeepMultiplier(
state._effectiveStatuses(attacker, true)
);
this.assertGreater(multiplier, 1, "a unit in battle burns more material upkeep");
}
test_a_blocked_move_with_no_route_is_abandoned() {
const state = smallState();
const pair = findEmptyLandPair(state);
const mover = state._spawnUnit(pair.a, 0, state.protoUnits[1]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
mover.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }];
mover.pathIndex = 0;
mover.progressHours = 0;
state.advanceMovement(48);
this.assertEqual(mover.coords, pair.a, "the blocked worker stays put");
this.assertEmpty(mover.path, "the unreachable move is abandoned");
this.assertNull(state._combatTarget(mover), "the worker has no fight");
}
test_a_blocked_move_recalculates_around_an_obstacle() {
const state = smallState();
const spot = findDetour(state);
this.assertNotNull(spot, "found a start/goal pair with a detour");
const mover = state._spawnUnit(spot.start, 0, state.protoUnits[0]);
state._spawnUnit(spot.mid, 0, state.protoUnits[1]);
mover.path = [spot.start, spot.mid, spot.goal];
mover.pathIndex = 0;
mover.progressHours = 0;
state.advanceMovement(1000);
this.assertEqual(mover.coords, spot.goal, "the unit reaches the goal by another route");
this.assertEmpty(mover.path);
}
test_combat_deals_one_round_per_day_with_a_defensive_bonus() {
const state = smallState();
const { attacker, defender } = engagement(state);
for (let hour = 0; hour < DAY - 1; hour++) state.tickHour();
this.assertEqual(attacker.hp, attacker.maxHp, "no round before the day is up");
state.tickHour();
this.assertEqual(state.totalHours, DAY);
const toAttacker = Math.max(
1,
state._unitAttack(defender) - state._effectiveDefense(attacker, false)
);
const toDefender = Math.max(
1,
state._unitAttack(attacker) - state._effectiveDefense(defender, true)
);
this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "attacker takes the counterblow");
this.assertEqual(defender.hp, defender.maxHp - toDefender, "defender gets the 100% bonus");
}
test_terrain_defense_bonus_reduces_the_damage_taken() {
const flat = combatScenario(null);
const forest = combatScenario("Forest");
const hills = combatScenario("Hills");
const mountain = combatScenario("Mountain");
this.assertApprox(flat.defenderDamage, 300 - 40 * 2, 1e-9);
this.assertApprox(forest.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9);
this.assertApprox(hills.defenderDamage, 300 - 40 * 2 * 1.33, 1e-9);
this.assertApprox(mountain.defenderDamage, 300 - 40 * 2 * 2, 1e-9);
this.assertGreater(flat.defenderDamage, forest.defenderDamage, "forest defends better");
this.assertGreater(forest.defenderDamage, mountain.defenderDamage, "mountain defends best");
}
test_a_city_adds_its_own_defense_bonus() {
const city = combatScenario(null, { city: true });
const flat = combatScenario(null);
this.assertApprox(city.defenderDamage, 300 - 40 * 2 * 1.5, 1e-9);
this.assertLess(city.defenderDamage, flat.defenderDamage, "a city defends better than open land");
}
test_defeated_defender_disappears_and_attacker_holds_the_tile() {
const state = smallState();
const { attacker, defender, b } = engagement(state);
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(defender.id), "the vanquished unit is gone");
this.assertGreater(attacker.hp, 0, "the attacker survives");
this.assertEqual(attacker.coords, b, "the attacker already holds the tile");
}
test_attacker_disappears_when_the_defender_holds() {
const state = smallState();
const { attacker, defender } = engagement(state);
for (let round = 0; round < 40 && state.findUnit(attacker.id); round++) {
state._resolveCombatRound();
}
this.assertNull(state.findUnit(attacker.id), "the attacker is destroyed");
this.assertNotNull(state.findUnit(defender.id), "the defender holds");
this.assertGreater(defender.hp, 0);
}
test_mutual_destruction_removes_both_units() {
const state = smallState();
const { attacker, defender } = engagement(state);
attacker.hp = 1;
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(attacker.id));
this.assertNull(state.findUnit(defender.id));
}
test_units_ordered_into_each_other_fight_on_one_tile() {
const state = smallState();
const { attacker, defender, a, b } = engagement(state);
// Pull them apart again and order both to charge.
attacker.coords = a;
defender.coords = b;
attacker.hp = attacker.maxHp;
defender.hp = defender.maxHp;
attacker.path = [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
attacker.pathIndex = 0;
attacker.progressHours = 0;
defender.path = [{ x: b.x, y: b.y }, { x: a.x, y: a.y }];
defender.pathIndex = 0;
defender.progressHours = 0;
state.advanceMovement(1000);
this.assertEqual(state._combatPairs().length, 1, "the exchange is a single pair");
const toAttacker = Math.max(
1,
state._unitAttack(defender) - state._effectiveDefense(attacker, false)
);
const toDefender = Math.max(
1,
state._unitAttack(attacker) - state._effectiveDefense(defender, true)
);
state._resolveCombatRound();
this.assertEqual(attacker.hp, attacker.maxHp - toAttacker, "one counterblow, not two");
this.assertEqual(defender.hp, defender.maxHp - toDefender, "one blow, not two");
}
test_non_military_units_never_fight() {
const state = smallState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[1]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
// A worker can never path into the enemy, so give it the route by hand.
attacker.path = [{ x: pair.a.x, y: pair.a.y }, { x: pair.b.x, y: pair.b.y }];
attacker.pathIndex = 0;
attacker.progressHours = 0;
this.assertNull(state._combatTarget(attacker), "a worker does not attack");
state.advanceMovement(48);
this.assertEqual(attacker.coords, pair.a, "a worker is blocked by the enemy");
this.assertEqual(defender.hp, defender.maxHp, "no damage from a worker");
}
test_a_military_unit_captures_an_undefended_city() {
const state = smallState();
let city = null;
let launch = null;
for (const candidate of state.cities) {
if (candidate.civ !== 1 || state.unitAt(candidate.coords)) continue;
const neighbour = emptyLandNeighbour(state, candidate.coords);
if (!neighbour) continue;
city = candidate;
launch = neighbour;
break;
}
this.assertNotNull(city, "found an undefended enemy city with a staging tile");
const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]);
state.training.set(city.id, [{ protoIndex: 0, capacity: 1, elapsedHours: 0, totalHours: 10 }]);
this.assertTrue(state.requestMove(attacker.id, city.coords));
state.advanceMovement(1000);
this.assertEqual(attacker.coords, city.coords, "the attacker enters the city");
this.assertEqual(city.civ, 0, "the city changes hands");
this.assertEqual(state.civAt(city.coords), 0, "the city tile changes hands");
this.assertNull(state.getTraining(city.id), "the old owner's queue is cancelled");
}
test_a_defended_city_falls_only_after_its_defender_dies() {
const state = smallState();
let city = null;
let launch = null;
for (const candidate of state.cities) {
if (candidate.civ !== 1) continue;
const defender = state.unitAt(candidate.coords);
if (!defender || !state.isMilitary(defender)) continue;
const neighbour = emptyLandNeighbour(state, candidate.coords);
if (!neighbour) continue;
city = candidate;
launch = neighbour;
break;
}
this.assertNotNull(city, "found a defended enemy city with a staging tile");
const defender = state.unitAt(city.coords);
const attacker = state._spawnUnit(launch, 0, state.protoUnits[0]);
this.assertTrue(state.requestMove(attacker.id, city.coords));
state.advanceMovement(100);
this.assertEqual(attacker.coords, city.coords, "the attacker charges into the city");
this.assertEqual(city.civ, 1, "the city has not fallen while it is defended");
this.assertEqual(state._combatTarget(attacker), defender);
defender.hp = 1;
state._resolveCombatRound();
this.assertNull(state.findUnit(defender.id), "the defender dies");
this.assertEqual(city.civ, 0, "the city is captured once the defender is gone");
}
test_non_military_units_cannot_enter_an_enemy_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 1);
const worker = state.units.find((u) => u.civ === 0);
const workerProto = state.protoUnits[1];
const proxy = { ...worker, proto: state.protoUnits.indexOf(workerProto) };
this.assertFalse(
state._canUnitEnter(proxy, city.coords),
"a worker cannot walk into an enemy city"
);
}
test_capture_takes_only_the_closest_territory() {
const state = smallState();
const cities = state.cities.filter((c) => c.civ === 1);
this.assertGreaterOrEqual(cities.length, 2, "civ 1 has two cities");
const captured = cities[0];
const before = (state._territoryByCiv.get(1) || []).map((c) => ({ x: c.x, y: c.y }));
const distances = new Map();
for (const city of cities) distances.set(city.id, landDistances(state, city.coords));
const shouldFlip = new Set();
for (const coords of before) {
const k = key(coords.x, coords.y);
const capturedDist = distances.get(captured.id).get(k);
let stays = capturedDist === undefined;
if (!stays) {
for (const other of cities) {
if (other === captured) continue;
const otherDist = distances.get(other.id).get(k);
if (otherDist !== undefined && otherDist <= capturedDist) {
stays = true;
break;
}
}
}
if (!stays) shouldFlip.add(k);
}
this.assertTrue(state._captureCity(captured, 0), "the city is captured");
this.assertEqual(captured.civ, 0);
for (const coords of before) {
const k = key(coords.x, coords.y);
if (shouldFlip.has(k)) this.assertEqual(state.civAt(coords), 0, `tile ${k} is captured`);
else this.assertEqual(state.civAt(coords), 1, `tile ${k} stays with its owner`);
}
this.assertEqual(state._territoryByCiv.get(1).length, before.length - shouldFlip.size);
}
test_dead_units_leave_the_snapshot() {
const state = smallState();
const { defender } = engagement(state);
const before = state.snapshot(0).units.length;
defender.hp = 1;
state._resolveCombatRound();
this.assertEqual(state.snapshot(0).units.length, before - 1, "the dead unit leaves the snapshot");
this.assertNull(state.findUnit(defender.id));
}
test_artillery_and_infantry_trade_blows_on_one_tile() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(artillery.id, pair.b), "attack order accepted");
state.advanceMovement(1000);
this.assertEqual(artillery.coords, pair.b, "the artillery charges onto the infantry");
this.assertEqual(state._combatTarget(artillery), infantry);
const artilleryHp = artillery.hp;
const infantryHp = infantry.hp;
state._resolveCombatRound();
this.assertLess(artillery.hp, artilleryHp, "the thin-skinned artillery is hit");
this.assertLess(infantry.hp, infantryHp, "the infantry is hit back");
}
test_infantry_captures_an_unaccompanied_artillery_it_charges() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
// With no friendly infantry beside it, the battery is taken instead of
// fought, so there is no battle to resolve.
this.assertEqual(artillery.civ, 1, "the artillery is captured");
this.assertNull(state._combatTarget(infantry), "no melee follows the capture");
}
test_artillery_sheltered_by_infantry_still_fights() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
this.assertTrue(state.requestMove(infantry.id, pair.a), "the infantry charges in");
state.advanceMovement(1000);
this.assertEqual(artillery.civ, 0, "the artillery stays in the fight");
this.assertNotNull(state._combatTarget(infantry), "there is a battle to resolve");
}
test_a_battle_removes_a_share_of_population_and_gdp_each_day() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const population = state.tilePopulation.get(k);
const perCapita = state.getTileGdpPerCapita(b);
this.assertGreater(perCapita, 0, "the battlefield has a taxable economy");
state._tickBattleTiles();
this.assertApprox(
state.tilePopulation.get(k),
population * (1 - BATTLE.populationLossPerDay),
1e-6,
"the tile loses population"
);
this.assertApprox(
state.getTileGdpPerCapita(b),
perCapita * (1 - BATTLE.productionLossPerDay),
1e-6,
"the tile loses GDP per capita"
);
}
test_gdp_per_capita_recovers_only_after_thirty_days() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const perCapita = state.getTileGdpPerCapita(b);
state._tickBattleTiles();
const deficit = state.tileBattleProductionDeficit.get(k);
this.assertApprox(deficit, perCapita * BATTLE.productionLossPerDay, 1e-6);
const start = state.tileBattleRecoveryStart.get(k);
this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days");
state.totalHours = start - 1;
state._tickProductionRecovery();
this.assertApprox(state.tileBattleProductionDeficit.get(k), deficit, 1e-9, "nothing recovers early");
state.totalHours = start;
state._tickProductionRecovery();
this.assertApprox(
state.tileBattleProductionDeficit.get(k),
deficit - BATTLE.recoveryPerDay,
1e-9,
"recovery begins at $100 a day"
);
for (let day = 0; day < Math.ceil(deficit / BATTLE.recoveryPerDay) + 1; day++) {
state.totalHours += DAY;
state._tickProductionRecovery();
}
this.assertFalse(state.tileBattleProductionDeficit.has(k), "the wound heals completely");
this.assertFalse(state.tileBattleRecoveryStart.has(k), "and the clock is cleared");
}
test_a_full_day_of_fighting_scars_the_tile() {
const state = smallState();
const { b } = joinBattleOn(state, ownedLandPair(state));
const k = key(b.x, b.y);
const before = state.getTileGdpPerCapita(b);
this.assertFalse(state.tileBattleProductionDeficit.has(k), "the tile is untouched at first");
for (let hour = 0; hour < DAY; hour++) state.tickHour();
this.assertTrue(state.tileBattleProductionDeficit.has(k), "the daily tick records the scar");
this.assertLess(state.getTileGdpPerCapita(b), before, "and the tile is worth less");
this.assertEqual(state.tileBattleRecoveryStart.get(k), DAY + BATTLE.recoveryDelayDays * DAY);
}
test_a_unit_in_combat_cannot_pillage() {
const state = smallState();
const tile = (state._territoryByCiv.get(1) || []).find(
(coords) => state._isLand(coords) && !state.cityAt(coords)
);
this.assertNotNull(tile, "found an enemy-owned land tile");
const raider = state._spawnUnit(tile, 0, state.protoUnits[0]);
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
this.assertEqual(state.civAt(tile), 1, "the tile belongs to the defender");
this.assertFalse(state.canPillage([raider.id]), "a unit in combat cannot pillage");
state._destroyUnit(defender);
this.assertTrue(state.canPillage([raider.id]), "once the fight is over it can");
}
async test_client_prediction_mirrors_occupancy() {
const state = smallState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
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 military = PROTO_UNITS.find((u) => u.id === "modern_infantry");
const worker = PROTO_UNITS.find((u) => u.id === "worker");
this.assertTrue(
map._canEnter(0, military, pair.b, attacker.id, true),
"military may target an enemy military unit as the goal"
);
this.assertFalse(
map._canEnter(0, military, pair.b, attacker.id, false),
"an enemy military unit is avoided in transit"
);
this.assertFalse(
map._canEnter(0, worker, pair.b, 0, true),
"a worker is blocked by an enemy"
);
this.assertTrue(
map._canEnter(0, military, pair.a, 999),
"a friendly unit does not block; units stack"
);
this.assertTrue(
map._canEnter(0, military, pair.a, attacker.id),
"a unit ignores itself"
);
} finally {
teardownDom(env);
}
}
async test_client_prediction_charges_onto_the_target() {
const state = smallState();
const pair = findEmptyLandPair(state);
const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
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 data = state.snapshot(0).units.find((u) => u.id === artillery.id);
const route = await map.predictMove(data, pair.b);
this.assertNotNull(route, "a route is predicted");
const last = route[route.length - 1];
this.assertEqual(last, pair.b, "the predicted route enters the target tile");
} finally {
teardownDom(env);
}
}
async test_client_animates_the_charge_onto_the_enemy() {
const state = smallState();
const pair = findEmptyLandPair(state);
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
state._spawnUnit(pair.b, 1, state.protoUnits[0]);
state.requestMove(attacker.id, pair.b);
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 motion = map._unitMotion.get(attacker.id);
this.assertNotNull(motion, "the attacker has a motion");
const last = motion.path[motion.path.length - 1];
this.assertEqual(last, pair.b, "the animation charges onto the defender");
} finally {
teardownDom(env);
}
}
async test_client_draws_half_size_stacks_with_the_battle_icon_between_them() {
const state = smallState();
const { b } = engagement(state);
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 units = env.$("#layer-entities .unit.in-battle");
this.assertEqual(units.length, 2, "both stacks are drawn half-size");
const marker = env.$("#layer-entities .battle-marker");
this.assertEqual(marker.length, 1, "one battle marker is drawn");
this.assertTrue(
(marker.find("img").attr("src") || "").endsWith("icon_battle.svg"),
"the marker uses the battle icon"
);
const lefts = units.map((_, el) => parseFloat(el.style.left)).get();
const tops = units.map((_, el) => parseFloat(el.style.top)).get();
const markerLeft = parseFloat(marker[0].style.left);
const markerTop = parseFloat(marker[0].style.top);
this.assertLess(Math.min(...lefts), markerLeft, "one stack sits left of the icon");
this.assertGreater(Math.max(...lefts), markerLeft, "the other sits right of it");
this.assertApprox(markerTop, tops[0], 1e-6, "the marker is on the tile");
this.assertTrue(map._battles.has(key(b.x, b.y)), "the tile is registered as a battlefield");
} finally {
teardownDom(env);
}
}
}
// Spawns an attacker (civ 0) and a defender (civ 1) beside each other, then
// orders the attacker in. Returns the two units and both tiles, with the fight
// already joined on `b`.
function engagement(state) {
return joinBattleOn(state, findEmptyLandPair(state));
}
function joinBattleOn(state, pair) {
const attacker = state._spawnUnit(pair.a, 0, state.protoUnits[0]);
const defender = state._spawnUnit(pair.b, 1, state.protoUnits[0]);
state.requestMove(attacker.id, pair.b);
state.advanceMovement(1000);
return { attacker, defender, a: pair.a, b: pair.b };
}
// Two adjacent empty land tiles that both belong to a civilisation, so the
// battlefield has a population and a GDP per capita to scar.
function ownedLandPair(state) {
for (const cell of state.landCells) {
if (state.civAt(cell) < 0) continue;
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (state.tileImprovements.has(key(cell.x, cell.y))) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.civAt(neighbour) < 0) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
if (state.tileImprovements.has(key(neighbour.x, neighbour.y))) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return findEmptyLandPair(state);
}
// Two units planted on one tile of the given terrain, resolved for one round.
// A `city` option adds a city under them; the attacker's stats are boosted so
// the damage numbers are easy to read.
function combatScenario(defenderType, options = {}) {
const state = smallState();
const attackerProto = { ...state.protoUnits[0], attack: 300 };
const tile = findTerrainTile(state, defenderType);
if (!tile) return null;
if (options.city) {
state._spawnCity({ civIndex: 1, coords: { x: tile.x, y: tile.y }, isCapital: false });
state._rebuildCityIndex();
}
const attacker = state._spawnUnit(tile, 0, attackerProto);
const defender = state._spawnUnit(tile, 1, state.protoUnits[0]);
const attackerHp = attacker.hp;
const defenderHp = defender.hp;
state._resolveCombatRound();
return {
state,
attacker,
defender,
attackerDamage: attackerHp - attacker.hp,
defenderDamage: defenderHp - defender.hp,
};
}
function findTerrainTile(state, terrainType) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (terrainType && state.tiles[key(cell.x, cell.y)].terrainType !== terrainType) continue;
return { x: cell.x, y: cell.y };
}
return null;
}
function findEmptyLandPair(state) {
let fallback = null;
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
const pair = { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
if (!fallback) fallback = pair;
const ownA = state.civAt(cell) < 0 || state.civAt(cell) === 0;
const ownB = state.civAt(neighbour) < 0 || state.civAt(neighbour) === 0;
if (ownA && ownB) return pair;
}
}
return fallback;
}
function findAnotherEmptyLandPair(state, first) {
for (const cell of state.landCells) {
if (state.unitAt(cell) || state.cityAt(cell)) continue;
if (sameCoords(cell, first.a) || sameCoords(cell, first.b)) continue;
for (const neighbour of state.topology.neighbours(cell.x, cell.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
if (sameCoords(neighbour, first.a) || sameCoords(neighbour, first.b)) continue;
return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } };
}
}
return null;
}
// A start tile, an adjacent direct step and a goal two tiles away, with a
// distinct land neighbour of the start that also touches the goal: the detour
// a blocked unit can take.
function findDetour(state) {
for (const start of state.landCells) {
if (state.unitAt(start) || state.cityAt(start)) continue;
for (const mid of state.topology.neighbours(start.x, start.y)) {
if (!state._isLand(mid) || state.unitAt(mid) || state.cityAt(mid)) continue;
for (const goal of state.topology.neighbours(mid.x, mid.y)) {
if (sameCoords(goal, start) || !state._isLand(goal)) continue;
if (state.unitAt(goal) || state.cityAt(goal)) continue;
for (const alt of state.topology.neighbours(start.x, start.y)) {
if (sameCoords(alt, mid) || !state._isLand(alt)) continue;
if (state.unitAt(alt) || state.cityAt(alt)) continue;
if (state.topology.tileDistance(alt, goal) === 1) {
return { start, mid, goal, alt };
}
}
}
}
}
return null;
}
function emptyLandNeighbour(state, coords) {
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
if (!state._isLand(neighbour)) continue;
if (state.unitAt(neighbour) || state.cityAt(neighbour)) continue;
return { x: neighbour.x, y: neighbour.y };
}
return null;
}
function landDistances(state, start) {
const dist = new Map();
dist.set(key(start.x, start.y), 0);
let frontier = [start];
while (frontier.length > 0) {
const next = [];
for (const coords of frontier) {
const distance = dist.get(key(coords.x, coords.y));
for (const neighbour of state.topology.neighbours(coords.x, coords.y)) {
const nk = key(neighbour.x, neighbour.y);
if (dist.has(nk) || !state._isLand(neighbour)) continue;
dist.set(nk, distance + 1);
next.push(neighbour);
}
}
frontier = next;
}
return dist;
}
function sameCoords(a, b) {
return a.x === b.x && a.y === b.y;
}