mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 21:13:24 +00:00
ccd0745ad4
## 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
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
ConnectedComponents,
|
|
LAND_MARKER,
|
|
} from "../../../src/core/pathfinding/algorithms/ConnectedComponents";
|
|
import { createGameMap, createIslandMap, L, W } from "./_fixtures";
|
|
|
|
// prettier-ignore
|
|
const twoComponentsMapData = {
|
|
width: 7, height: 5, grid: [
|
|
W, W, L, L, L, W, W,
|
|
W, W, L, L, L, W, W,
|
|
W, W, L, L, L, W, W,
|
|
W, W, L, L, L, W, W,
|
|
W, W, L, L, L, W, W,
|
|
],
|
|
};
|
|
|
|
describe("ConnectedComponents", () => {
|
|
describe("getComponentId", () => {
|
|
it("returns 0 before initialization", () => {
|
|
const map = createGameMap(createIslandMap());
|
|
const wc = new ConnectedComponents(map);
|
|
|
|
// Water tile at (0,0) - should return 0 (not initialized)
|
|
const waterTile = map.ref(0, 0);
|
|
expect(wc.getComponentId(waterTile)).toBe(0);
|
|
});
|
|
|
|
it("returns same component ID for all water tiles in single connected area", () => {
|
|
const map = createGameMap(createIslandMap());
|
|
const wc = new ConnectedComponents(map);
|
|
wc.initialize();
|
|
|
|
const water1 = map.ref(0, 0);
|
|
const water2 = map.ref(4, 0);
|
|
const water3 = map.ref(0, 4);
|
|
const water4 = map.ref(4, 4);
|
|
|
|
expect(map.isWater(water1)).toBe(true);
|
|
expect(map.isWater(water2)).toBe(true);
|
|
expect(map.isWater(water3)).toBe(true);
|
|
expect(map.isWater(water4)).toBe(true);
|
|
|
|
const id1 = wc.getComponentId(water1);
|
|
const id2 = wc.getComponentId(water2);
|
|
const id3 = wc.getComponentId(water3);
|
|
const id4 = wc.getComponentId(water4);
|
|
|
|
expect(id1).toBe(1);
|
|
expect(id2).toBe(id1);
|
|
expect(id3).toBe(id1);
|
|
expect(id4).toBe(id1);
|
|
});
|
|
|
|
it("returns different component IDs for disconnected water areas", () => {
|
|
const map = createGameMap(twoComponentsMapData);
|
|
const wc = new ConnectedComponents(map);
|
|
wc.initialize();
|
|
|
|
const leftWater1 = map.ref(0, 0);
|
|
const leftWater2 = map.ref(1, 2);
|
|
const rightWater1 = map.ref(5, 0);
|
|
const rightWater2 = map.ref(6, 4);
|
|
|
|
expect(map.isWater(leftWater1)).toBe(true);
|
|
expect(map.isWater(leftWater2)).toBe(true);
|
|
expect(map.isWater(rightWater1)).toBe(true);
|
|
expect(map.isWater(rightWater2)).toBe(true);
|
|
|
|
const leftId1 = wc.getComponentId(leftWater1);
|
|
const leftId2 = wc.getComponentId(leftWater2);
|
|
const rightId1 = wc.getComponentId(rightWater1);
|
|
const rightId2 = wc.getComponentId(rightWater2);
|
|
|
|
expect(leftId1).not.toBe(rightId1);
|
|
|
|
expect(leftId1).toBe(leftId2);
|
|
expect(leftId1).toBeGreaterThan(0);
|
|
expect(leftId1).not.toBe(LAND_MARKER);
|
|
|
|
expect(rightId1).toBe(rightId2);
|
|
expect(rightId1).toBeGreaterThan(0);
|
|
expect(rightId1).not.toBe(LAND_MARKER);
|
|
});
|
|
|
|
it("returns LAND_MARKER for land tiles", () => {
|
|
const map = createGameMap(twoComponentsMapData);
|
|
const wc = new ConnectedComponents(map);
|
|
wc.initialize();
|
|
|
|
const landTile1 = map.ref(2, 0);
|
|
const landTile2 = map.ref(3, 2);
|
|
const landTile3 = map.ref(4, 4);
|
|
|
|
expect(map.isLand(landTile1)).toBe(true);
|
|
expect(map.isLand(landTile2)).toBe(true);
|
|
expect(map.isLand(landTile3)).toBe(true);
|
|
|
|
expect(wc.getComponentId(landTile1)).toBe(LAND_MARKER);
|
|
expect(wc.getComponentId(landTile2)).toBe(LAND_MARKER);
|
|
expect(wc.getComponentId(landTile3)).toBe(LAND_MARKER);
|
|
});
|
|
});
|
|
|
|
describe("determinism", () => {
|
|
it("produces same component IDs on repeated initialization", () => {
|
|
const map = createGameMap(twoComponentsMapData);
|
|
const wc1 = new ConnectedComponents(map);
|
|
const wc2 = new ConnectedComponents(map);
|
|
|
|
wc1.initialize();
|
|
wc2.initialize();
|
|
|
|
// Check all tiles have same component ID
|
|
for (let y = 0; y < 5; y++) {
|
|
for (let x = 0; x < 7; x++) {
|
|
const tile = map.ref(x, y);
|
|
expect(wc1.getComponentId(tile)).toBe(wc2.getComponentId(tile));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("direct terrain access optimization", () => {
|
|
it("produces same results with accessTerrainDirectly=false", () => {
|
|
const map = createGameMap(twoComponentsMapData);
|
|
const wcDirect = new ConnectedComponents(map, true);
|
|
const wcIndirect = new ConnectedComponents(map, false);
|
|
|
|
wcDirect.initialize();
|
|
wcIndirect.initialize();
|
|
|
|
// Check all tiles have same component ID
|
|
for (let y = 0; y < 5; y++) {
|
|
for (let x = 0; x < 7; x++) {
|
|
const tile = map.ref(x, y);
|
|
expect(wcDirect.getComponentId(tile)).toBe(
|
|
wcIndirect.getComponentId(tile),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|