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