import { TestCase } from "./framework/test_case.js"; import { smallState, stripPorts } 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_share_a_tile_and_move_at_the_slowest_speed() { 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"); const slow = smallState(); const slowPair = emptyLandPair(slow); const infantry = slow._spawnUnit(slowPair.a, 0, slow.protoUnits[0]); const artillery = slow._spawnUnit(slowPair.a, 0, slow.protoUnits[2]); this.assertTrue(slow.requestGroupMove([infantry.id, artillery.id], slowPair.b)); this.assertApprox(infantry.moveSpeed, 0.25, 1e-9, "pinned to the slowest member"); this.assertApprox(artillery.moveSpeed, 0.25, 1e-9); slow.advanceMovement(1000); this.assertEqual(infantry.coords, slowPair.b); this.assertEqual(artillery.coords, slowPair.b); this.assertEqual(slow.unitsAt(slowPair.b).length, 2, "the stack stays together"); const lone = smallState(); const lonePair = emptyLandPair(lone); const solo = lone._spawnUnit(lonePair.a, 0, lone.protoUnits[0]); lone.requestMove(solo.id, lonePair.b); this.assertNull(solo.moveSpeed, "a solo move is not slowed by neighbours"); } test_group_pathfinding_is_shared_per_unit_type() { 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"); } const mixed = smallState(); const mixedPair = emptyLandPair(mixed); const mixedIds = [ mixed._spawnUnit(mixedPair.a, 0, mixed.protoUnits[0]).id, mixed._spawnUnit(mixedPair.a, 0, mixed.protoUnits[0]).id, mixed._spawnUnit(mixedPair.a, 0, mixed.protoUnits[2]).id, ]; const mixedSearches = this.countPathSearches(mixed); this.assertTrue(mixed.requestGroupMove(mixedIds, mixedPair.b)); this.assertEqual(mixedSearches.count, 2, "one search per type, not per unit"); } test_units_heal_only_when_garrisoned_and_never_past_maximum() { 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"); unit.hp = unit.maxHp - 1; state.tickHour(); this.assertEqual(unit.hp, unit.maxHp, "reaches full health"); state.tickHour(); this.assertEqual(unit.hp, unit.maxHp, "never exceeds maximum"); const field = smallState(); const pair = emptyLandPair(field); const wounded = field._spawnUnit(pair.a, 0, field.protoUnits[0]); wounded.hp = 100; field.tickHour(); this.assertEqual(wounded.hp, 100, "units in the field do not heal"); } } export class PillageTest extends TestCase { test_pillaging_removes_population_heals_and_compounds() { const state = smallState(); // A port adds a flat production figure the pillage does not scale, which // would keep the ratio above 0.7; drop the ports so the test isolates the // pillage penalty. stripPorts(state); 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.tileProductionPenalty.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"); const stack = smallState(); const stackCoords = tileOwnedBy(stack, 1); const stackKey = `${stackCoords.x},${stackCoords.y}`; stack.tilePopulation.set(stackKey, 100000); const strong = stack._spawnUnit(stackCoords, 0, stack.protoUnits[0]); strong.hp = strong.maxHp - 100; const weak = stack._spawnUnit(stackCoords, 0, stack.protoUnits[0]); weak.hp = strong.maxHp / 2; this.assertTrue(stack.requestPillage([strong.id, weak.id])); this.assertApprox(stack.tilePopulation.get(stackKey), 81000, 1, "90% of 90% remains"); this.assertApprox(stack.tileProductionPenalty.get(stackKey), 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_requires_enemy_territory_and_an_own_unit() { 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"); } const enemyCoords = tileOwnedBy(state, 1); const enemy = state._spawnUnit(enemyCoords, 1, state.protoUnits[0]); this.assertFalse(state.requestPillage([enemy.id])); } }