From 7654537a00605c610e31953fae07b90722567339 Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Wed, 29 Apr 2026 02:25:11 +0200
Subject: [PATCH] =?UTF-8?q?Much=20better=20river=20handling=20for=20nation?=
=?UTF-8?q?s=20and=20tribes!=20=F0=9F=8F=9E=EF=B8=8F=20(#3786)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
In this example, the two nations DONT see each other as neighbors, but
as ISLANDERS. Because they dont have a direct border connection, there
is water in between.
This is a big problem, because most of the logic in AiAttackBehavior
gets ignored. Only the "islander" strategy runs (late, because its a
not-important strat).
### Summary
- `PlayerImpl.neighbors()` now includes cross-water neighbors: a new
`shoreReachableNeighbors()` helper samples every 10th shore border tile
and looks up to 5 tiles in each cardinal direction across water, finding
land owners on the other side (covers rivers up to 4 tiles wide).
- `AiAttackBehavior.maybeAttack()` extends the `hasNonNukedTerraNullius`
check to also trigger on TN detected via `player.neighbors()`, so
nations notice and pursue TN that is only reachable across a river.
- `sendAttack()` uses a new `hasLandBorderWithTerraNullius()` land-only
adjacency check to decide between a land attack and a boat attack for
TN, rather than `sharesBorderWith()` which includes water tiles.
- Added `sendBoatAttackToNearbyTerraNullius()`: when no TN land is
directly adjacent, the AI scans its shore border tiles for unowned land
across water and dispatches a transport ship.
### Also works for Tribes!
Tribes can boat rivers now, really cool.
https://github.com/user-attachments/assets/382e85aa-c437-4e0c-afc2-0c381432da3d
## 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
---
src/core/execution/TribeExecution.ts | 11 +-
.../alliance/BreakAllianceExecution.ts | 2 +-
.../nation/NationAllianceBehavior.ts | 2 +-
.../execution/nation/NationEmojiBehavior.ts | 2 +-
.../nation/NationStructureBehavior.ts | 4 +-
src/core/execution/utils/AiAttackBehavior.ts | 186 ++++++++++++------
src/core/game/Game.ts | 2 +-
src/core/game/PlayerImpl.ts | 52 ++++-
tests/NationStructureBehavior.test.ts | 2 +-
9 files changed, 191 insertions(+), 72 deletions(-)
diff --git a/src/core/execution/TribeExecution.ts b/src/core/execution/TribeExecution.ts
index b288d67ad..57f998c20 100644
--- a/src/core/execution/TribeExecution.ts
+++ b/src/core/execution/TribeExecution.ts
@@ -108,17 +108,16 @@ export class TribeExecution implements Execution {
this.tribe.breakAlliance(alliance);
}
- this.attackBehavior.sendAttack(toAttack);
- return;
+ if (this.attackBehavior.sendAttack(toAttack)) return;
}
}
if (this.neighborsTerraNullius) {
- if (this.tribe.neighbors().some((n) => !n.isPlayer())) {
- this.attackBehavior.sendAttack(this.mg.terraNullius());
- return;
+ if (this.tribe.nearby().some((n) => !n.isPlayer())) {
+ if (this.attackBehavior.sendAttack(this.mg.terraNullius())) return;
+ } else {
+ this.neighborsTerraNullius = false;
}
- this.neighborsTerraNullius = false;
}
this.attackBehavior.attackRandomTarget();
diff --git a/src/core/execution/alliance/BreakAllianceExecution.ts b/src/core/execution/alliance/BreakAllianceExecution.ts
index 36d774527..cbeb587a9 100644
--- a/src/core/execution/alliance/BreakAllianceExecution.ts
+++ b/src/core/execution/alliance/BreakAllianceExecution.ts
@@ -38,7 +38,7 @@ export class BreakAllianceExecution implements Execution {
this.recipient.updateRelation(this.requestor, -100);
const neighbors = this.requestor
- .neighbors()
+ .nearby()
.filter(
(n): n is Player => n.isPlayer() && !n.isOnSameTeam(this.recipient!),
);
diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts
index b91c9fb11..2fb858944 100644
--- a/src/core/execution/nation/NationAllianceBehavior.ts
+++ b/src/core/execution/nation/NationAllianceBehavior.ts
@@ -282,7 +282,7 @@ export class NationAllianceBehavior {
case Difficulty.Impossible: {
// On hard and impossible we try to not ally with all our neighbors (If we have 2+ neighbors)
const borderingPlayers = this.player
- .neighbors()
+ .nearby()
.filter(
(n): n is Player => n.isPlayer() && n.type() !== PlayerType.Bot,
);
diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts
index 93ba8140a..31e1d67bd 100644
--- a/src/core/execution/nation/NationEmojiBehavior.ts
+++ b/src/core/execution/nation/NationEmojiBehavior.ts
@@ -210,7 +210,7 @@ export class NationEmojiBehavior {
if (!this.random.chance(250)) return;
const nearbyHumans = this.player
- .neighbors()
+ .nearby()
.filter(
(p): p is Player => p.isPlayer() && p.type() === PlayerType.Human,
);
diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts
index 86783f665..6db34587c 100644
--- a/src/core/execution/nation/NationStructureBehavior.ts
+++ b/src/core/execution/nation/NationStructureBehavior.ts
@@ -878,7 +878,7 @@ export class NationStructureBehavior {
}
// Neighbor structures — all non-embargoed non-bot neighbors.
- for (const neighbor of player.neighbors()) {
+ for (const neighbor of player.nearby()) {
if (!neighbor.isPlayer()) continue;
if (neighbor.type() === PlayerType.Bot) continue;
if (!player.canTrade(neighbor)) continue;
@@ -1023,7 +1023,7 @@ export class NationStructureBehavior {
// Check if we have any non-friendly non-bot neighbors with more troops
const hasHostileNeighbor =
player
- .neighbors()
+ .nearby()
.filter(
(n): n is Player =>
n.isPlayer() &&
diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts
index c46df4de0..44525623f 100644
--- a/src/core/execution/utils/AiAttackBehavior.ts
+++ b/src/core/execution/utils/AiAttackBehavior.ts
@@ -59,13 +59,18 @@ export class AiAttackBehavior {
this.game.isLand(t) &&
this.game.ownerID(t) !== this.player?.smallID(),
);
- const borderingPlayers = [
- ...new Set(
- border
- .map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
- .filter((o): o is Player => o.isPlayer()),
- ),
- ].sort((a, b) => a.troops() - b.troops());
+ const playerNeighbors = this.player.nearby();
+ const borderingPlayerSet = new Set(
+ border
+ .map((t) => this.game.playerBySmallID(this.game.ownerID(t)))
+ .filter((o): o is Player => o.isPlayer()),
+ );
+ for (const n of playerNeighbors) {
+ if (n.isPlayer()) borderingPlayerSet.add(n);
+ }
+ const borderingPlayers = [...borderingPlayerSet].sort(
+ (a, b) => a.troops() - b.troops(),
+ );
const borderingFriends = borderingPlayers.filter(
(o) => this.player?.isFriendly(o) === true,
);
@@ -73,13 +78,12 @@ export class AiAttackBehavior {
(o) => this.player?.isFriendly(o) === false,
);
- // Attack TerraNullius but not nuked territory
- const hasNonNukedTerraNullius = border.some(
- (t) => !this.game.hasOwner(t) && !this.game.hasFallout(t),
- );
+ // Attack TerraNullius but not nuked territory (direct border or across a river)
+ const hasNonNukedTerraNullius =
+ border.some((t) => !this.game.hasOwner(t) && !this.game.hasFallout(t)) ||
+ playerNeighbors.some((n) => !n.isPlayer());
if (hasNonNukedTerraNullius) {
- this.sendAttack(this.game.terraNullius());
- return;
+ if (this.sendAttack(this.game.terraNullius())) return;
}
if (borderingEnemies.length === 0) {
@@ -243,8 +247,7 @@ export class AiAttackBehavior {
const retaliate = (): boolean => {
const attacker = this.findIncomingAttackPlayer();
if (attacker) {
- this.sendAttack(attacker, true);
- return true;
+ return this.sendAttack(attacker, true);
}
return false;
};
@@ -256,8 +259,7 @@ export class AiAttackBehavior {
const traitor = (): boolean => {
const traitor = this.findTraitor(borderingEnemies);
if (traitor) {
- this.sendAttack(traitor);
- return true;
+ return this.sendAttack(traitor);
}
return false;
};
@@ -270,8 +272,7 @@ export class AiAttackBehavior {
(!this.isFFA() || enemy.troops() < this.player.troops() * 3),
);
if (afk) {
- this.sendAttack(afk);
- return true;
+ return this.sendAttack(afk);
}
return false;
};
@@ -281,8 +282,7 @@ export class AiAttackBehavior {
const nuked = (): boolean => {
if (this.isBorderingNukedTerritory()) {
- this.sendAttack(this.game.terraNullius());
- return true;
+ return this.sendAttack(this.game.terraNullius());
}
return false;
};
@@ -290,8 +290,7 @@ export class AiAttackBehavior {
const victim = (): boolean => {
const victim = this.findVictim(borderingEnemies);
if (victim) {
- this.sendAttack(victim);
- return true;
+ return this.sendAttack(victim);
}
return false;
};
@@ -302,8 +301,7 @@ export class AiAttackBehavior {
const other = relation.player;
if (this.player.isFriendly(other)) continue;
if (this.isFFA() && other.troops() > this.player.troops() * 3) continue;
- this.sendAttack(other);
- return true;
+ return this.sendAttack(other);
}
return false;
};
@@ -311,8 +309,7 @@ export class AiAttackBehavior {
const veryWeak = (): boolean => {
const veryWeak = this.findVeryWeakEnemy(borderingEnemies);
if (veryWeak) {
- this.sendAttack(veryWeak);
- return true;
+ return this.sendAttack(veryWeak);
}
return false;
};
@@ -323,8 +320,7 @@ export class AiAttackBehavior {
const weakest = borderingEnemies[0];
// In FFA, don't attack if they have more troops than us
if (!this.isFFA() || weakest.troops() < this.player.troops()) {
- this.sendAttack(weakest);
- return true;
+ return this.sendAttack(weakest);
}
}
return false;
@@ -334,8 +330,7 @@ export class AiAttackBehavior {
if (borderingEnemies.length === 0) {
const enemy = this.findNearestIslandEnemy();
if (enemy) {
- this.sendAttack(enemy);
- return true;
+ return this.sendAttack(enemy);
}
}
return false;
@@ -365,7 +360,7 @@ export class AiAttackBehavior {
private hasNeighboringBotWithStructures(): boolean {
return this.player
- .neighbors()
+ .nearby()
.some(
(n) =>
n.isPlayer() &&
@@ -415,7 +410,7 @@ export class AiAttackBehavior {
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
private attackBots(): boolean {
const bots = this.player
- .neighbors()
+ .nearby()
.filter(
(n): n is Player =>
n.isPlayer() &&
@@ -489,9 +484,8 @@ export class AiAttackBehavior {
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
continue;
}
- // All checks passed, assist them
+ if (!this.sendAttack(target)) continue;
this.player.updateRelation(ally, -20);
- this.sendAttack(target);
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
return true;
}
@@ -529,8 +523,7 @@ export class AiAttackBehavior {
borderingFriends.length + borderingEnemies.length,
)
) {
- this.sendAttack(friend, true);
- return true;
+ return this.sendAttack(friend, true);
}
}
}
@@ -689,21 +682,19 @@ export class AiAttackBehavior {
// Retaliate against incoming attacks
const incomingAttackPlayer = this.findIncomingAttackPlayer();
if (incomingAttackPlayer) {
- this.sendAttack(incomingAttackPlayer, true);
- return;
+ if (this.sendAttack(incomingAttackPlayer, true)) return;
}
// Select a traitor as an enemy
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (this.random.chance(3)) {
- this.sendAttack(toAttack);
- return;
+ if (this.sendAttack(toAttack)) return;
}
}
// Choose a new enemy randomly
- const neighbors = this.player.neighbors();
+ const neighbors = this.player.nearby();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
@@ -715,8 +706,7 @@ export class AiAttackBehavior {
continue;
}
}
- this.sendAttack(neighbor);
- return;
+ if (this.sendAttack(neighbor)) return;
}
}
@@ -724,7 +714,7 @@ export class AiAttackBehavior {
if (this.game.config().disableAlliances()) return null;
const traitors = this.player
- .neighbors()
+ .nearby()
.filter(
(n): n is Player =>
n.isPlayer() && this.player.isFriendly(n) === false && n.isTraitor(),
@@ -742,16 +732,94 @@ export class AiAttackBehavior {
);
}
- sendAttack(target: Player | TerraNullius, force = false) {
- if (!force && !this.shouldAttack(target)) return;
+ sendAttack(target: Player | TerraNullius, force = false): boolean {
+ if (!force && !this.shouldAttack(target)) return false;
- if (this.player.sharesBorderWith(target)) {
- this.sendLandAttack(target);
- } else if (target.isPlayer()) {
- this.sendBoatAttack(target);
+ if (target.isPlayer()) {
+ if (this.player.sharesBorderWith(target)) {
+ return this.sendLandAttack(target);
+ } else {
+ return this.sendBoatAttack(target);
+ }
+ } else {
+ // sharesBorderWith(TerraNullius) counts water tiles as TN (ownerID 0 = TN smallID),
+ // so use a land-only adjacency check to decide land vs boat attack.
+ if (this.hasLandBorderWithTerraNullius()) {
+ return this.sendLandAttack(target);
+ } else {
+ return this.sendBoatAttackToNearbyTerraNullius();
+ }
}
}
+ private hasLandBorderWithTerraNullius(): boolean {
+ for (const border of this.player.borderTiles()) {
+ for (const neighbor of this.game.neighbors(border)) {
+ if (
+ this.game.isLand(neighbor) &&
+ !this.game.hasOwner(neighbor) &&
+ !this.game.hasFallout(neighbor)
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ // Scans shore border tiles (every 10th) for unowned land within 5 water tiles
+ // in each cardinal direction, then sends a transport ship to the first match.
+ private sendBoatAttackToNearbyTerraNullius(): boolean {
+ if (this.game.config().isUnitDisabled(UnitType.TransportShip)) return false;
+ if (
+ this.player.unitCount(UnitType.TransportShip) >=
+ this.game.config().boatMaxNumber()
+ )
+ return false;
+
+ const directions: [number, number][] = [
+ [0, -1],
+ [0, 1],
+ [-1, 0],
+ [1, 0],
+ ];
+ const shores = Array.from(this.player.borderTiles()).filter((t) =>
+ this.game.isShore(t),
+ );
+
+ for (let i = 0; i < shores.length; i += 10) {
+ const border = shores[i];
+
+ const bx = this.game.x(border);
+ const by = this.game.y(border);
+
+ for (const [dx, dy] of directions) {
+ const x1 = bx + dx;
+ const y1 = by + dy;
+ if (!this.game.isValidCoord(x1, y1)) continue;
+ if (!this.game.isWater(this.game.ref(x1, y1))) continue;
+
+ const nx = bx + dx * 5;
+ const ny = by + dy * 5;
+ if (!this.game.isValidCoord(nx, ny)) continue;
+ const tile = this.game.ref(nx, ny);
+ if (!this.game.isLand(tile)) continue;
+ if (this.game.hasOwner(tile)) continue;
+ if (this.game.hasFallout(tile)) continue;
+ if (!canBuildTransportShip(this.game, this.player, tile)) continue;
+
+ const troops = this.player.troops() / 5;
+ if (troops < 1) return false;
+
+ this.game.addExecution(
+ new TransportShipExecution(this.player, tile, troops),
+ );
+ return true;
+ }
+ }
+ return false;
+ }
+
shouldAttack(other: Player | TerraNullius): boolean {
if (
// Always attack Terra Nullius, non-humans and traitors
@@ -776,7 +844,7 @@ export class AiAttackBehavior {
return true;
}
- private sendLandAttack(target: Player | TerraNullius) {
+ private sendLandAttack(target: Player | TerraNullius): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const botWithStructures =
target.isPlayer() &&
@@ -803,7 +871,7 @@ export class AiAttackBehavior {
}
if (troops < 1) {
- return;
+ return false;
}
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
@@ -818,11 +886,12 @@ export class AiAttackBehavior {
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
+ return true;
}
- private sendBoatAttack(target: Player) {
+ private sendBoatAttack(target: Player): boolean {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
- return;
+ return false;
}
const closest = closestTwoTiles(
@@ -831,11 +900,11 @@ export class AiAttackBehavior {
Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)),
);
if (closest === null) {
- return;
+ return false;
}
if (!canBuildTransportShip(this.game, this.player, closest.y)) {
- return;
+ return false;
}
let troops;
@@ -846,7 +915,7 @@ export class AiAttackBehavior {
}
if (troops < 1) {
- return;
+ return false;
}
if (target.isPlayer() && this.player.type() === PlayerType.Nation) {
@@ -857,6 +926,7 @@ export class AiAttackBehavior {
this.game.addExecution(
new TransportShipExecution(this.player, closest.y, troops),
);
+ return true;
}
private calculateBotAttackTroops(target: Player, maxTroops: number): number {
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index e078712df..eb4d7f3be 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -739,7 +739,7 @@ export interface Player {
captureUnit(unit: Unit): void;
// Relations & Diplomacy
- neighbors(): (Player | TerraNullius)[];
+ nearby(): (Player | TerraNullius)[];
sharesBorderWith(other: Player | TerraNullius): boolean;
relation(other: Player): Relation;
allRelationsSorted(): { player: Player; relation: Relation }[];
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 40e92b13c..6f163598b 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -319,6 +319,7 @@ export class PlayerImpl implements Player {
}
return false;
}
+
numTilesOwned(): number {
return this._tiles.size;
}
@@ -331,7 +332,7 @@ export class PlayerImpl implements Player {
return this._borderTiles;
}
- neighbors(): (Player | TerraNullius)[] {
+ nearby(): (Player | TerraNullius)[] {
const ns: Set = new Set();
for (const border of this.borderTiles()) {
for (const neighbor of this.mg.map().neighbors(border)) {
@@ -345,9 +346,58 @@ export class PlayerImpl implements Player {
}
}
}
+ for (const n of this.shoreReachableNeighbors()) {
+ ns.add(n);
+ }
return Array.from(ns);
}
+ // Samples every 10th border tile for shore tiles, checks the tile 5 steps
+ // away in each cardinal direction that immediately enters water, to detect
+ // players separated by a small river (up to 4 water tiles wide)
+ private shoreReachableNeighbors(): Set {
+ const ns: Set = new Set();
+ const map = this.mg.map();
+ const shores = Array.from(this.borderTiles()).filter((t) => map.isShore(t));
+ const directions: [number, number][] = [
+ [0, -1],
+ [0, 1],
+ [-1, 0],
+ [1, 0],
+ ];
+
+ for (let i = 0; i < shores.length; i += 10) {
+ const border = shores[i];
+
+ const bx = map.x(border);
+ const by = map.y(border);
+
+ for (const [dx, dy] of directions) {
+ // Only follow directions that immediately enter water; land-adjacent
+ // directions are already covered by the direct neighbors() loop.
+ const x1 = bx + dx;
+ const y1 = by + dy;
+ if (!map.isValidCoord(x1, y1) || !map.isWater(map.ref(x1, y1)))
+ continue;
+
+ const nx = bx + dx * 5;
+ const ny = by + dy * 5;
+ if (!map.isValidCoord(nx, ny)) continue;
+ const tile = map.ref(nx, ny);
+ if (!map.isLand(tile)) continue;
+ if (!map.hasOwner(tile) && map.hasFallout(tile)) continue;
+ const owner = map.ownerID(tile);
+ if (owner !== this.smallID()) {
+ ns.add(
+ this.mg.playerBySmallID(owner) satisfies Player | TerraNullius,
+ );
+ }
+ }
+ }
+
+ return ns;
+ }
+
isPlayer(): this is Player {
return true as const;
}
diff --git a/tests/NationStructureBehavior.test.ts b/tests/NationStructureBehavior.test.ts
index 7f0632c40..579422ab3 100644
--- a/tests/NationStructureBehavior.test.ts
+++ b/tests/NationStructureBehavior.test.ts
@@ -47,7 +47,7 @@ function makePlayer(
): any {
return {
units: vi.fn(() => ownUnits),
- neighbors: vi.fn(() => neighborList),
+ nearby: vi.fn(() => neighborList),
canTrade: vi.fn((n: any) => opts.canTrade?.(n) ?? true),
isOnSameTeam: vi.fn((n: any) => opts.isOnSameTeam?.(n) ?? false),
isAlliedWith: vi.fn((n: any) => opts.isAlliedWith?.(n) ?? false),