Cache shared-water computation for nation port placement 💧 (#3696)

## Description:

- Cache `sharedWaterComponents` globally with a 30-tick (~3s) TTL so all
nations share one `O(total_border_tiles)` pass instead of each nation
re-scanning every other player's border on every call.
- Treat ocean as always-shared: any ocean neighbor short-circuits as a
valid port site, skipping the `getWaterComponent` lookup in both the
build pass and the per-tile port check.
- Exclude bots and mutually-embargoed players from the trade-partner
candidate set, so nations no longer avoid port sites that only "share"
water with a player they can never trade with.

Port placement is not time-critical, so the 3-second staleness is
acceptable and lets the expensive build amortize across many attack
cycles.

### Performance
Benchmarked on World map (2000×1000, 61 nations) with the realistic call
pattern of ~3 nations invoking `sharedWaterComponents` per tick:

- **Before (main):** ~414 μs per tick
- **After:** ~8 μs per tick amortized (29/30 ticks hit the warm cache;
1/30 rebuilds)
- **~50× faster** on this AI hot path

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
This commit is contained in:
FloPinguin
2026-04-18 02:34:45 +02:00
committed by GitHub
parent 0801cad0b5
commit bcd9cd6af5
4 changed files with 115 additions and 34 deletions
@@ -108,7 +108,7 @@ export class NationStructureBehavior {
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
)
: this.player.unitsOwned(UnitType.City);
this._sharedWaterComponents = this.sharedWaterComponents();
this._sharedWaterComponents = this.game.sharedWaterComponents(this.player);
const hasCoastalTiles = this._sharedWaterComponents !== null;
// Build order for non-city structures (priority order)
@@ -167,39 +167,6 @@ export class NationStructureBehavior {
return false;
}
/**
* Returns the set of water components shared with at least one other player,
* or null if there are none.
*/
private sharedWaterComponents(): Set<number> | null {
// Collect all water-component IDs reachable from this player's coast.
const playerComponents = new Set<number>();
for (const tile of this.player.borderTiles()) {
if (!this.game.isShore(tile)) continue;
for (const neighbor of this.game.neighbors(tile)) {
if (!this.game.isWater(neighbor)) continue;
const comp = this.game.getWaterComponent(neighbor);
if (comp !== null) playerComponents.add(comp);
}
}
if (playerComponents.size === 0) return null;
// Keep only components that at least one other player also touches.
const shared = new Set<number>();
for (const other of this.game.players()) {
if (other === this.player) continue;
for (const tile of other.borderTiles()) {
if (!this.game.isShore(tile)) continue;
for (const neighbor of this.game.neighbors(tile)) {
if (!this.game.isWater(neighbor)) continue;
const comp = this.game.getWaterComponent(neighbor);
if (comp !== null && playerComponents.has(comp)) shared.add(comp);
}
}
}
return shared.size > 0 ? shared : null;
}
/**
* Determines if we should build more of this structure type based on
* the current city count and the configured ratio.
@@ -507,6 +474,9 @@ export class NationStructureBehavior {
if (shared === null) return false;
for (const neighbor of this.game.neighbors(t)) {
if (!this.game.isWater(neighbor)) continue;
// Ocean is always considered shared, so any ocean neighbor makes the
// tile a valid port site — skip the component lookup.
if (this.game.isOcean(neighbor)) return true;
const comp = this.game.getWaterComponent(neighbor);
if (comp !== null && shared.has(comp)) return true;
}
@@ -0,0 +1,99 @@
import { Game, Player, PlayerType } from "../../game/Game";
/**
* Cache for "which water components does each nation share with a
* valid trade partner". Used by nation AI to decide whether to spend cycles
* trying to place a port on a given coastline.
*
* Rebuilt at most once every TTL_TICKS (3s at 10 ticks/s). Port placement is
* not time-critical - a nation noticing a newly-valid port site a few seconds
* late is fine and lets us amortize the O(total_border_tiles) build across
* far more callers than a per-tick cache would.
*/
const TTL_TICKS = 30;
/** Sentinel added to a player's shared-water set to signal "touches ocean". */
const OCEAN_SENTINEL = -1;
export class SharedWaterCache {
private tick: number = -Infinity;
private byPlayer: Map<Player, Set<number> | null> | null = null;
constructor(private game: Game) {}
get(player: Player): Set<number> | null {
const tick = this.game.ticks();
if (this.byPlayer === null || tick - this.tick >= TTL_TICKS) {
this.byPlayer = this.build();
this.tick = tick;
}
return this.byPlayer.get(player) ?? null;
}
private build(): Map<Player, Set<number> | null> {
const game = this.game;
// Pass 1: for each non-bot player, record which water bodies they touch
// and which lakes have them as a candidate trade partner. Bots are skipped
// entirely — nation AI is the only caller, and bots are never candidate
// trade partners.
const playerToWater = new Map<
Player,
{ hasOcean: boolean; lakes: Set<number> }
>();
const lakePartners = new Map<number, Player[]>();
for (const player of game.players()) {
if (player.type() === PlayerType.Bot) continue;
let hasOcean = false;
const lakes = new Set<number>();
for (const tile of player.borderTiles()) {
if (!game.isShore(tile)) continue;
for (const neighbor of game.neighbors(tile)) {
if (!game.isWater(neighbor)) continue;
if (game.isOcean(neighbor)) {
hasOcean = true;
continue;
}
const comp = game.getWaterComponent(neighbor);
if (comp !== null) lakes.add(comp);
}
}
playerToWater.set(player, { hasOcean, lakes });
for (const c of lakes) {
let arr = lakePartners.get(c);
if (arr === undefined) {
arr = [];
lakePartners.set(c, arr);
}
arr.push(player);
}
}
// Pass 2: ocean is treated as always shared (nation AI short-circuits on
// ocean neighbors). Lake components are shared only if some *other* player
// on that component can trade with P (i.e. no mutual embargo).
const result = new Map<Player, Set<number> | null>();
for (const [player, { hasOcean, lakes }] of playerToWater) {
const shared = new Set<number>();
if (hasOcean) shared.add(OCEAN_SENTINEL);
for (const c of lakes) {
const partners = lakePartners.get(c);
if (partners === undefined) continue;
for (const other of partners) {
if (other !== player && player.canTrade(other)) {
shared.add(c);
break;
}
}
}
result.set(player, shared.size > 0 ? shared : null);
}
return result;
}
}
+6
View File
@@ -922,6 +922,12 @@ export interface Game extends GameMap {
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
/**
* Returns the set of water components that `player` shares with at least one
* valid trade partner (cached). Used by nation AI for port-placement
* heuristics. `null` means no usable water body for ports.
*/
sharedWaterComponents(player: Player): Set<number> | null;
/** Incremented each time the water navigation graph is rebuilt (e.g. after nuke terrain change). */
waterGraphVersion(): number;
+6
View File
@@ -1,5 +1,6 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
import { SharedWaterCache } from "../execution/nation/SharedWaterCache";
import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
@@ -107,6 +108,7 @@ export class GameImpl implements Game {
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
private _waterManager: WaterManager;
private _sharedWaterCache: SharedWaterCache;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
constructor(
@@ -130,6 +132,7 @@ export class GameImpl implements Game {
this.miniGameMap,
_config.disableNavMesh(),
);
this._sharedWaterCache = new SharedWaterCache(this);
if (_config.gameConfig().gameMode === GameMode.Team) {
this.populateTeams();
@@ -1168,6 +1171,9 @@ export class GameImpl implements Game {
hasWaterComponent(tile: TileRef, component: number): boolean {
return this._waterManager.hasWaterComponent(tile, component);
}
sharedWaterComponents(player: Player): Set<number> | null {
return this._sharedWaterCache.get(player);
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
const ships = conquered