mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 14:00:47 +00:00
Perf: Maintain a per-player alliance list (#4172)
# 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)
This commit is contained in:
@@ -890,7 +890,6 @@ export interface Game extends GameMap {
|
||||
teamSpawnArea(team: Team): SpawnArea | undefined;
|
||||
|
||||
// Alliances
|
||||
alliances(): MutableAlliance[];
|
||||
expireAlliance(alliance: Alliance): void;
|
||||
|
||||
// Immunity timer
|
||||
|
||||
+15
-11
@@ -89,7 +89,6 @@ export class GameImpl implements Game {
|
||||
_terraNullius: TerraNulliusImpl;
|
||||
|
||||
allianceRequests: AllianceRequestImpl[] = [];
|
||||
alliances_: AllianceImpl[] = [];
|
||||
|
||||
private nextPlayerID = 1;
|
||||
private _nextUnitID = 1;
|
||||
@@ -227,10 +226,6 @@ export class GameImpl implements Game {
|
||||
return this.playerBySmallID(this.ownerID(ref));
|
||||
}
|
||||
|
||||
alliances(): MutableAlliance[] {
|
||||
return this.alliances_;
|
||||
}
|
||||
|
||||
playerBySmallID(id: number): Player | TerraNullius {
|
||||
if (id === 0) {
|
||||
return this.terraNullius();
|
||||
@@ -367,7 +362,8 @@ export class GameImpl implements Game {
|
||||
this._ticks,
|
||||
this.nextAllianceID++,
|
||||
);
|
||||
this.alliances_.push(alliance);
|
||||
(alliance.requestor() as PlayerImpl)._alliances.push(alliance);
|
||||
(alliance.recipient() as PlayerImpl)._alliances.push(alliance);
|
||||
(request.requestor() as PlayerImpl).pastOutgoingAllianceRequests.push(
|
||||
request,
|
||||
);
|
||||
@@ -794,7 +790,7 @@ export class GameImpl implements Game {
|
||||
breaker.markTraitor();
|
||||
}
|
||||
|
||||
this.alliances_ = this.alliances_.filter((a) => a !== alliance);
|
||||
this.detachAlliance(alliance);
|
||||
|
||||
this.addUpdate({
|
||||
type: GameUpdateType.BrokeAlliance,
|
||||
@@ -815,7 +811,7 @@ export class GameImpl implements Game {
|
||||
`cannot expire alliance: must have exactly one alliance, have ${alliances.length}`,
|
||||
);
|
||||
}
|
||||
this.alliances_ = this.alliances_.filter((a) => a !== alliances[0]);
|
||||
this.detachAlliance(alliances[0]);
|
||||
this.addUpdate({
|
||||
type: GameUpdateType.AllianceExpired,
|
||||
player1ID: alliance.requestor().smallID(),
|
||||
@@ -824,9 +820,17 @@ export class GameImpl implements Game {
|
||||
}
|
||||
|
||||
public removeAlliancesByPlayerSilently(player: Player): void {
|
||||
this.alliances_ = this.alliances_.filter(
|
||||
(a) => a.requestor() !== player && a.recipient() !== player,
|
||||
);
|
||||
// Snapshot — detachAlliance reassigns the player's _alliances as it goes.
|
||||
const removed = [...(player as PlayerImpl)._alliances];
|
||||
for (const alliance of removed) this.detachAlliance(alliance);
|
||||
}
|
||||
|
||||
/** Remove an alliance from both participants' per-player alliance lists. */
|
||||
private detachAlliance(alliance: Alliance): void {
|
||||
const requestor = alliance.requestor() as PlayerImpl;
|
||||
const recipient = alliance.recipient() as PlayerImpl;
|
||||
requestor._alliances = requestor._alliances.filter((a) => a !== alliance);
|
||||
recipient._alliances = recipient._alliances.filter((a) => a !== alliance);
|
||||
}
|
||||
|
||||
public isSpawnImmunityActive(): boolean {
|
||||
|
||||
@@ -102,6 +102,8 @@ export class PlayerImpl implements Player {
|
||||
public _outgoingAttacks: Attack[] = [];
|
||||
public _outgoingLandAttacks: Attack[] = [];
|
||||
|
||||
public _alliances: MutableAlliance[] = [];
|
||||
|
||||
private _spawnTile: TileRef | undefined;
|
||||
private _isDisconnected = false;
|
||||
|
||||
@@ -487,9 +489,7 @@ export class PlayerImpl implements Player {
|
||||
}
|
||||
|
||||
alliances(): MutableAlliance[] {
|
||||
return this.mg.alliances_.filter(
|
||||
(a) => a.requestor() === this || a.recipient() === this,
|
||||
);
|
||||
return this._alliances;
|
||||
}
|
||||
|
||||
expiredAlliances(): Alliance[] {
|
||||
|
||||
Reference in New Issue
Block a user