Files
Battle-for-Tismo/tests/warfare_test.js
T
adrien 6f73e2cc51 Reworked the world around a fixed sea share and settled coasts
World generation now aims at a fixed share of the ice-free tiles (60%) instead of a minimum continent count, bisecting the water level for it rather than sweeping every step, and still joins the ocean into one body. Ice and tundra are the outermost rows only, scaled down on small maps so the fixtures keep usable land.

Cities score their site, coastal plains best and landlocked worst, avoiding tundra and desert. Every nation that holds a coast gets a port city on each island it owns, and any landmass left empty is settled by the nearest nation, so no land starts unowned. A coastal city opens with a port already built.

Bundles the in-progress work in the tree: the commodity monthly price series and inflation the central bank's chart reads, and the rolling city-to-city migration graph the population map draws as flow arrows.
2026-09-22 17:00:38 +02:00

306 lines
12 KiB
JavaScript

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]);
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
);
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_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");
}
}
}