Files
Battle-for-Tismo/tests/stacking_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

185 lines
7.3 KiB
JavaScript

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_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();
// 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");
}
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.tileProductionPenalty.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]));
}
}