diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index 485d95d01..fce9f76f1 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -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 | null { - // Collect all water-component IDs reachable from this player's coast. - const playerComponents = new Set(); - 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(); - 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; } diff --git a/src/core/execution/nation/SharedWaterCache.ts b/src/core/execution/nation/SharedWaterCache.ts new file mode 100644 index 000000000..adcc8af16 --- /dev/null +++ b/src/core/execution/nation/SharedWaterCache.ts @@ -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 | null> | null = null; + + constructor(private game: Game) {} + + get(player: Player): Set | 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 | 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 } + >(); + const lakePartners = new Map(); + + for (const player of game.players()) { + if (player.type() === PlayerType.Bot) continue; + + let hasOcean = false; + const lakes = new Set(); + 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 | null>(); + for (const [player, { hasOcean, lakes }] of playerToWater) { + const shared = new Set(); + + 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; + } +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 037083ed4..7e8a1b281 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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 | null; /** Incremented each time the water navigation graph is rebuilt (e.g. after nuke terrain change). */ waterGraphVersion(): number; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 35bf005ba..0dec4ffc7 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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 | null { + return this._sharedWaterCache.get(player); + } conquerPlayer(conqueror: Player, conquered: Player) { if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) { const ships = conquered