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-17 17:34:45 -07: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;
}
}