mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:00:42 +00:00
ee8c28331b
# Maintain a per-player alliance list (drop O(all-alliances) scan)
## Summary
`PlayerImpl.alliances()` was implemented as a full scan of the global
alliance
list on every call:
```ts
alliances(): MutableAlliance[] {
return this.mg.alliances_.filter(
(a) => a.requestor() === this || a.recipient() === this,
);
}
```
This is O(all-alliances-in-game) **per call**, and it's called a lot —
most
notably twice per player per tick from `PlayerImpl.toFullUpdate()` (once
for
`allies`, once for `alliances`), which runs for every player every tick
on the
worker/core thread.
This PR makes each player own its alliance list: a per-player
`_alliances`
array (mirroring the existing `_incomingAttacks` / `_outgoingAttacks`
pattern),
maintained incrementally as alliances form/break/expire, so
`alliances()`
becomes an O(1) field read.
It turned out the global `mg.alliances_` list was only ever read by this
scan —
the `Game`-level `alliances()` getter had **zero callers** (all 17
`.alliances()`
callsites use the player-level accessor), and the list isn't used in
serialization. So rather than keep two structures in sync, this removes
the
global list entirely and makes the per-player lists the single source of
truth.
## Motivation
Profiling the worker/core thread showed `player.toFullUpdate` at ~**4%
of CPU**.
Breaking down where that time goes (microbenchmark, 100 players, ~100
alliances):
| Component | µs/tick | Share |
| --- | --- | --- |
| FULL (current: alliance scan ×2 + allocate collections) | 61.5 | 100%
|
| Alliance scan only (the two global `.filter()`s) | 41.7 | **~68%** |
| Allocation only (build arrays/objects, per-player list, no scan) | 6.4
| ~10% |
The global alliance scan — not the object allocation — is the dominant
cost, and
it gets *worse* with game size: the scan is O(players × total-alliances)
while
allocation is only O(players × own-alliances). Removing the scan targets
the
dominant ~2/3 of `toFullUpdate`'s cost.
It also speeds up `alliances()` everywhere, not just `toFullUpdate` —
it's called
in **17 places**, including AI hot paths (`NationAllianceBehavior`,
`PlayerExecution`).
> Note: this builds on the already-merged `diffPlayerUpdate`
typed-comparison
> change (commit `be87c76`), which addressed the diff/serialization
cost. This PR
> addresses the snapshot-construction cost.
## Changes
- **`PlayerImpl`**: add `public _alliances: MutableAlliance[]`;
`alliances()`
returns it directly.
- **`GameImpl`**: remove the global `alliances_` field and the unused
`alliances()` getter. Maintain the per-player lists at the mutation
sites:
- **add** — `acceptAllianceRequest` pushes the new alliance onto both
participants.
- **remove** — `breakAlliance`, `expireAlliance`, and
`removeAlliancesByPlayerSilently` all funnel through a small
`detachAlliance()` helper that removes the alliance from both
participants.
- **`Game` interface**: drop `alliances(): MutableAlliance[]` (no
callers).
## Correctness notes
- `alliances()` now returns the internal array by reference. This
matches the
existing `outgoingAttacks()` / `incomingAttacks()` accessors, which
already do
the same. All 17 callsites were checked — none mutate the returned
array.
- `detachAlliance` reassigns the array (`filter`) rather than splicing
in place,
so the `for (const alliance of player.alliances())` loop in
`PlayerExecution`
(which can expire alliances mid-iteration) iterates a stable snapshot
and is
safe. `removeAlliancesByPlayerSilently` likewise snapshots the player's
list
before detaching.
## Tests
New `tests/PlayerAllianceList.test.ts` asserts both participants' lists
stay in
sync through every mutation path:
- forming an alliance adds it to both lists
- `alliances()` agrees with `isAlliedWith` / `allianceWith`
- breaking removes it from both lists
- expiring removes it from both lists
- a player tracks multiple alliances independently (breaking one keeps
the other)
- `removeAllAlliances` clears the player and every partner
Full suite green: **1360 tests / 120 files**.
🤖 Generated with [Claude Code](https://claude.com/claude-code)