Cut the migration sweep's repeated lookups and population churn

This commit is contained in:
2026-09-24 20:48:12 +02:00
parent a219cf650f
commit afb23df669
4 changed files with 101 additions and 13 deletions
+6 -6
View File
@@ -1,6 +1,12 @@
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
// The pre-commit hook refreshes it so the main menu shows the latest commits.
export const DEVLOG = [
{
"hash": "a219cf6",
"date": "2026-09-24",
"subject": "Flew aircraft and missiles straight and let strikes target fogged cities",
"body": ""
},
{
"hash": "36ac669",
"date": "2026-09-24",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-23",
"subject": "Gave the tax boxes a stepper and kept edits from the snapshot",
"body": ""
},
{
"hash": "965f0bf",
"date": "2026-09-23",
"subject": "Drew the cylindrical map as a repeating world",
"body": ""
}
];
+3
View File
@@ -234,6 +234,9 @@ export class GameState {
// ethnicity, territory or migration deltas move (see politics.js).
this._civEthnicMakeupCache = new Map();
this._cityEthnicMakeupCache = new Map();
// The full per-region income-per-head map `_migrateForIncome` builds each
// day, kept until a counter its walk reads moves (see politics.js).
this._regionIncomeCache = null;
this._nextUnitId = 1;
this._nextCityId = 1;
+31 -7
View File
@@ -744,10 +744,14 @@ export const politicsMethods = {
_migrateForIncome() {
if (this.cities.length < 2) return;
const income = this._regionIncomePerCapita(this.cities);
// Each airport's daily intake, shared among every region that flies into it.
// Each airport's level and daily intake. The inner sweep reads them for
// every ordered pair, so they are gathered once here instead of walking
// every city's buildings again for each pair.
const airLevel = new Map();
const airBudget = new Map();
for (const city of this.cities) {
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_IMMIGRATION);
airLevel.set(city.id, level);
if (level > 0) airBudget.set(city.id, MIGRATION.airPeoplePerDayPerLevel * level);
}
for (const from of this.cities) {
@@ -763,7 +767,7 @@ export const politicsMethods = {
(income.get(other.id) || 0) >= fromIncome * MIGRATION.incomeRatioBase
);
if (!worthwhile) continue;
const fromAirport = this._cityMechanicLevel(from, BUILDING_MECHANIC.AIR_IMMIGRATION);
const fromAirport = airLevel.get(from.id) || 0;
const travel = this._migrationTravel(from);
for (const to of this.cities) {
if (to === from || source <= 0) continue;
@@ -771,7 +775,7 @@ export const politicsMethods = {
if (toIncome <= 0) continue;
// Two nations at war do not trade people.
if (this.isAtWar(from.civ, to.civ)) continue;
const toAirport = this._cityMechanicLevel(to, BUILDING_MECHANIC.AIR_IMMIGRATION);
const toAirport = airLevel.get(to.id) || 0;
const air = fromAirport > 0 && toAirport > 0;
const hours = air ? MIGRATION.airHours : travel.get(to.id);
if (hours === undefined) continue;
@@ -848,13 +852,24 @@ export const politicsMethods = {
// The production per head of every region, as the economy shows it. The
// nation-wide balance is folded in here too, so two regions in different
// countries compare on the same footing.
// countries compare on the same footing. The walk is the day's region-by-
// region GDP rebuild, so its result is kept until a counter it reads moves:
// population, territory, the region map, buildings/ports (modifiers) or a
// GDP-figure regeneration (`_gdpEpoch`, bumped by the daily natural-growth
// reset and by any tile edit that drops the per-tile figure).
_regionIncomePerCapita(cities) {
const stamp = `${this._gdpEpoch}:${this._populationVersion}:${this._territoryVersion}` +
`:${this._regionVersion}:${this._modifiersVersion}`;
const cached = this._regionIncomeCache;
// The cache is the full-world map `_migrateForIncome` asks for; a caller
// passing a subset gets a fresh walk rather than a wrongly-narrowed one.
if (cities === this.cities && cached && cached.stamp === stamp) return cached.value;
const result = new Map();
for (const city of cities) {
const economy = this.getCityEconomy(city);
result.set(city.id, economy.population > 0 ? economy.gdp / economy.population : 0);
}
if (cities === this.cities) this._regionIncomeCache = { stamp, value: result };
return result;
},
@@ -898,10 +913,13 @@ export const politicsMethods = {
const totalMix = Array.from(mix.values()).reduce((sum, value) => sum + value, 0) || 1;
for (const [eth, value] of mix) mix.set(eth, value / totalMix);
let populationChanged = false;
for (const entry of source) {
const people = this.tilePopulation.get(entry.k) || 0;
const leave = moved * people / totalFrom;
this.tilePopulation.set(entry.k, people - leave);
const after = people - leave;
if (after !== people) populationChanged = true;
this.tilePopulation.set(entry.k, after);
if (this._migrationOutDay && leave > 0) {
this._migrationOutDay.set(entry.k, (this._migrationOutDay.get(entry.k) || 0) + leave);
}
@@ -911,6 +929,7 @@ export const politicsMethods = {
const people = this.tilePopulation.get(entry.k) || 0;
const add = totalTo > 0 ? moved * people / totalTo : moved / dest.length;
const after = people + add;
if (after !== people) populationChanged = true;
this.tilePopulation.set(entry.k, after);
const shares = new Map(this.tileEthnicity.get(entry.k) || []);
for (const [eth, share] of mix) {
@@ -919,8 +938,13 @@ export const politicsMethods = {
}
this._setTileEthnicFractions(parseKey(entry.k), shares);
}
this._populationVersion += 1;
this._clearTileGdpCache();
// A move too small to change any tile's stored population still updates the
// ethnic mix, but it must not churn `_populationVersion` and every memo keyed
// on it (GDP included) for a no-op.
if (populationChanged) {
this._populationVersion += 1;
this._clearTileGdpCache();
}
// Keep each city's headline population in step with its city tile.
for (const city of [fromCity, toCity]) {
city.population = Math.round(
+61
View File
@@ -213,4 +213,65 @@ export class MigrationTest extends TestCase {
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"
);
}
}