mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user