From ccd0745ad4b3268fbf250e513b4ee614cbeebeb6 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Mon, 29 Jun 2026 04:19:58 +0200 Subject: [PATCH] =?UTF-8?q?Prevent=20AI=20from=20placing=20ports=20on=20sm?= =?UTF-8?q?all=20lakes=20=F0=9F=9A=A2=20(#4429)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: AI nations were placing ports on small decorative ponds scattered across maps (Missisipi for example), wasting structure slots on strategically useless water bodies. This fix adds a water component size check to the port placement logic so the AI skips lakes that are too small for meaningful port use. We already had a check for available trade partners, but trading in small lakes is usually stupid. **How it works:** - `ConnectedComponents` now tracks component sizes during its existing flood-fill (zero extra cost - counts tiles as they're visited) - `AbstractGraph`, `WaterManager`, and the `Game` interface expose `getWaterComponentSize(tile)` so callers can query the size of any water body - `NationStructureBehavior.randCoastalTileArray()` filters out non-ocean water components below `MIN_PORT_WATER_COMPONENT_SIZE` (3000 minimap tiles, ~12000 full-map tiles) - Ocean tiles bypass the check entirely since they're always large enough ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- .../nation/NationStructureBehavior.ts | 16 ++++- src/core/game/Game.ts | 6 ++ src/core/game/GameImpl.ts | 3 + src/core/game/WaterManager.ts | 15 ++++ .../pathfinding/algorithms/AbstractGraph.ts | 5 ++ .../algorithms/ConnectedComponents.ts | 46 +++++++----- .../core/pathfinding/WaterComponents.test.ts | 70 +++++++++++++++++++ 7 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index 03f3db17d..457767eca 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -78,6 +78,13 @@ const FIRST_MISSILE_SILO_RATIO = 0.4; /** If we have more than this many structures per tiles, prefer upgrading over building */ const UPGRADE_DENSITY_THRESHOLD = 1 / 1500; +/** + * Minimum number of full-map water tiles a water body must have for the AI to + * consider placing a port on it. Prevents the AI from wasting ports on tiny + * decorative ponds scattered across the map. + */ +const MIN_PORT_WATER_COMPONENT_SIZE = 3000; + /** Estimated number of tiles per city equivalent, used when cities are disabled */ const TILES_PER_CITY_EQUIVALENT = 2000; @@ -844,7 +851,14 @@ export class NationStructureBehavior { // 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; + if (comp === null || !shared.has(comp)) continue; + // Skip tiny lakes that are too small for meaningful port use (not on Easy). + const { difficulty } = this.game.config().gameConfig(); + if (difficulty !== Difficulty.Easy) { + const size = this.game.getWaterComponentSize(neighbor); + if (size !== null && size < MIN_PORT_WATER_COMPONENT_SIZE) continue; + } + return true; } return false; }); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 63e3d160e..a6e3a5b84 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -799,6 +799,12 @@ export interface Game extends GameMap { miniWaterGraph(): AbstractGraph | null; getWaterComponent(tile: TileRef): number | null; hasWaterComponent(tile: TileRef, component: number): boolean; + /** + * Returns the approximate number of water tiles in the component + * containing `tile`, or null if the tile has no water component. Useful for + * filtering tiny water bodies (e.g. preventing AI port placement on ponds). + */ + getWaterComponentSize(tile: TileRef): number | null; /** * Returns the set of water components that `player` shares with at least one * valid trade partner (cached). Used by nation AI for port-placement diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 0de2177f7..db3a9e57c 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1204,6 +1204,9 @@ export class GameImpl implements Game { hasWaterComponent(tile: TileRef, component: number): boolean { return this._waterManager.hasWaterComponent(tile, component); } + getWaterComponentSize(tile: TileRef): number | null { + return this._waterManager.getWaterComponentSize(tile); + } sharedWaterComponents(player: Player): Set | null { return this._sharedWaterCache.get(player); } diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts index f72cc685e..218d34e1f 100644 --- a/src/core/game/WaterManager.ts +++ b/src/core/game/WaterManager.ts @@ -182,6 +182,21 @@ export class WaterManager { return false; } + /** + * Returns the approximate number of water tiles in the component + * containing `tile`, or null if the tile has no water component. + * + * The underlying ConnectedComponents are computed on the 2× downsampled + * minimap, so each minimap tile represents up to 4 full-map tiles. We + * multiply by 4 to give callers a value in full-map-tile units. + */ + getWaterComponentSize(tile: TileRef): number | null { + const componentId = this.getWaterComponent(tile); + if (componentId === null) return null; + if (!this._miniWaterGraph) return 0; + return this._miniWaterGraph.getComponentSize(componentId) * 4; + } + private finalizeWaterChanges( convertedTiles: TileRef[], changedTiles: TileRef[], diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts index 4ab24947a..647849acb 100644 --- a/src/core/pathfinding/algorithms/AbstractGraph.ts +++ b/src/core/pathfinding/algorithms/AbstractGraph.ts @@ -143,6 +143,11 @@ export class AbstractGraph { return this._waterComponents?.getComponentId(tile) ?? 0; } + /** Returns the number of water tiles in the given component, or 0 if unknown. */ + getComponentSize(componentId: number): number { + return this._waterComponents?.getComponentSize(componentId) ?? 0; + } + getClusterKey(clusterX: number, clusterY: number): number { return clusterY * this.clustersX + clusterX; } diff --git a/src/core/pathfinding/algorithms/ConnectedComponents.ts b/src/core/pathfinding/algorithms/ConnectedComponents.ts index 5f42888d2..62bcee874 100644 --- a/src/core/pathfinding/algorithms/ConnectedComponents.ts +++ b/src/core/pathfinding/algorithms/ConnectedComponents.ts @@ -3,7 +3,8 @@ import { GameMap, TileRef } from "../../game/GameMap"; import { DebugSpan } from "../../utilities/DebugSpan"; -export const LAND_MARKER = 0xff; // Must fit in Uint8Array +export const LAND_MARKER = 0xff; // Uint8Array sentinel — upgraded to 0xFFFF on Uint16Array promotion +const LAND_MARKER_WIDE = 0xffff; /** * Connected component labeling for grid-based maps. @@ -16,6 +17,7 @@ export class ConnectedComponents { private readonly lastRowStart: number; private readonly queue: Int32Array; private componentIds: Uint8Array | Uint16Array | null = null; + private _componentSizes: number[] = []; constructor( private readonly map: GameMap, @@ -32,6 +34,7 @@ export class ConnectedComponents { DebugSpan.start("ConnectedComponents:initialize"); let ids: Uint8Array | Uint16Array = this.createPrefilledIds(); + this._componentSizes = []; let nextId = 0; // Scan all tiles and flood-fill each unvisited water component @@ -45,11 +48,18 @@ export class ConnectedComponents { nextId++; - // Dynamically upgrade to Uint16Array when we hit component 254 - if (nextId === 254 && ids instanceof Uint8Array) { + // Dynamically upgrade to Uint16Array before assigning component 254, + // because 0xFF (component 254 in Uint8Array) collides with LAND_MARKER. + if (nextId === 253 && ids instanceof Uint8Array) { ids = this.upgradeToUint16Array(ids); } + // Cap at 0xFFFE — 0xFFFF is reserved as LAND_MARKER_WIDE after + // Uint16Array promotion and must not be assigned to a real component. + if (nextId === 0xffff) { + break; + } + this.floodFillComponent(ids, start, nextId); } @@ -75,19 +85,17 @@ export class ConnectedComponents { /** * Pre-mark all land tiles in the ids array. - * Land tiles are marked with 0xFF, water tiles remain 0. + * Land tiles are marked with 0xFF (Uint8Array) or 0xFFFF (Uint16Array), + * water tiles remain 0. */ private premarkLandTiles(ids: Uint8Array): void { for (let i = 0; i < this.numTiles; i++) { - ids[i] = this.map.isWater(i) ? 0 : LAND_MARKER; + ids[i] = this.map.isWater(i) ? 0 : 0xff; } } /** - * Pre-mark all land tiles in the ids array. - * Land tiles are marked with 0xFF, water tiles remain 0. - * - * This implementation accesses the terrain data **directly** without GameMap abstraction. + * Pre-mark all land tiles in the ids array using direct terrain access. * In tests it is 30% to 50% faster than using isWater() method calls. * As of 2026-01-05 it reduces avg. time for GWM from 15ms to 10ms. */ @@ -105,19 +113,13 @@ export class ConnectedComponents { for (let i = 0; i < numChunks; i++) { const chunk = terrain32[i]; - - // Extract bit 7 from each byte, negate, and combine into single 32-bit write - // bit 7 = 0 (water) → -(0) = 0x00 - // bit 7 = 1 (land) → -(1) = 0xFF (truncated to 8 bits) const b0 = -((chunk >> 7) & 1) & 0xff; const b1 = -((chunk >> 15) & 1) & 0xff; const b2 = -((chunk >> 23) & 1) & 0xff; - const b3 = -((chunk >> 31) & 1); // Upper byte, no mask needed - + const b3 = -((chunk >> 31) & 1); ids32[i] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24); } - // Handle remaining tiles (when numTiles not divisible by 4) for (let i = numChunks * 4; i < this.numTiles; i++) { ids[i] = -(terrain[i] >> 7); } @@ -125,12 +127,12 @@ export class ConnectedComponents { /** * Upgrade from Uint8Array to Uint16Array when we exceed 254 components. - * Direct copy works because both use 0xFF for land marker. + * Remaps 0xFF land markers to 0xFFFF so component id 255 is unambiguous. */ private upgradeToUint16Array(ids: Uint8Array): Uint16Array { const newIds = new Uint16Array(this.numTiles); for (let i = 0; i < this.numTiles; i++) { - newIds[i] = ids[i]; + newIds[i] = ids[i] === LAND_MARKER ? LAND_MARKER_WIDE : ids[i]; } return newIds; } @@ -172,6 +174,9 @@ export class ConnectedComponents { } // Fill the entire horizontal span and check above/below for new spans + const spanSize = right - left + 1; + this._componentSizes[componentId] = + (this._componentSizes[componentId] ?? 0) + spanSize; for (let x = left; x <= right; x++) { ids[x] = componentId; @@ -198,4 +203,9 @@ export class ConnectedComponents { if (!this.componentIds) return 0; return this.componentIds[tile] ?? 0; } + + /** Returns the number of water tiles in the given component, or 0 if unknown. */ + getComponentSize(componentId: number): number { + return this._componentSizes[componentId] ?? 0; + } } diff --git a/tests/core/pathfinding/WaterComponents.test.ts b/tests/core/pathfinding/WaterComponents.test.ts index 91faad9c6..2b2eda51c 100644 --- a/tests/core/pathfinding/WaterComponents.test.ts +++ b/tests/core/pathfinding/WaterComponents.test.ts @@ -142,4 +142,74 @@ describe("ConnectedComponents", () => { } }); }); + + describe("getComponentSize", () => { + it("returns 0 before initialization", () => { + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + expect(wc.getComponentSize(1)).toBe(0); + }); + + it("returns correct size for a single water ring (island map)", () => { + // 5x5 island: outer ring is water = 25 - 9 = 16 tiles + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + wc.initialize(); + + const waterTile = map.ref(0, 0); + const id = wc.getComponentId(waterTile); + expect(id).toBeGreaterThan(0); + expect(wc.getComponentSize(id)).toBe(16); + }); + + it("returns correct sizes for two disconnected water areas", () => { + // Two 2-wide columns of water (10 tiles each), separated by 3 land columns + const map = createGameMap(twoComponentsMapData); + const wc = new ConnectedComponents(map); + wc.initialize(); + + const leftId = wc.getComponentId(map.ref(0, 0)); + const rightId = wc.getComponentId(map.ref(5, 0)); + expect(leftId).not.toBe(rightId); + + expect(wc.getComponentSize(leftId)).toBe(10); + expect(wc.getComponentSize(rightId)).toBe(10); + }); + + it("returns 0 for a land component ID", () => { + const map = createGameMap(twoComponentsMapData); + const wc = new ConnectedComponents(map); + wc.initialize(); + + // LAND_MARKER (0xFF) is not a valid water component + expect(wc.getComponentSize(0xff)).toBe(0); + }); + + it("returns 0 for non-existent component ID", () => { + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + wc.initialize(); + + expect(wc.getComponentSize(999)).toBe(0); + }); + + it("never assigns 0xFFFF (LAND_MARKER_WIDE) as a component ID", () => { + const map = createGameMap(createIslandMap()); + const wc = new ConnectedComponents(map); + wc.initialize(); + + // 0xFFFF is reserved after Uint16Array promotion + expect(wc.getComponentSize(0xffff)).toBe(0); + + // No tile should have component ID 0xFFFF + for (let y = 0; y < map.height(); y++) { + for (let x = 0; x < map.width(); x++) { + const tile = map.ref(x, y); + if (map.isWater(tile)) { + expect(wc.getComponentId(tile)).not.toBe(0xffff); + } + } + } + }); + }); });