// 08-migrations.md, tested from the 11-tests.md plan: the simple economy // retires only the two *economic* migration mechanisms -- the daily income-ratio // sweep (`_migrateForIncome`) and the opening income-levelling pass // (`_equalizeRegionIncomes`) -- while political/policy flows and natural growth // carry on. The hard model's income migration stays pinned by // `migration_test.js`. import { TestCase } from "./framework/test_case.js"; import { simpleState, smallState } from "./framework/helpers.js"; import { key } from "../shared/hex.js"; import { POLICY_EXPEL } from "../shared/data/politics.js"; // A pair of neighbouring tiles where `from` is owned by `fromCiv` and the // neighbour `to` is owned by a different 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; } // A sparse (so rich) region beside a packed (so poor) one. The hard income // sweep sends people out of the packed region toward the sparse one. function splitRichAndPoor(state) { const cities = state.cities.filter((city) => city.civ === 0); const rich = cities[0]; const poor = cities[1]; 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(); return { rich, poor }; } export class SimpleMigrationTest extends TestCase { // Two regions with very different income do not exchange people: the daily // sweep is a no-op, so thirty days of politics leave every region untouched. test_two_regions_with_different_income_do_not_exchange_people() { const state = simpleState(); splitRichAndPoor(state); const before = state.cities.map((city) => state.regionPopulation(city)); for (let day = 0; day < 30; day++) state._tickPolitics(); 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, "no income-driven movement"); } } // The two overrides themselves are the decision: called directly with a // glaring income gap, neither moves a single person. test_the_income_sweep_and_the_levelling_pass_are_no_ops() { const state = simpleState(); const { rich, poor } = splitRichAndPoor(state); let moved = 0; const realMove = state._movePopulation.bind(state); state._movePopulation = (...args) => { moved += 1; return realMove(...args); }; state._migrateForIncome(); this.assertEqual(moved, 0, "the income sweep moved nobody"); const before = state.cities.map((city) => state.regionPopulation(city)); state._equalizeRegionIncomes(); 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, "the levelling pass moved nobody"); } // The gap is still there: nothing quietly flattened it. this.assertGreater( state.regionPopulation(poor), state.regionPopulation(rich), "the regions keep their different sizes" ); delete state._movePopulation; } // Natural population growth is not migration and must survive: the simple // model only removes the income flows. test_natural_growth_still_applies() { const state = simpleState(); const before = state.getPlayerPopulation(0); state._tickPopulation(); this.assertGreater(state.getPlayerPopulation(0), before, "a day of natural growth"); } // Option A keeps political migration in the simple economy: the income sweep // is a no-op, but an expelling nation still drives a people across the border. test_political_migration_still_applies() { const state = simpleState(); const border = borderPair(state, 0, 1); this.assertNotNull(border, "the two nations share a border"); state._setTileEthnicFractions(border.from, new Map([[0, 0.6], [1, 0.4]])); state._setTileEthnicFractions(border.to, new Map([[1, 0.9], [0, 0.1]])); this.assertTrue(state.requestPolicy(0, POLICY_EXPEL, 1)); state._tickMigration(); const leaving = state.migrations.get(0); this.assertNotNull(leaving, "the expelling country has a flow"); this.assertGreater(leaving.outbound.get(1) || 0, 0, "people still leave under a policy"); } // The migration snapshot fields stay present and empty rather than going // missing, so the client's migration graph keeps its shape. test_the_migration_snapshot_is_present_and_empty() { const state = simpleState(); const snap = state.snapshot(0); this.assertHas(snap, "migrations"); this.assertEqual(snap.migrations.length, state.civilisations.length, "one entry per nation"); this.assertHas(snap, "migrationGraph"); this.assertEmpty(snap.migrationGraph.links, "the opening graph draws no flows"); } // The hard model is the control: the same sparse-next-to-packed setup does // send people to the rich region, so the simple no-op is a real difference. test_the_hard_model_still_moves_people_for_income() { const state = smallState(); const { rich, poor } = splitRichAndPoor(state); const richBefore = state.regionPopulation(rich); const poorBefore = state.regionPopulation(poor); state._migrateForIncome(); this.assertGreater(state.regionPopulation(rich), richBefore, "the hard rich region gained"); this.assertLess(state.regionPopulation(poor), poorBefore, "the hard poor region lost"); } }