Files
Battle-for-Tismo/tests/migration_test.js
T

278 lines
12 KiB
JavaScript

import { TestCase } from "./framework/test_case.js";
import { smallState, cityOf } from "./framework/helpers.js";
import { key } from "../shared/hex.js";
import { POLICY_EXPEL, MIGRATION } from "../shared/data/politics.js";
// A pair of neighbouring tiles where `from` is owned by `fromCiv` and the
// neighbour `to` is owned by another civilisation.
function borderPair(state, fromCiv, toCiv) {
for (const coords of state._territoryByCiv.get(fromCiv) || []) {
for (const neighbour of state._neighbours(coords)) {
if (state.territory.get(key(neighbour.x, neighbour.y)) === toCiv) {
return { from: coords, to: neighbour };
}
}
}
return null;
}
export class MigrationTest extends TestCase {
test_food_output_comes_from_the_land_not_the_headcount() {
const state = smallState();
const city = cityOf(state, 0);
const before = state._regionProductionValue(city);
// Pack many more people onto the same land: the harvest must not change.
for (const coords of state.regionTiles(city)) {
const k = key(coords.x, coords.y);
state.tilePopulation.set(k, (state.tilePopulation.get(k) || 0) * 4 + 1);
}
state._gdpPerCapitaCache.clear();
const after = state._regionProductionValue(city);
this.assertApprox(after, before, 1e-6, "the land yields the same however many live on it");
}
test_regions_of_a_nation_open_with_equal_income_per_head() {
const state = smallState();
for (let civ = 0; civ < state.civilisations.length; civ++) {
const cities = state.cities.filter((city) => city.civ === civ);
if (cities.length < 2) continue;
const perHead = cities.map((city) => {
const economy = state.getCityEconomy(city);
return economy.population > 0 ? economy.gdp / economy.population : 0;
});
const highest = Math.max(...perHead);
const lowest = Math.min(...perHead);
this.assertTrue(
highest > 0 && (highest - lowest) / highest <= 0.02,
`regions of civ ${civ} open level`
);
}
}
test_moving_people_conserves_the_total() {
const state = smallState();
const cities = state.cities.filter((city) => city.civ === 0);
this.assertGreaterOrEqual(cities.length, 2, "the fixture has two regions");
const from = cities[0];
const to = cities[1];
const fromBefore = state.regionPopulation(from);
const toBefore = state.regionPopulation(to);
const moved = state._movePopulation(from, to, 100_000, false);
this.assertApprox(moved, 100_000, 1);
this.assertApprox(state.regionPopulation(from), fromBefore - moved, 1e-6);
this.assertApprox(state.regionPopulation(to), toBefore + moved, 1e-6);
}
test_people_move_toward_the_richer_region() {
const state = smallState();
const cities = state.cities.filter((city) => city.civ === 0);
const rich = cities[0];
const poor = cities[1];
// Dense on one side, sparse on the other: with land-based food that makes
// the sparse region the rich one.
for (const coords of state.regionTiles(rich)) {
state.tilePopulation.set(key(coords.x, coords.y), 1_000);
}
for (const coords of state.regionTiles(poor)) {
state.tilePopulation.set(key(coords.x, coords.y), 5_000_000);
}
state._gdpPerCapitaCache.clear();
const richBefore = state.regionPopulation(rich);
const poorBefore = state.regionPopulation(poor);
state._migrateForIncome();
this.assertGreater(state.regionPopulation(rich), richBefore, "people moved to the rich region");
this.assertLess(state.regionPopulation(poor), poorBefore, "and left the poor one");
}
test_a_region_only_moves_to_a_much_richer_one() {
const state = smallState();
// Migration levelled the regions at generation, so no one is twice as rich
// as anyone else and nobody moves.
const before = state.cities.map((city) => state.regionPopulation(city));
state._migrateForIncome();
const after = state.cities.map((city) => state.regionPopulation(city));
for (let i = 0; i < before.length; i++) {
this.assertApprox(after[i], before[i], 1e-6, "nobody moved to a merely equal region");
}
}
test_an_airport_caps_its_daily_intake() {
const state = smallState();
const cities = state.cities;
const from = cityOf(state, 0);
const to = cities.find((city) => city.civ === 1);
// Every region is dense and equally poor except `to`, which is sparse and
// so far richer. Only `to` is worth moving to.
for (const city of cities) {
const people = city.id === to.id ? 1_000 : 5_000_000;
for (const coords of state.regionTiles(city)) {
state.tilePopulation.set(key(coords.x, coords.y), people);
}
}
state._gdpPerCapitaCache.clear();
const airport = state.protoBuildings.findIndex((b) => b.id === "airport");
from.buildings[airport] = 1;
to.buildings[airport] = 1;
const fromBefore = state.regionPopulation(from);
state._migrateForIncome();
const lost = fromBefore - state.regionPopulation(from);
this.assertGreater(lost, 0, "people flew to the rich region");
// 1000 people per airport level a day, and the route's level is 1.
this.assertTrue(lost <= 1_001, `the flight carried at most its capacity, lost ${lost}`);
}
test_the_migration_graph_tallies_city_to_city_flows() {
const state = smallState();
const from = cityOf(state, 0);
const to = state.cities.find((city) => city.id !== from.id);
this.assertNotNull(from, "the fixture has an origin");
this.assertNotNull(to, "and a destination");
const moved = state._movePopulation(from, to, 100_000, true);
this.assertGreater(moved, 0, "people moved");
const graph = state.snapshot(0).migrationGraph;
this.assertHas(graph, "links");
const link = graph.links.find(
(entry) =>
entry[0] === from.coords.x && entry[1] === from.coords.y &&
entry[2] === to.coords.x && entry[3] === to.coords.y
);
this.assertNotNull(link, "the city-to-city flow was tallied");
this.assertApprox(link[4], moved, 1, "the link carries everyone who moved");
this.assertEqual(link[5], from.civ, "the link names the origin nation for its flag");
}
test_the_migration_graph_rolls_over_a_month() {
const state = smallState();
const from = cityOf(state, 0);
const to = state.cities.find((city) => city.id !== from.id);
state._movePopulation(from, to, 100_000, true);
this.assertNotEmpty(state.snapshot(0).migrationGraph.links, "the flow is on the graph");
// Each day archives the last, so a month's worth of empties eventually
// pushes the only flow out of the rolling window.
for (let day = 0; day < 31; day++) state._tickMigration();
this.assertEmpty(state.snapshot(0).migrationGraph.links, "the month-old flow has fallen off");
}
test_internal_equalisation_does_not_pollute_the_migration_graph() {
// The start-of-game levelling moved people with recording off, so the
// population map opens with no migration arrows.
const state = smallState();
this.assertEmpty(state.snapshot(0).migrationGraph.links, "the opening graph is empty");
}
test_no_migration_into_a_country_you_are_at_war_with() {
const state = smallState();
const border = borderPair(state, 0, 1);
this.assertNotNull(border, "the two nations share a border");
const fromK = key(border.from.x, border.from.y);
const toK = key(border.to.x, border.to.y);
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
state.tilePopulation.set(fromK, 1_000_000);
state.tilePopulation.set(toK, 1_000_000);
// Peacetime: the people cross the border.
this.assertGreater(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "migration in peace");
// War: the same move is refused.
state._setTileEthnicFractions(border.from, new Map([[0, 1]]));
state._setTileEthnicFractions(border.to, new Map([[0, 1]]));
this.assertTrue(state.requestDeclareWar(0, 1));
this.assertEqual(state._transferEthnicity(fromK, toK, 0, 0.5), 0, "no crossing into a hostile land");
}
test_expelling_a_people_keeps_them_from_moving_in() {
const state = smallState();
const border = borderPair(state, 0, 1);
this.assertNotNull(border, "the two nations share a border");
const fromK = key(border.from.x, border.from.y);
const toK = key(border.to.x, border.to.y);
state._setTileEthnicFractions(border.from, new Map([[1, 1]]));
state._setTileEthnicFractions(border.to, new Map([[1, 1]]));
state.tilePopulation.set(fromK, 1_000_000);
state.tilePopulation.set(toK, 1_000_000);
// Enact the expel policy directly: the request guard would refuse a people
// that is this large a share of the expelling nation.
state.policies.get(1).push({ id: 999, type: POLICY_EXPEL, ethnicity: 1 });
this.assertTrue(state._expelsEthnicity(1, 1), "nation 1 expels people 1");
this.assertEqual(state._transferEthnicity(fromK, toK, 1, 0.5), 0, "the expelled people stay out");
}
test_a_tile_cannot_lose_more_than_its_daily_migration_share() {
const state = smallState();
const cities = state.cities.filter((city) => city.civ === 0);
this.assertGreaterOrEqual(cities.length, 2, "two regions to move between");
const from = cities[0];
const to = cities[1];
for (const coords of state.regionTiles(from)) {
state.tilePopulation.set(key(coords.x, coords.y), 1_000_000);
}
// Open the daily budget the migration tick runs under.
state._migrationOutDay = new Map();
const before = state.regionPopulation(from);
const moved = state._movePopulation(from, to, 5_000_000, false);
const cap = before * MIGRATION.maxDailyOutShare;
this.assertTrue(moved <= cap + 1, `moved ${moved} within the ${cap} cap`);
state._migrationOutDay = null;
}
// The income sweep is the day's region-by-region GDP rebuild, so its result is
// kept until a counter it reads moves. Identity proves whether it was reused,
// the same way `trade_graph_test` checks the trade walk.
test_the_region_income_walk_is_reused_until_its_counters_move() {
const state = smallState();
const first = state._regionIncomePerCapita(state.cities);
const again = state._regionIncomePerCapita(state.cities);
this.assert(first === again, "an unchanged world reuses the cached income");
state._populationVersion += 1;
const rebuilt = state._regionIncomePerCapita(state.cities);
this.assert(first !== rebuilt, "a population change throws the cached income away");
}
// A world where only one region is rich sends every poorer region looking for
// a destination, so the sweep runs its whole O(cities^2) search. The airport
// level each pair reads is gathered once per city, not looked up per pair.
test_the_migration_sweep_reads_each_airport_level_once_per_city() {
const state = smallState();
const rich = cityOf(state, 0);
for (const city of state.cities) {
const people = city === rich ? 1_000 : 5_000_000;
for (const coords of state.regionTiles(city)) {
state.tilePopulation.set(key(coords.x, coords.y), people);
}
}
state._gdpPerCapitaCache.clear();
const original = state._cityMechanicLevel.bind(state);
let calls = 0;
state._cityMechanicLevel = (...args) => {
calls += 1;
return original(...args);
};
state._migrateForIncome();
state._cityMechanicLevel = original;
this.assertTrue(
calls <= state.cities.length,
`each city's airport level was read at most once (read ${calls})`
);
}
// A move too small to change any tile's stored population is still a real
// request for the caller, but it must not churn the version every population
// memo (and the GDP cache) hangs off.
test_a_move_too_small_to_round_a_tile_does_not_churn_the_population_version() {
const state = smallState();
const from = cityOf(state, 0);
const to = state.cities.find((city) => city.id !== from.id);
const before = state._populationVersion;
// The smallest positive double leaves every tile's population exactly as it
// was.
state._movePopulation(from, to, Number.MIN_VALUE, false);
this.assertEqual(
state._populationVersion,
before,
"a move that rounds away does not bump the population version"
);
}
}