mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 09:42:13 +00:00
Prevent AI from placing ports on small lakes 🚢 (#4429)
## 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
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number> | null {
|
||||
return this._sharedWaterCache.get(player);
|
||||
}
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user