- run_tests.js discovers and runs each *_test.js in its own worker process (--jobs N to cap the pool, --file <name> for a single suite) - GameState caches the deterministic terrain, city sites and road network by (seed, mapConfig, civ count), rebuilding only the mutable per-game state - the snapshot ships mapConfig and the client rebuilds terrain from it, so the DOM tests can use the small map fixture and a non-default config renders right - shortestPaths can stop once every target city is settled
768 lines
31 KiB
JavaScript
768 lines
31 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 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_melee_charges_an_artillery_and_hits_it() {
|
|
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);
|
|
this.assertEqual(state._combatTarget(infantry), artillery);
|
|
const artilleryHp = artillery.hp;
|
|
state._resolveCombatRound();
|
|
this.assertLess(artillery.hp, artilleryHp, "an adjacent artillery has almost no protection");
|
|
}
|
|
|
|
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;
|
|
}
|