7.6 KiB
7.6 KiB
Politics tick performance — design brief
A working document for a session on making the hourly politics/migration tick cheap in a large, settled world.
1. Shared project context (read this first)
Battle for 'Tismo is an HTML5 port of a turn-of-the-millennium strategy game. Pure JavaScript, no build step, no runtime dependencies.
- Server-authoritative.
shared/game_state.js(GameState) is a framework-free model, ticked hourly byserver/server.js; the browser only renders snapshots. - Shared logic runs in Node and the browser.
shared/stays framework-free (no Node, no DOM). - GameState is mixins.
shared/game_state.jsimports eachshared/game_state/<topic>.jsand adds its methods to the prototype. - Version counters are how expensive derived data is memoised on the snapshot
and tick paths:
_gdpEpoch,_populationVersion,_territoryVersion,_modifiersVersion,_tileImprovementVersion,_visibleVersion,_ethnicityVersion,_regionVersion, and the trade graph's own stamp. - The trade graph is the reference pattern: a walk from each city floods the
ocean (a sea lane runs any distance), so
_tradeGraphis cached across ticks and days under a stamp built from the road/railway, territory and storage-node counters. Any network change rebuilds lazily; a quiet world reuses it. The tile-improvement version is deliberately not in the stamp. - Do not memoise
getCityEconomyoutside a snapshot — a test pins that a simulation read reflects a directtilePopulationedit immediately.
Commands
- Install once:
npm install. - One suite:
node tests/run_tests.js --file <name>.js. Never the full suite during development — the commit hook runs it. - Time a settle:
node server/server.js --port 27015 --bind 127.0.0.1logs how many days the warm-up took (and how long).--warmup 0skips it.
Conventions
- 2-space indent; no new libraries.
- Tests in
tests/<topic>_test.jsextendingTestCase. Force RNG withstate._random = () => value. - Commit only when asked; one imperative sentence; the hook runs the full suite.
2. The problem (from ROADMAP.md)
- Faster politics tick: with the trade-graph walks cached,
_tickPoliticsis the largest remaining cost in a settled world —_migrateForIncomethrough_regionIncomePerCapitaand_movePopulation, and the repeated_cityMechanicLevellookups each of them makes. Memoise the per-region income the way the trade graph is now kept, and let a migration sweep visit only the regions whose pull actually changed.
3. Where the time goes
shared/game_state/politics.js:
_tickPolitics()(line 71) runs hourly and calls, among others,_migrateForIncome()._migrateForIncome()(744) computes_regionIncomePerCapita(this.cities)(852) and then, per city,_cityMechanicLevel(city, AIR_IMMIGRATION)and nested loops over origin/destination cities, calling_movePopulation(865) which bumps_populationVersion(922)._regionIncomePerCapita(852) is the expensive walk; it recomputes income for every region on every tick even when nothing that feeds it changed._cityMechanicLevelis called repeatedly for the same city/building within one tick.- Some politics walks are already memoised (
politics.js:222,:251use_ethnicityVersion:_populationVersion:_territoryVersion[: _regionVersion]stamps), so there is a local idiom to follow.
4. Design space (settle before coding)
- Memoise
_regionIncomePerCapitaunder a stamp of the counters that can change a region's per-capita income — likely_populationVersion,_territoryVersion,_ethnicityVersion,_gdpEpoch,_modifiersVersion, and the building/mechanic version (check what_regionIncomePerCapitaactually reads). Keep the stamp minimal: the trade-graph lesson is that folding in a fast-changing counter throws the cache away constantly. Rebuild lazily on demand. - Visit only changed regions. If a region's "pull" (income, or the migration allowance) did not change, skip its whole migration sweep. Needs a per-region pull signature / version and a place to keep it across ticks.
- Hoist
_cityMechanicLevel. Compute each city's mechanic levels once per tick into a small map and pass it down, instead of calling it per city inside the loops. - Bound the inner loop.
_migrateForIncomeis effectively all-pairs over cities in a region (or the world?). If it is world-wide, restrict to regions that are connected / have a pull difference, or use a nearest-first sweep. - Avoid
_populationVersionchurn from no-op moves._movePopulationbumps the version even for trivial/fractional moves; only bump when the population actually changes. (This also keeps every population-keyed memo alive longer.) - Measure first. Build a benchmark that settles the standard map N days and
times
_tickPolitics, and/or count calls to_regionIncomePerCapitaand_cityMechanicLevelper tick. Change nothing until the hot spot is confirmed.
5. Suggested order of work
- Add a benchmark/profiling test (or temporary counters) that reports the per-tick politics cost and call counts on a settled standard map, so the before/after is objective.
- Memoise
_regionIncomePerCapitawith the right stamp (item 1). Re-measure. - Hoist
_cityMechanicLevel(item 3). Re-measure. - Skip unchanged regions (item 2) if still hot. Re-measure.
- Keep the full politics/upkeep suites green; the migration tests pin the observable behaviour, so the refactor must not change outcomes.
6. Tests to add / extend
- A performance-oriented test that asserts
_regionIncomePerCapitais computed once per tick when nothing changed (e.g. count invocations via a spy or a counter), and is recomputed when a counter it depends on moves. - A correctness test that a direct
tilePopulationedit is still reflected in a simulation read (do not break thegetCityEconomyrule). - Keep
tests/politics_test.js,tests/migration_test.js,tests/growth_test.js,tests/warmup_test.jsgreen.warmup_test.jstiming is a coarse guard.
7. Gotchas
- A wrong stamp is worse than no cache. If a counter that changes income is missing, migration reads stale data and tests will flake; if a fast counter is included, the cache is useless.
- Deltas. Any new persisted/cached structure that is serialised must pair
with a
DELTA_COLLECTIONSentry and a version (see §1), or the snapshot grows. _populationVersionis load-bearing for GDP/population memoisation; do not bump it in a hot loop for no reason, and do not stop bumping it when population truly changes.- Same numbers, different cost. The goal is identical simulation output at lower cost; verify by comparing settlement figures before/after, not just timing.
8. Relevant files
shared/game_state/politics.js—_tickPolitics,_migrateForIncome,_regionIncomePerCapita,_movePopulation,_cityMechanicLevel, existing memoisation at lines ~222/~251.shared/game_state/resources.js—_tradeGraphcache/stamp (the template),_tickResources.shared/game_state.js— version counters, tick order.server/server.js— the hourly tick and warm-up timing logs.- Tests:
tests/politics_test.js,tests/migration_test.js,tests/growth_test.js,tests/warmup_test.js,tests/trade_graph_test.js.
9. Related design docs
INTELLIGENCE.md, STACKS.md, AIR_MOVEMENT.md, ECONOMY_BALANCE.md,
TRAINING_UI.md, ICONS.md (repo root).