Files
Battle-for-Tismo/POLITICS_PERFORMANCE.md
T

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 by server/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.js imports each shared/game_state/<topic>.js and 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 _tradeGraph is 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 getCityEconomy outside a snapshot — a test pins that a simulation read reflects a direct tilePopulation edit 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.1 logs how many days the warm-up took (and how long). --warmup 0 skips it.

Conventions

  • 2-space indent; no new libraries.
  • Tests in tests/<topic>_test.js extending TestCase. Force RNG with state._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, _tickPolitics is the largest remaining cost in a settled world — _migrateForIncome through _regionIncomePerCapita and _movePopulation, and the repeated _cityMechanicLevel lookups 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.
  • _cityMechanicLevel is called repeatedly for the same city/building within one tick.
  • Some politics walks are already memoised (politics.js:222, :251 use _ethnicityVersion:_populationVersion:_territoryVersion[: _regionVersion] stamps), so there is a local idiom to follow.

4. Design space (settle before coding)

  1. Memoise _regionIncomePerCapita under 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 _regionIncomePerCapita actually 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.
  2. 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.
  3. 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.
  4. Bound the inner loop. _migrateForIncome is 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.
  5. Avoid _populationVersion churn from no-op moves. _movePopulation bumps the version even for trivial/fractional moves; only bump when the population actually changes. (This also keeps every population-keyed memo alive longer.)
  6. Measure first. Build a benchmark that settles the standard map N days and times _tickPolitics, and/or count calls to _regionIncomePerCapita and _cityMechanicLevel per tick. Change nothing until the hot spot is confirmed.

5. Suggested order of work

  1. 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.
  2. Memoise _regionIncomePerCapita with the right stamp (item 1). Re-measure.
  3. Hoist _cityMechanicLevel (item 3). Re-measure.
  4. Skip unchanged regions (item 2) if still hot. Re-measure.
  5. 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 _regionIncomePerCapita is 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 tilePopulation edit is still reflected in a simulation read (do not break the getCityEconomy rule).
  • Keep tests/politics_test.js, tests/migration_test.js, tests/growth_test.js, tests/warmup_test.js green. warmup_test.js timing 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_COLLECTIONS entry and a version (see §1), or the snapshot grows.
  • _populationVersion is 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 — _tradeGraph cache/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.

INTELLIGENCE.md, STACKS.md, AIR_MOVEMENT.md, ECONOMY_BALANCE.md, TRAINING_UI.md, ICONS.md (repo root).