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 proto = state.protoUnits[attacker.proto]; const idleUpkeep = state.getPlayerUpkeep(0) - proto.upkeep * 2; const idleUnits = state.budgetBreakdown(0).unitUpkeep.total - proto.upkeep * 2; this.assertApprox( state.getPlayerUpkeep(0) - idleUpkeep, proto.upkeep * 2, 1e-9, "the battle premium is twice the base upkeep" ); this.assertApprox( state.budgetBreakdown(0).unitUpkeep.total - idleUnits, proto.upkeep * 2, 1e-9, "the budget breakdown reflects the premium" ); } 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.gdpPerCapitaLossPerDay), 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.tileBattleGdpDeficit.get(k); this.assertApprox(deficit, perCapita * BATTLE.gdpPerCapitaLossPerDay, 1e-6); const start = state.tileBattleRecoveryStart.get(k); this.assertEqual(start, BATTLE.recoveryDelayDays * DAY, "recovery waits thirty days"); state.totalHours = start - 1; state._tickGdpRecovery(); this.assertApprox(state.tileBattleGdpDeficit.get(k), deficit, 1e-9, "nothing recovers early"); state.totalHours = start; state._tickGdpRecovery(); this.assertApprox( state.tileBattleGdpDeficit.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._tickGdpRecovery(); } this.assertFalse(state.tileBattleGdpDeficit.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.tileBattleGdpDeficit.has(k), "the tile is untouched at first"); for (let hour = 0; hour < DAY; hour++) state.tickHour(); this.assertTrue(state.tileBattleGdpDeficit.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; 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; 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; }