Files
Battle-for-Tismo/POLITICS_PERFORMANCE.md
T

164 lines
7.6 KiB
Markdown

# 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`.
## 9. Related design docs
`INTELLIGENCE.md`, `STACKS.md`, `AIR_MOVEMENT.md`, `ECONOMY_BALANCE.md`,
`TRAINING_UI.md`, `ICONS.md` (repo root).