From 17c1a6300f7be063f44063bfeeb3fb7fb42d2f42 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Mon, 13 Apr 2026 02:18:52 +0200
Subject: [PATCH] =?UTF-8?q?Trading=20in=20lakes=20=F0=9F=9A=A4=20(#3653)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
- Widened port placement and warship spawn/patrol checks from
`isOcean`/`isOceanShore` to `isWater`/`isShore`, so ports can be built
on lake shores and ships can operate on lakes, we discussed it here:
- Filtered `tradingPorts()` by water component so ports only attempt
trades with reachable ports - prevents silent path-not-found failures
across disconnected water bodies
- Applied the same water component filter when a captured trade ship
reroutes to its new owner's nearest port
- Removed the `WaterManager` fallback that force-marked isolated
water-nuked-tiles as ocean (no longer needed since lakes are now
navigable)
- Added a check to prevent nations from building ports on water bodies
that aren't accessible to other players
## 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
- [X] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
FloPinguin
---------
Co-authored-by: Evan
---
src/client/graphics/layers/UnitLayer.ts | 6 +--
src/core/execution/PortExecution.ts | 12 +++++
src/core/execution/TradeShipExecution.ts | 5 +-
src/core/execution/WarshipExecution.ts | 2 +-
.../nation/NationStructureBehavior.ts | 51 ++++++++++++++++---
.../execution/nation/NationWarshipBehavior.ts | 2 +-
src/core/execution/utils/AiAttackBehavior.ts | 28 +++++-----
src/core/game/PlayerImpl.ts | 11 ++--
src/core/game/WaterManager.ts | 8 ---
tests/nukes/WaterNukes.test.ts | 36 -------------
10 files changed, 86 insertions(+), 75 deletions(-)
diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts
index 3dd62c8e2..5f3227226 100644
--- a/src/client/graphics/layers/UnitLayer.ts
+++ b/src/client/graphics/layers/UnitLayer.ts
@@ -137,7 +137,7 @@ export class UnitLayer implements Layer {
clickRef = this.game.ref(cell.x, cell.y);
}
- if (!this.game.isOcean(clickRef)) return;
+ if (!this.game.isWater(clickRef)) return;
if (this.selectedUnit) {
this.eventBus.emit(
@@ -169,13 +169,13 @@ export class UnitLayer implements Layer {
const clickRef = this.game.ref(cell.x, cell.y);
if (this.game.inSpawnPhase()) {
// No Radial Menu during spawn phase, only spawn point selection
- if (!this.game.isOcean(clickRef)) {
+ if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
}
return;
}
- if (!this.game.isOcean(clickRef)) {
+ if (!this.game.isWater(clickRef)) {
// No warship to find because no Ocean tile, open Radial Menu
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
return;
diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts
index 9483f1b70..3dc05724e 100644
--- a/src/core/execution/PortExecution.ts
+++ b/src/core/execution/PortExecution.ts
@@ -97,10 +97,22 @@ export class PortExecution implements Execution {
// It's a probability list, so if an element appears twice it's because it's
// twice more likely to be picked later.
tradingPorts(): Unit[] {
+ const sourceComponents = new Set();
+ for (const neighbor of this.mg.neighbors(this.port!.tile())) {
+ if (!this.mg.isWater(neighbor)) continue;
+ const comp = this.mg.getWaterComponent(neighbor);
+ if (comp !== null) sourceComponents.add(comp);
+ }
const ports = this.mg
.players()
.filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner()))
.flatMap((p) => p.units(UnitType.Port))
+ .filter((p) => {
+ for (const comp of sourceComponents) {
+ if (this.mg.hasWaterComponent(p.tile(), comp)) return true;
+ }
+ return false;
+ })
.sort((p1, p2) => {
return (
this.mg.manhattanDist(this.port!.tile(), p1.tile()) -
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index 9eaa2c092..0f3df5f7b 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -90,13 +90,16 @@ export class TradeShipExecution implements Execution {
this.wasCaptured &&
(tradeShipOwner !== dstPortOwner || !this._dstPort.isActive())
) {
+ const myComponent = this.mg.getWaterComponent(curTile);
const nearestPort = findClosestBy(
tradeShipOwner.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), curTile),
(port) =>
port.isActive() &&
!port.isMarkedForDeletion() &&
- !port.isUnderConstruction(),
+ !port.isUnderConstruction() &&
+ myComponent !== null &&
+ this.mg.hasWaterComponent(port.tile(), myComponent),
);
if (nearestPort === null) {
this.tradeShip.delete(false);
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index e8b736bc2..527056df6 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -254,7 +254,7 @@ export class WarshipExecution implements Execution {
}
const tile = this.mg.ref(x, y);
if (
- !this.mg.isOcean(tile) ||
+ !this.mg.isWater(tile) ||
(!allowShoreline && this.mg.isShoreline(tile))
) {
attempts++;
diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts
index fe9698abd..43ec27a8a 100644
--- a/src/core/execution/nation/NationStructureBehavior.ts
+++ b/src/core/execution/nation/NationStructureBehavior.ts
@@ -90,6 +90,7 @@ export class NationStructureBehavior {
cluster: Cluster | null;
weight: number;
}> | null = null;
+ private _sharedWaterComponents: Set | null = null;
constructor(
private random: PseudoRandom,
@@ -107,7 +108,8 @@ export class NationStructureBehavior {
Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT),
)
: this.player.unitsOwned(UnitType.City);
- const hasCoastalTiles = this.hasCoastalTiles();
+ this._sharedWaterComponents = this.sharedWaterComponents();
+ const hasCoastalTiles = this._sharedWaterComponents !== null;
// Build order for non-city structures (priority order)
const buildOrder: UnitType[] = [
@@ -165,11 +167,37 @@ export class NationStructureBehavior {
return false;
}
- private hasCoastalTiles(): boolean {
+ /**
+ * 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.isOceanShore(tile)) return true;
+ 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);
+ }
}
- return false;
+ 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;
}
/**
@@ -471,10 +499,19 @@ export class NationStructureBehavior {
return bestTile;
}
+ /** Samples shore tiles adjacent to water reachable by another player (=> trading possible) */
private randCoastalTileArray(numTiles: number): TileRef[] {
- const tiles = Array.from(this.player.borderTiles()).filter((t) =>
- this.game.isOceanShore(t),
- );
+ const shared = this._sharedWaterComponents;
+ const tiles = Array.from(this.player.borderTiles()).filter((t) => {
+ if (!this.game.isShore(t)) return false;
+ if (shared === null) return false;
+ for (const neighbor of this.game.neighbors(t)) {
+ if (!this.game.isWater(neighbor)) continue;
+ const comp = this.game.getWaterComponent(neighbor);
+ if (comp !== null && shared.has(comp)) return true;
+ }
+ return false;
+ });
return Array.from(this.arraySampler(tiles, numTiles));
}
diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts
index df240f03f..51b469954 100644
--- a/src/core/execution/nation/NationWarshipBehavior.ts
+++ b/src/core/execution/nation/NationWarshipBehavior.ts
@@ -74,7 +74,7 @@ export class NationWarshipBehavior {
}
const tile = this.game.ref(randX, randY);
// Sanity check
- if (!this.game.isOcean(tile)) {
+ if (!this.game.isWater(tile)) {
continue;
}
return tile;
diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts
index c42301cc7..738fe6893 100644
--- a/src/core/execution/utils/AiAttackBehavior.ts
+++ b/src/core/execution/utils/AiAttackBehavior.ts
@@ -109,15 +109,15 @@ export class AiAttackBehavior {
return;
}
- // Check if we have any ocean shore tiles to launch from
- const oceanShore = Array.from(this.player.borderTiles()).filter((t) =>
- this.game.isOceanShore(t),
+ // Check if we have any shore tiles to launch from
+ const shore = Array.from(this.player.borderTiles()).filter((t) =>
+ this.game.isShore(t),
);
- if (oceanShore.length === 0) {
+ if (shore.length === 0) {
return;
}
- const src = this.random.randElement(oceanShore);
+ const src = this.random.randElement(shore);
// First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame
let dst = this.findRandomBoatTarget(src, borderingEnemies, true);
@@ -574,11 +574,11 @@ export class AiAttackBehavior {
return null;
}
- // Check if we have any ocean shore tiles to launch from
- const hasOceanShore = Array.from(this.player.borderTiles()).some((t) =>
- this.game.isOceanShore(t),
+ // Check if we have any shore tiles to launch from
+ const hasShore = Array.from(this.player.borderTiles()).some((t) =>
+ this.game.isShore(t),
);
- if (!hasOceanShore) return null;
+ if (!hasShore) return null;
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
@@ -615,10 +615,10 @@ export class AiAttackBehavior {
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) =>
- this.game.isOceanShore(t),
+ this.game.isShore(t),
),
Array.from(entry.player.borderTiles()).filter((t) =>
- this.game.isOceanShore(t),
+ this.game.isShore(t),
),
);
if (closest === null) continue;
@@ -786,10 +786,8 @@ export class AiAttackBehavior {
private sendBoatAttack(target: Player) {
const closest = closestTwoTiles(
this.game,
- Array.from(this.player.borderTiles()).filter((t) =>
- this.game.isOceanShore(t),
- ),
- Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)),
+ Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
+ Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
);
if (closest === null) {
return;
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index b223b2f62..05a90d1a6 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -1222,7 +1222,7 @@ export class PlayerImpl implements Player {
manhattanDistFN(tile, this.mg.config().radiusPortSpawn()),
),
)
- .filter((t) => this.mg.owner(t) === this && this.mg.isOceanShore(t))
+ .filter((t) => this.mg.owner(t) === this && this.mg.isShore(t))
.sort(
(a, b) =>
this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile),
@@ -1239,14 +1239,19 @@ export class PlayerImpl implements Player {
}
warshipSpawn(tile: TileRef): TileRef | false {
- if (!this.mg.isOcean(tile)) {
+ if (!this.mg.isWater(tile)) {
return false;
}
+ const tileComponent = this.mg.getWaterComponent(tile);
const bestPort = findClosestBy(
this.units(UnitType.Port),
(port) => this.mg.manhattanDist(port.tile(), tile),
- (port) => port.isActive() && !port.isUnderConstruction(),
+ (port) =>
+ port.isActive() &&
+ !port.isUnderConstruction() &&
+ tileComponent !== null &&
+ this.mg.hasWaterComponent(port.tile(), tileComponent),
);
return bestPort?.tile() ?? false;
diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts
index d3960b010..69009d5be 100644
--- a/src/core/game/WaterManager.ts
+++ b/src/core/game/WaterManager.ts
@@ -216,14 +216,6 @@ export class WaterManager {
}
}
}
- // If no converted tile is adjacent to existing ocean (e.g. all-land map),
- // mark all converted tiles as ocean so they're navigable for ports/boats.
- if (oceanQueue.length === 0) {
- for (const tile of converted) {
- map.setOcean(tile);
- oceanQueue.push(tile);
- }
- }
let oHead = 0;
while (oHead < oceanQueue.length) {
const tile = oceanQueue[oHead++];
diff --git a/tests/nukes/WaterNukes.test.ts b/tests/nukes/WaterNukes.test.ts
index 2607c4e14..91a65e739 100644
--- a/tests/nukes/WaterNukes.test.ts
+++ b/tests/nukes/WaterNukes.test.ts
@@ -180,42 +180,6 @@ describe("Water Nukes", () => {
});
});
- describe("all-land map (no pre-existing ocean)", () => {
- test("nuke-created water gets ocean bit so ports can be built", async () => {
- game = await setup("plains", {
- infiniteGold: true,
- instantBuild: true,
- waterNukes: true,
- });
- const info = new PlayerInfo("p", PlayerType.Human, null, "p");
- game.addPlayer(info);
- game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1)));
- while (game.inSpawnPhase()) game.executeNextTick();
- player = game.player(info.id);
- constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
-
- const target = game.ref(10, 10);
-
- // Verify no ocean exists anywhere near the target before the nuke
- expect(game.isLand(target)).toBe(true);
-
- launchNukeAt(game, player, target);
- tickUntilNukeLands(game);
-
- // The converted tile should be ocean (not just lake water)
- expect(game.isWater(target)).toBe(true);
- expect(game.isOcean(target)).toBe(true);
-
- // Neighboring land tiles should be ocean-shore (required for port placement)
- const x = game.x(target);
- const y = game.y(target);
- const shoreCandidate = game.ref(x + 2, y);
- if (game.isLand(shoreCandidate)) {
- expect(game.isOceanShore(shoreCandidate)).toBe(true);
- }
- });
- });
-
describe("updateTile terrain byte round-trip", () => {
test("terrain byte is packed and unpacked correctly", async () => {
game = await setup("plains", {