278 lines
12 KiB
JavaScript
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"
|
|
);
|
|
}
|
|
}
|