import { TestCase } from "./framework/test_case.js"; import { smallState } from "./framework/helpers.js"; function tileOwnedBy(state, owner) { for (const [k, tileOwner] of state.territory) { if (tileOwner !== owner) continue; const i = k.indexOf(","); const coords = { x: Number(k.slice(0, i)), y: Number(k.slice(i + 1)) }; if (state.unitAt(coords) || state.cityAt(coords)) continue; return coords; } return null; } function emptyLandPair(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; } export class StackingTest extends TestCase { // Counts actual A* runs by wrapping this state's pathfinder. countPathSearches(state) { const counter = { count: 0 }; const pathfinder = state._pathfinder; const original = pathfinder.findPath.bind(pathfinder); pathfinder.findPath = (...args) => { counter.count += 1; return original(...args); }; return counter; } test_friendly_units_can_share_a_tile() { const state = smallState(); const pair = emptyLandPair(state); const first = state._spawnUnit(pair.a, 0, state.protoUnits[0]); state._spawnUnit(pair.a, 0, state.protoUnits[0]); this.assertEqual(state.unitsAt(pair.a).length, 2, "both units stack"); this.assertTrue(state._canUnitEnter(first, pair.a), "a unit may join its own stack"); } test_a_stack_moves_at_its_slowest_speed() { const state = smallState(); const pair = emptyLandPair(state); const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]); const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); this.assertTrue(state.requestGroupMove([infantry.id, artillery.id], pair.b)); this.assertApprox(infantry.moveSpeed, 0.25, 1e-9, "pinned to the slowest member"); this.assertApprox(artillery.moveSpeed, 0.25, 1e-9); state.advanceMovement(1000); this.assertEqual(infantry.coords, pair.b); this.assertEqual(artillery.coords, pair.b); this.assertEqual(state.unitsAt(pair.b).length, 2, "the stack stays together"); } test_a_stack_of_one_type_pathfinds_once_and_shares_the_route() { const state = smallState(); const pair = emptyLandPair(state); const ids = []; for (let i = 0; i < 5; i++) ids.push(state._spawnUnit(pair.a, 0, state.protoUnits[0]).id); const searches = this.countPathSearches(state); this.assertTrue(state.requestGroupMove(ids, pair.b)); this.assertEqual(searches.count, 1, "one search covers the whole stack"); const first = state.findUnit(ids[0]).path; for (const id of ids) { this.assertTrue(state.findUnit(id).path === first, "the stack shares one route"); } } test_a_mixed_stack_pathfinds_once_per_unit_type() { const state = smallState(); const pair = emptyLandPair(state); const ids = [ state._spawnUnit(pair.a, 0, state.protoUnits[0]).id, state._spawnUnit(pair.a, 0, state.protoUnits[0]).id, state._spawnUnit(pair.a, 0, state.protoUnits[2]).id, ]; const searches = this.countPathSearches(state); this.assertTrue(state.requestGroupMove(ids, pair.b)); this.assertEqual(searches.count, 2, "one search per type, not per unit"); } test_a_lone_unit_keeps_its_own_speed() { const state = smallState(); const pair = emptyLandPair(state); const infantry = state._spawnUnit(pair.a, 0, state.protoUnits[0]); state.requestMove(infantry.id, pair.b); this.assertNull(infantry.moveSpeed, "a solo move is not slowed by neighbours"); } test_garrisoned_units_heal_one_hp_per_hour() { const state = smallState(); const city = state.cities.find((c) => c.civ === 0); const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); unit.hp = 100; state.tickHour(); this.assertEqual(unit.hp, 101, "heals inside a friendly city"); for (let i = 0; i < 1000; i++) state.tickHour(); this.assertEqual(unit.hp, unit.maxHp, "never exceeds maximum"); } test_units_in_the_field_do_not_heal() { const state = smallState(); const pair = emptyLandPair(state); const unit = state._spawnUnit(pair.a, 0, state.protoUnits[0]); unit.hp = 100; state.tickHour(); this.assertEqual(unit.hp, 100); } } export class PillageTest extends TestCase { test_pillaging_removes_population_and_heals() { const state = smallState(); const coords = tileOwnedBy(state, 1); this.assertNotNull(coords, "found an enemy tile"); const k = `${coords.x},${coords.y}`; state.tilePopulation.set(k, 100000); const unit = state._spawnUnit(coords, 0, state.protoUnits[0]); unit.hp = 100; const gdpBefore = state.getTileGdpPerCapita(coords); this.assertTrue(state.requestPillage([unit.id])); this.assertApprox(state.tilePopulation.get(k), 90000, 1); this.assertApprox(state.tileGdpPenalty.get(k), 0.7, 1e-9); this.assertApprox(state.getTileGdpPerCapita(coords), gdpBefore * 0.7, 1e-6); this.assertApprox(unit.hp, 110, 1e-9, "heals 1 HP per 1000 people removed"); } test_a_stack_pillages_once_per_unit_weakest_first() { const state = smallState(); const coords = tileOwnedBy(state, 1); const k = `${coords.x},${coords.y}`; state.tilePopulation.set(k, 100000); const strong = state._spawnUnit(coords, 0, state.protoUnits[0]); strong.hp = strong.maxHp - 100; const weak = state._spawnUnit(coords, 0, state.protoUnits[0]); weak.hp = strong.maxHp / 2; this.assertTrue(state.requestPillage([strong.id, weak.id])); this.assertApprox(state.tilePopulation.get(k), 81000, 1, "90% of 90% remains"); this.assertApprox(state.tileGdpPenalty.get(k), 0.49, 1e-9, "70% of 70% remains"); this.assertApprox(weak.hp, strong.maxHp / 2 + 10, 1e-9, "the weakest pillages first"); this.assertApprox(strong.hp, strong.maxHp - 100 + 9, 1e-9, "the second heals for the 9000 it removed"); } test_pillaging_needs_enemy_territory() { const state = smallState(); const city = state.cities.find((c) => c.civ === 0); const unit = state._spawnUnit(city.coords, 0, state.protoUnits[0]); this.assertFalse(state.canPillage([unit.id]), "cannot pillage your own land"); const pair = emptyLandPair(state); const neutral = state._spawnUnit(pair.a, 0, state.protoUnits[0]); if (state.civAt(pair.a) < 0) { this.assertFalse(state.canPillage([neutral.id]), "cannot pillage unclaimed land"); } } test_pillaging_rejects_an_enemy_unit() { const state = smallState(); const coords = tileOwnedBy(state, 1); const enemy = state._spawnUnit(coords, 1, state.protoUnits[0]); this.assertFalse(state.requestPillage([enemy.id])); } }