import { TestCase } from "./framework/test_case.js"; import { smallState, cityOf, stripPorts } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; import { TRENCH } from "../shared/data/combat.js"; const BIG_BUDGET = 1.0e15; function emptyLandPair(state) { 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; return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; } } return null; } function ownLand(state, civ = 0) { for (const cell of state.landCells) { if (state.civAt(cell) !== civ) continue; if (state.unitAt(cell) || state.cityAt(cell)) continue; return cell; } return null; } export class TrenchTest extends TestCase { test_infantry_dig_in_on_their_own_land() { const state = smallState(); const coords = ownLand(state); this.assertNotNull(coords, "the nation holds land"); const infantry = state._spawnUnit(coords, 0, state.protoUnits[0]); this.assertTrue(state.requestDigTrench([infantry.id])); this.assertTrue(state.trenches.has(key(coords.x, coords.y))); this.assertFalse(state.requestDigTrench([infantry.id]), "already dug in"); } test_only_infantry_can_dig() { const state = smallState(); const coords = ownLand(state); const artillery = state._spawnUnit(coords, 0, state.protoUnits[2]); this.assertFalse(state.requestDigTrench([artillery.id])); this.assertFalse(state.trenches.has(key(coords.x, coords.y))); } test_trenches_multiply_defense_and_cut_gdp() { const state = smallState(); // A seeded port adds flat GDP that the trench multiplier would not scale. stripPorts(state, 0); const coords = ownLand(state); const k = key(coords.x, coords.y); const before = state.getTileGdpPerCapita(coords); state.trenches.add(k); state._gdpPerCapitaCache.delete(k); this.assertApprox( state.getTileGdpPerCapita(coords), before * TRENCH.productionMultiplier, 1e-6, "a trench costs the tile a fifth of its GDP per capita" ); // Infantry attackers are blunted partly; artillery far more. const infantryProto = state.protoUnits[0]; const artilleryProto = state.protoUnits[2]; this.assertApprox( state._tileFortificationMultiplier(coords, infantryProto), TRENCH.meleeDefenseMultiplier, 1e-9 ); this.assertApprox( state._tileFortificationMultiplier(coords, artilleryProto), TRENCH.artilleryDefenseMultiplier, 1e-9 ); } } export class CaptureTest extends TestCase { test_infantry_captures_unaccompanied_artillery() { const state = smallState(); const pair = emptyLandPair(state); const artillery = state._spawnUnit(pair.a, 0, state.protoUnits[2]); const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]); // A gun is usually spiked when taken; force the one-in-five capture. state._random = () => 0.9; this.assertTrue(state.requestMove(infantry.id, pair.a)); state.advanceMovement(1000); this.assertEqual(artillery.civ, 1, "the battery changed hands"); } test_infantry_beside_the_artillery_protects_it() { const state = smallState(); const pair = emptyLandPair(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)); state.advanceMovement(1000); this.assertEqual(artillery.civ, 0, "the artillery is not captured while sheltered"); } test_walking_onto_a_radar_tower_captures_it() { const state = smallState(); const pair = emptyLandPair(state); const k = key(pair.a.x, pair.a.y); state.tileImprovements.set(k, "radar_tower"); state.tileImprovementOwner.set(k, 0); state.tileImprovementHp.set(k, 500); const infantry = state._spawnUnit(pair.b, 1, state.protoUnits[0]); this.assertTrue(state.requestMove(infantry.id, pair.a)); state.advanceMovement(1000); this.assertEqual(state.tileImprovementOwner.get(k), 1, "the radar tower changed hands"); this.assertTrue(state.tileImprovements.has(k), "the tower is captured, not destroyed"); } test_capturing_a_city_takes_its_aircraft() { const state = smallState(); const city = cityOf(state, 1); const airport = state.protoBuildings.findIndex((b) => b.id === "airport"); city.buildings[airport] = 1; const jet = state._spawnUnit( city.coords, 1, state.protoUnits.find((u) => u.id === "jet_fighter"), city.id ); // Aircraft are usually destroyed with the field; force the one-in-five // chance that the plane is taken intact. state._random = () => 0.9; state._captureCity(city, 0); this.assertEqual(jet.civ, 0, "the aircraft is captured with the city"); this.assertEqual(jet.homeCityId, city.id); } } export class TileImprovementTest extends TestCase { test_a_radar_tower_takes_time_to_build() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = ownLand(state); const k = key(coords.x, coords.y); this.assertTrue(state.requestBuildTileImprovement(0, coords, "radar_tower")); this.assertFalse(state.tileImprovements.has(k), "not raised instantly"); for (let i = 0; i < 100000 && !state.tileImprovements.has(k); i++) state.advanceHour(); this.assertEqual(state.tileImprovements.get(k), "radar_tower", "the work completes"); this.assertEqual(state.tileImprovementOwner.get(k), 0); } test_a_coastal_cannon_needs_the_sea() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); // A coastal preference can put every owned tile on the shore, so reach for // a landlocked tile explicitly. const inland = state.landCells.find((cell) => state.civAt(cell) === 0 && !state.unitAt(cell) && !state.cityAt(cell) && !state.isCoastalCoords(cell) ); this.assertNotNull(inland, "the fixture has an inland tile"); this.assertFalse( state.requestBuildTileImprovement(0, inland, "coastal_cannon"), "an inland tile cannot host a coastal cannon" ); } test_improvements_cost_an_hourly_maintenance() { const state = smallState(); state.budgets.set(0, BIG_BUDGET); const coords = ownLand(state); const k = key(coords.x, coords.y); const before = state.getTileImprovementUpkeep(0); state.tileImprovements.set(k, "fortifications"); state.tileImprovementOwner.set(k, 0); state.tileImprovementHp.set(k, 2000); this.assertGreater(state.getTileImprovementUpkeep(0), before, "forts carry upkeep"); } test_fortifications_hold_permanent_control() { const state = smallState(); const city = cityOf(state, 0); // A tile right beside the city: without the fort it would fall with it. const coords = state.topology .neighbours(city.coords.x, city.coords.y) .find((n) => state._isLand(n) && state.civAt(n) === 0 && !state.unitAt(n) && !state.cityAt(n)); this.assertNotNull(coords, "the city borders a tile of its own land"); const k = key(coords.x, coords.y); state.tileImprovements.set(k, "fortifications"); state.tileImprovementOwner.set(k, 0); state.tileImprovementHp.set(k, 2000); state._captureCity(city, 1); this.assertEqual(state.civAt(coords), 0, "the fort's area of control never changes hands"); } test_a_coastal_cannon_fires_on_a_warship() { const state = smallState(); const coastal = state.landCells.find((cell) => state.civAt(cell) === 0 && state.topology.neighbours(cell.x, cell.y).some( (n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea" ) ); this.assertNotNull(coastal, "the nation holds a coastal tile"); const sea = state.topology.neighbours(coastal.x, coastal.y).find( (n) => state.tiles[key(n.x, n.y)].terrainClass === "Sea" ); const k = key(coastal.x, coastal.y); state.tileImprovements.set(k, "coastal_cannon"); state.tileImprovementOwner.set(k, 0); state.tileImprovementHp.set(k, 800); const warship = state._spawnUnit( sea, 1, state.protoUnits.find((u) => u.id === "battleship") ); const hp = warship.hp; state._tickCoastalCannons(); this.assertLess(warship.hp, hp, "the cannon hits the warship"); this.assertTrue(state.tileImprovements.has(k), "the cannon survives (only fires)"); } } export class TransportTest extends TestCase { coastalSetup(state) { for (const cell of state.landCells) { if (state.civAt(cell) !== 0) continue; for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { if (state.tiles[key(neighbour.x, neighbour.y)].terrainClass !== "Sea") continue; if (state.unitAt(cell) || state.unitAt(neighbour)) continue; return { land: { x: cell.x, y: cell.y }, sea: { x: neighbour.x, y: neighbour.y } }; } } return null; } test_infantry_board_a_launch_and_are_carried() { const state = smallState(); const spot = this.coastalSetup(state); this.assertNotNull(spot, "the nation has a coastal tile"); const launchProto = state.protoUnits.find((u) => u.id === "launch"); const launch = state._spawnUnit(spot.sea, 0, launchProto); const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]); this.assertTrue(state.requestEmbark([infantry.id], launch.id), "boarding begins"); for (let i = 0; i < 24; i++) state.advanceHour(); this.assertNull(state.findUnit(infantry.id), "the infantry left the map"); this.assertEqual(launch.cargo.length, 1, "the infantry is cargo"); } test_a_spy_can_board_a_launch_and_go_ashore() { const state = smallState(); const spot = this.coastalSetup(state); this.assertNotNull(spot, "the nation has a coastal tile"); const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch")); const spyProto = state.protoUnits.find((u) => u.id === "spy"); const spy = state._spawnUnit(spot.land, 0, spyProto); this.assertTrue(state.requestEmbark([spy.id], launch.id), "a spy may board"); for (let i = 0; i < 24; i++) state.advanceHour(); this.assertNull(state.findUnit(spy.id), "the spy left the map"); this.assertEqual(launch.cargo.length, 1, "the spy is cargo"); this.assertTrue(state.requestDisembark([launch.id], spot.land), "the spy goes ashore"); this.assertTrue( state.unitsAt(spot.land).some((unit) => unit.proto === state.protoUnits.indexOf(spyProto)), "the spy stands on the shore" ); } test_a_worker_stays_ashore() { const state = smallState(); const spot = this.coastalSetup(state); const launch = state._spawnUnit(spot.sea, 0, state.protoUnits.find((u) => u.id === "launch")); const worker = state._spawnUnit(spot.land, 0, state.protoUnits.find((u) => u.id === "worker")); this.assertFalse(state.requestEmbark([worker.id], launch.id), "only infantry and spies board"); } test_a_loaded_launch_disembarks_its_stack() { const state = smallState(); const spot = this.coastalSetup(state); const launchProto = state.protoUnits.find((u) => u.id === "launch"); const launch = state._spawnUnit(spot.sea, 0, launchProto); const infantry = state._spawnUnit(spot.land, 0, state.protoUnits[0]); state.requestEmbark([infantry.id], launch.id); for (let i = 0; i < 24; i++) state.advanceHour(); this.assertTrue(state.requestDisembark([launch.id], spot.land), "the troops go ashore"); this.assertEqual(launch.cargo.length, 0, "the launch is empty"); this.assertGreater(state.unitsAt(spot.land).length, 0, "a unit stands on the shore"); } } export class ZoneOfControlTest extends TestCase { // Two neighbouring, unclaimed land tiles, so terrain and ownership never // interfere with the zone-of-control check under test. neutralPair(state) { for (const cell of state.landCells) { if (state.civAt(cell) !== 0) continue; for (const neighbour of state.topology.neighbours(cell.x, cell.y)) { if (!state._isLand(neighbour) || state.civAt(neighbour) !== 0) continue; if (state.unitAt(cell) || state.unitAt(neighbour)) continue; return { a: { x: cell.x, y: cell.y }, b: { x: neighbour.x, y: neighbour.y } }; } } return null; } test_a_unit_cannot_cross_between_two_denied_tiles() { const state = smallState(); const pair = this.neutralPair(state); this.assertNotNull(pair, "the map has two adjacent neutral land tiles"); const mover = state._spawnUnit(pair.a, 0, state.protoUnits[0]); // The hostile is only denied by the artificial zone, not by occupancy. const zoc = new Set([key(pair.a.x, pair.a.y), key(pair.b.x, pair.b.y)]); this.assertFalse( state._canUnitEnter(mover, pair.b, false, -1, null, zoc, pair.a), "denied to denied is blocked" ); this.assertTrue( state._canUnitEnter(mover, pair.b, false, -1, null, new Set([key(pair.b.x, pair.b.y)]), pair.a), "entering the ring from outside is allowed" ); this.assertTrue( state._canUnitEnter(mover, pair.b, true, -1, null, zoc, pair.a), "a deliberate attack on the goal is allowed" ); } test_a_hostile_unit_denies_its_tile_and_neighbours() { const state = smallState(); const pair = emptyLandPair(state); state._spawnUnit(pair.a, 1, state.protoUnits[0]); const zoc = state._zoneOfControl(0); this.assertTrue(zoc.has(key(pair.a.x, pair.a.y)), "the hostile's tile is denied"); for (const neighbour of state.topology.neighbours(pair.a.x, pair.a.y)) { this.assertTrue(zoc.has(key(neighbour.x, neighbour.y)), "each neighbour is denied"); } } }