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:
FloPinguin
2026-06-29 04:19:58 +02:00
committed by GitHub
parent 06d505ebc9
commit ccd0745ad4
7 changed files with 142 additions and 19 deletions
@@ -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);
}
}
}
});
});
});