From afb23df6692024ee288b0aff44b53ad4a7bcf578 Mon Sep 17 00:00:00 2001 From: Adrien Jaguenet Date: Thu, 24 Sep 2026 20:48:12 +0200 Subject: [PATCH] Cut the migration sweep's repeated lookups and population churn --- client/js/devlog_data.js | 12 +++---- shared/game_state.js | 3 ++ shared/game_state/politics.js | 38 ++++++++++++++++++---- tests/migration_test.js | 61 +++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index e49619b..b0c92d3 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -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": "" } ]; diff --git a/shared/game_state.js b/shared/game_state.js index 34b85aa..9f591ac 100644 --- a/shared/game_state.js +++ b/shared/game_state.js @@ -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; diff --git a/shared/game_state/politics.js b/shared/game_state/politics.js index bd72aaf..dcee332 100644 --- a/shared/game_state/politics.js +++ b/shared/game_state/politics.js @@ -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( diff --git a/tests/migration_test.js b/tests/migration_test.js index 8430ede..ccc6a04 100644 --- a/tests/migration_test.js +++ b/tests/migration_test.js @@ -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" + ); + } }