Cut the migration sweep's repeated lookups and population churn
This commit is contained in:
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user