From 7f7cbba12f26df5d906c985826f829cdca0675aa Mon Sep 17 00:00:00 2001
From: FloPinguin <25036848+FloPinguin@users.noreply.github.com>
Date: Thu, 9 Apr 2026 05:56:02 +0200
Subject: [PATCH] =?UTF-8?q?Water-Nukes=20=F0=9F=92=A7=20(#3604)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
Adds a new `waterNukes` game config option that causes nuclear
detonations to convert land tiles into water instead of just leaving
fallout. When enabled, nuked land tiles are batched and converted to
water each tick, with full terrain metadata updates including:
- Ocean bit propagation from adjacent ocean tiles (BFS flood fill)
- Magnitude recomputation via BFS from remaining coastlines
- Shoreline bit fix-up in a 2-ring neighborhood around converted tiles
- Minimap terrain sync (majority-rule downsampling)
- Throttled water navigation graph rebuild (every 20 ticks) for ship
pathfinding
- Ship executions detect graph rebuilds and refresh their pathfinders
- TransportShips auto-retreat if their destination becomes water
- Water nuke craters use a smoothed angular noise ring with a
bounding-box scan instead of the regular per-tile random coin flip with
BFS, producing clean blob-shaped craters without scattered land pixels
that players would have to boat to individually
The `TerrainLayer` now incrementally repaints tiles that changed terrain
type, and tile update packets encode the terrain byte alongside tile
state so clients can reflect water conversions in real time.
When `waterNukes` is disabled, behavior is unchanged (fallout only).
Includes a new test suite (WaterNukes.test.ts) covering the conversion
pipeline, ocean propagation, magnitude recalculation, shoreline updates,
and minimap sync.
Also adds a new public game modifier for the special rotation.
### The only problem
A bit of lag on impact. But otherwise it works great and is fun. Maybe
needs some followup improvements if it gets merged.
I think its very cool in baikal / four islands team games. Chip away the
territory of your opponents.
Its also fun to turn The Box / Alps into a water map (its actually
possible to boat-trade then)
### Media
Video does not show the updated craters
https://github.com/user-attachments/assets/aed8bf08-0e94-4484-b997-4de11ae313d9
Updated craters (no tiny islands after impact):
## 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
---
resources/lang/en.json | 6 +-
src/client/HostLobbyModal.ts | 11 +
src/client/JoinLobbyModal.ts | 7 +
src/client/SinglePlayerModal.ts | 12 +
src/client/Utils.ts | 6 +
src/client/graphics/layers/TerrainLayer.ts | 27 ++
src/client/graphics/layers/TerritoryLayer.ts | 9 +-
src/core/Schemas.ts | 2 +
src/core/configuration/Config.ts | 1 +
src/core/configuration/DefaultConfig.ts | 3 +
src/core/execution/AttackExecution.ts | 3 +
src/core/execution/NukeExecution.ts | 61 ++-
src/core/execution/TradeShipExecution.ts | 12 +-
src/core/execution/TransportShipExecution.ts | 23 +-
src/core/execution/WarshipExecution.ts | 8 +-
src/core/game/Game.ts | 6 +
src/core/game/GameImpl.ts | 155 +++----
src/core/game/GameMap.ts | 62 ++-
src/core/game/GameView.ts | 33 +-
src/core/game/WaterManager.ts | 428 ++++++++++++++++++
src/core/pathfinding/PathFinder.ts | 48 +-
.../algorithms/AStar.AbstractGraph.ts | 5 +-
.../pathfinding/algorithms/AbstractGraph.ts | 9 +-
.../algorithms/ConnectedComponents.ts | 4 -
src/server/GameServer.ts | 3 +
src/server/MapPlaylist.ts | 41 +-
tests/nukes/WaterNukes.test.ts | 246 ++++++++++
tests/pathfinding/utils.ts | 11 +-
28 files changed, 1113 insertions(+), 129 deletions(-)
create mode 100644 src/core/game/WaterManager.ts
create mode 100644 tests/nukes/WaterNukes.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index d62436257..fd977f7c3 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -201,6 +201,7 @@
"infinite_troops": "Infinite troops",
"compact_map": "Compact Map",
"disable_alliances": "Disable alliances",
+ "water_nukes": "Water nukes",
"max_timer": "Game length (minutes)",
"max_timer_placeholder": "Mins",
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
@@ -435,6 +436,7 @@
"donate_troops": "Donate troops",
"compact_map": "Compact Map",
"disable_alliances": "Disable alliances",
+ "water_nukes": "Water nukes",
"enables_title": "Enable Settings",
"player": "Player",
"players": "Players",
@@ -514,7 +516,9 @@
"sams_disabled": "SAMs Disabled",
"sams_disabled_label": "SAMs",
"peace_time": "4min Peace",
- "peace_time_label": "PVP Immunity"
+ "peace_time_label": "PVP Immunity",
+ "water_nukes": "Water Nukes",
+ "water_nukes_label": "Water Nukes"
},
"select_lang": {
"title": "Select Language"
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 53c1befcb..a7b2a88b5 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -72,6 +72,7 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
+ @state() private waterNukes: boolean = false;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@state() private clients: ClientInfo[] = [];
@@ -299,6 +300,10 @@ export class HostLobbyModal extends BaseModal {
labelKey: "host_modal.disable_alliances",
checked: this.disableAlliances,
},
+ {
+ labelKey: "host_modal.water_nukes",
+ checked: this.waterNukes,
+ },
],
inputCards,
},
@@ -463,6 +468,7 @@ export class HostLobbyModal extends BaseModal {
this.startingGold = false;
this.startingGoldValue = undefined;
this.disableAlliances = false;
+ this.waterNukes = false;
this.leaveLobbyOnClose = true;
}
@@ -543,6 +549,10 @@ export class HostLobbyModal extends BaseModal {
this.disableAlliances = checked;
this.putGameConfig();
break;
+ case "host_modal.water_nukes":
+ this.waterNukes = checked;
+ this.putGameConfig();
+ break;
default:
break;
}
@@ -803,6 +813,7 @@ export class HostLobbyModal extends BaseModal {
? Math.round(this.startingGoldValue * 1_000_000)
: null,
disableAlliances: this.disableAlliances || null,
+ waterNukes: this.waterNukes ? true : null,
} satisfies Partial,
},
bubbles: true,
diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts
index f4692a0d1..785b7cadb 100644
--- a/src/client/JoinLobbyModal.ts
+++ b/src/client/JoinLobbyModal.ts
@@ -552,6 +552,13 @@ export class JoinLobbyModal extends BaseModal {
.value=${translateText("common.disabled")}
>`,
);
+ if (c.waterNukes)
+ cards.push(
+ html``,
+ );
if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
cards.push(
html` 0
);
}
@@ -411,6 +418,7 @@ export class SinglePlayerModal extends BaseModal {
this.startingGold = DEFAULT_OPTIONS.startingGold;
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
+ this.waterNukes = DEFAULT_OPTIONS.waterNukes;
}
protected onOpen(): void {
@@ -493,6 +501,9 @@ export class SinglePlayerModal extends BaseModal {
case "single_modal.disable_alliances":
this.disableAlliances = checked;
break;
+ case "single_modal.water_nukes":
+ this.waterNukes = checked;
+ break;
default:
break;
}
@@ -700,6 +711,7 @@ export class SinglePlayerModal extends BaseModal {
}
: {}),
...(this.disableAlliances ? { disableAlliances: true } : {}),
+ ...(this.waterNukes ? { waterNukes: true } : {}),
},
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
},
diff --git a/src/client/Utils.ts b/src/client/Utils.ts
index 3677bbbeb..419d1fe21 100644
--- a/src/client/Utils.ts
+++ b/src/client/Utils.ts
@@ -210,6 +210,12 @@ export function getActiveModifiers(
badgeKey: "public_game_modifier.peace_time",
});
}
+ if (modifiers.isWaterNukes) {
+ result.push({
+ labelKey: "public_game_modifier.water_nukes_label",
+ badgeKey: "public_game_modifier.water_nukes",
+ });
+ }
return result;
}
diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts
index 542d21612..353555912 100644
--- a/src/client/graphics/layers/TerrainLayer.ts
+++ b/src/client/graphics/layers/TerrainLayer.ts
@@ -22,6 +22,33 @@ export class TerrainLayer implements Layer {
tick() {
if (this.config.theme() !== this.theme) {
this.redraw();
+ return;
+ }
+ // Repaint terrain for tiles whose terrain changed (e.g. nuke
+ // turning land to water).
+ const updatedTiles = this.game.recentlyUpdatedTerrainTiles();
+ if (updatedTiles.length > 0) {
+ let dirty = false;
+ for (const tile of updatedTiles) {
+ const terrainColor = this.theme.terrainColor(this.game, tile);
+ const offset = tile * 4;
+ const r = terrainColor.rgba.r;
+ const g = terrainColor.rgba.g;
+ const b = terrainColor.rgba.b;
+ if (
+ this.imageData.data[offset] !== r ||
+ this.imageData.data[offset + 1] !== g ||
+ this.imageData.data[offset + 2] !== b
+ ) {
+ this.imageData.data[offset] = r;
+ this.imageData.data[offset + 1] = g;
+ this.imageData.data[offset + 2] = b;
+ dirty = true;
+ }
+ }
+ if (dirty) {
+ this.context.putImageData(this.imageData, 0, 0);
+ }
}
}
diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts
index cc66b2eb9..a999380e1 100644
--- a/src/client/graphics/layers/TerritoryLayer.ts
+++ b/src/client/graphics/layers/TerritoryLayer.ts
@@ -81,7 +81,14 @@ export class TerritoryLayer implements Layer {
this.spawnHighlight();
}
- this.game.recentlyUpdatedTiles().forEach((t) => this.enqueueTile(t));
+ this.game.recentlyUpdatedTiles().forEach((t) => {
+ this.enqueueTile(t);
+ // Immediately clear territory overlay for water tiles so old
+ // borders/territory don't persist visually (e.g. after nuke turns land to water)
+ if (this.game.isWater(t)) {
+ this.clearTile(t);
+ }
+ });
const updates = this.game.updatesSinceLastTick();
const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : [];
unitUpdates.forEach((update) => {
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 7f7b867c3..a1177ec34 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -234,6 +234,7 @@ export const GameConfigSchema = z.object({
isNukesDisabled: z.boolean().optional(),
isSAMsDisabled: z.boolean().optional(),
isPeaceTime: z.boolean().optional(),
+ isWaterNukes: z.boolean().optional(),
})
.optional(),
nations: z
@@ -248,6 +249,7 @@ export const GameConfigSchema = z.object({
instantBuild: z.boolean(),
disableNavMesh: z.boolean().optional(),
disableAlliances: z.boolean().nullable().optional(),
+ waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 990da255b..42703f6b2 100644
--- a/src/core/configuration/Config.ts
+++ b/src/core/configuration/Config.ts
@@ -75,6 +75,7 @@ export interface Config {
instantBuild(): boolean;
disableNavMesh(): boolean;
disableAlliances(): boolean;
+ waterNukes(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts
index 85d3b661f..e3f4f293b 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -246,6 +246,9 @@ export class DefaultConfig implements Config {
disableAlliances(): boolean {
return this._gameConfig.disableAlliances ?? false;
}
+ waterNukes(): boolean {
+ return this._gameConfig.waterNukes ?? false;
+ }
isRandomSpawn(): boolean {
return this._gameConfig.randomSpawn;
}
diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts
index 6021fbc09..71def2aca 100644
--- a/src/core/execution/AttackExecution.ts
+++ b/src/core/execution/AttackExecution.ts
@@ -283,6 +283,9 @@ export class AttackExecution implements Execution {
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
continue;
}
+ if (!this.mg.isLand(tileToConquer)) {
+ continue;
+ }
this.addNeighbors(tileToConquer);
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts
index 9928be095..89d755831 100644
--- a/src/core/execution/NukeExecution.ts
+++ b/src/core/execution/NukeExecution.ts
@@ -63,10 +63,60 @@ export class NukeExecution implements Execution {
const rand = new PseudoRandom(this.mg.ticks());
const inner2 = magnitude.inner * magnitude.inner;
const outer2 = magnitude.outer * magnitude.outer;
- this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
- const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
- return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
- });
+
+ if (this.mg.config().waterNukes()) {
+ // Smooth irregular boundary for water nukes.
+ // Generate random radii at angular samples, then smooth them so the
+ // boundary undulates gently instead of creating spiky flower shapes.
+ // This avoids scattered land pixels that players would have to boat
+ // to individually in order to reclaim.
+ const NUM_SAMPLES = 16;
+ const radiiSq: number[] = new Array(NUM_SAMPLES);
+ for (let i = 0; i < NUM_SAMPLES; i++) {
+ radiiSq[i] = rand.nextFloat(inner2, outer2);
+ }
+ // Smooth the ring: 1 light pass (60% original, 20% each neighbour)
+ const prev = [...radiiSq];
+ for (let i = 0; i < NUM_SAMPLES; i++) {
+ const l = (i - 1 + NUM_SAMPLES) % NUM_SAMPLES;
+ const r = (i + 1) % NUM_SAMPLES;
+ radiiSq[i] = prev[i] * 0.6 + prev[l] * 0.2 + prev[r] * 0.2;
+ }
+
+ const cx = this.mg.x(this.dst);
+ const cy = this.mg.y(this.dst);
+ const outer = magnitude.outer;
+
+ const result = new Set();
+ const x0 = Math.max(0, cx - outer);
+ const y0 = Math.max(0, cy - outer);
+ const x1 = Math.min(this.mg.width() - 1, cx + outer);
+ const y1 = Math.min(this.mg.height() - 1, cy + outer);
+ for (let py = y0; py <= y1; py++) {
+ for (let px = x0; px <= x1; px++) {
+ const dx = px - cx;
+ const dy = py - cy;
+ const d2 = dx * dx + dy * dy;
+ if (d2 > outer2) continue;
+ if (d2 > inner2) {
+ const angle = Math.atan2(dy, dx) + Math.PI; // [0, 2π]
+ const t = (angle / (2 * Math.PI)) * NUM_SAMPLES;
+ const i0 = Math.floor(t) % NUM_SAMPLES;
+ const i1 = (i0 + 1) % NUM_SAMPLES;
+ const frac = t - Math.floor(t);
+ const threshold = radiiSq[i0] * (1 - frac) + radiiSq[i1] * frac;
+ if (d2 > threshold) continue;
+ }
+ result.add(this.mg.ref(px, py));
+ }
+ }
+ this.tilesToDestroyCache = result;
+ } else {
+ this.tilesToDestroyCache = this.mg.bfs(this.dst, (_, n: TileRef) => {
+ const d2 = this.mg?.euclideanDistSquared(this.dst, n) ?? 0;
+ return d2 <= outer2 && (d2 <= inner2 || rand.chance(2));
+ });
+ }
return this.tilesToDestroyCache;
}
@@ -266,8 +316,9 @@ export class NukeExecution implements Execution {
tilesPerPlayers.set(owner, (tilesPerPlayers.get(owner) ?? 0) + 1);
}
+ // Queue land tiles for batched water conversion
if (mg.isLand(tile)) {
- mg.setFallout(tile, true);
+ mg.queueWaterConversion(tile);
}
}
diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts
index e1efba627..9eaa2c092 100644
--- a/src/core/execution/TradeShipExecution.ts
+++ b/src/core/execution/TradeShipExecution.ts
@@ -8,8 +8,8 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
-import { PathFinding } from "../pathfinding/PathFinder";
-import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
+import { WaterPathFinder } from "../pathfinding/PathFinder";
+import { PathStatus } from "../pathfinding/types";
import { findClosestBy } from "../Util";
export class TradeShipExecution implements Execution {
@@ -17,7 +17,7 @@ export class TradeShipExecution implements Execution {
private mg: Game;
private tradeShip: Unit | undefined;
private wasCaptured = false;
- private pathFinder: SteppingPathFinder;
+ private pathFinder: WaterPathFinder;
private tilesTraveled = 0;
private motionPlanId = 1;
private motionPlanDst: TileRef | null = null;
@@ -30,10 +30,14 @@ export class TradeShipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
- this.pathFinder = PathFinding.Water(mg);
+ this.pathFinder = new WaterPathFinder(mg);
}
tick(ticks: number): void {
+ if (this.pathFinder.rebuilt) {
+ this.motionPlanDst = null; // Force motion plan re-recording
+ }
+
if (this.tradeShip === undefined) {
const spawn = this.origOwner.canBuild(
UnitType.TradeShip,
diff --git a/src/core/execution/TransportShipExecution.ts b/src/core/execution/TransportShipExecution.ts
index e24a9ac3b..f80001bc7 100644
--- a/src/core/execution/TransportShipExecution.ts
+++ b/src/core/execution/TransportShipExecution.ts
@@ -12,8 +12,8 @@ import {
import { TileRef } from "../game/GameMap";
import { MotionPlanRecord } from "../game/MotionPlans";
import { targetTransportTile } from "../game/TransportShipUtils";
-import { PathFinding } from "../pathfinding/PathFinder";
-import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
+import { WaterPathFinder } from "../pathfinding/PathFinder";
+import { PathStatus } from "../pathfinding/types";
import { AttackExecution } from "./AttackExecution";
const malusForRetreat = 25;
@@ -27,7 +27,7 @@ export class TransportShipExecution implements Execution {
private mg: Game;
private target: Player | TerraNullius;
- private pathFinder: SteppingPathFinder;
+ private pathFinder: WaterPathFinder;
private dst: TileRef | null;
private src: TileRef | null;
@@ -60,7 +60,7 @@ export class TransportShipExecution implements Execution {
this.lastMove = ticks;
this.mg = mg;
this.target = mg.owner(this.ref);
- this.pathFinder = PathFinding.Water(mg);
+ this.pathFinder = new WaterPathFinder(mg);
if (
this.attacker.unitCount(UnitType.TransportShip) >=
@@ -186,6 +186,21 @@ export class TransportShipExecution implements Execution {
this.originalOwner = boatOwner; // for when this owner disconnects too
}
+ if (this.pathFinder.rebuilt) {
+ this.motionPlanDst = null; // Force motion plan re-recording
+ }
+
+ // Auto-retreat if destination was destroyed by nuke (turned to water)
+ // Checked every tick (not just on graph rebuild) because graph rebuilds
+ // are throttled and the tile may already be water before the version bumps.
+ if (this.dst !== null && this.mg.isWater(this.dst)) {
+ if (!this.boat.retreating()) {
+ this.boat.orderBoatRetreat();
+ }
+ // Reset cached retreat destination so it's recomputed from current position
+ this.retreatDst = null;
+ }
+
if (this.boat.retreating()) {
// Resolve retreat destination once, based on current boat location when retreat begins.
this.retreatDst ??= this.attacker.bestTransportShipSpawn(
diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts
index 70bfb654c..e8b736bc2 100644
--- a/src/core/execution/WarshipExecution.ts
+++ b/src/core/execution/WarshipExecution.ts
@@ -8,8 +8,8 @@ import {
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
-import { PathFinding } from "../pathfinding/PathFinder";
-import { PathStatus, SteppingPathFinder } from "../pathfinding/types";
+import { WaterPathFinder } from "../pathfinding/PathFinder";
+import { PathStatus } from "../pathfinding/types";
import { PseudoRandom } from "../PseudoRandom";
import { ShellExecution } from "./ShellExecution";
@@ -17,7 +17,7 @@ export class WarshipExecution implements Execution {
private random: PseudoRandom;
private warship: Unit;
private mg: Game;
- private pathfinder: SteppingPathFinder;
+ private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set();
@@ -27,7 +27,7 @@ export class WarshipExecution implements Execution {
init(mg: Game, ticks: number): void {
this.mg = mg;
- this.pathfinder = PathFinding.Water(mg);
+ this.pathfinder = new WaterPathFinder(mg);
this.random = new PseudoRandom(mg.ticks());
if (isUnit(this.input)) {
this.warship = this.input;
diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts
index 6e4ffe25a..f15707387 100644
--- a/src/core/game/Game.ts
+++ b/src/core/game/Game.ts
@@ -269,6 +269,7 @@ export interface PublicGameModifiers {
isNukesDisabled?: boolean;
isSAMsDisabled?: boolean;
isPeaceTime?: boolean;
+ isWaterNukes?: boolean;
}
export interface UnitInfo {
@@ -915,6 +916,11 @@ export interface Game extends GameMap {
miniWaterGraph(): AbstractGraph | null;
getWaterComponent(tile: TileRef): number | null;
hasWaterComponent(tile: TileRef, component: number): boolean;
+ /** Incremented each time the water navigation graph is rebuilt (e.g. after nuke terrain change). */
+ waterGraphVersion(): number;
+
+ /** Queue a land tile for conversion to water (batched every few ticks). Tile must be unowned. */
+ queueWaterConversion(tile: TileRef): void;
}
export interface PlayerActions {
diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts
index ab2a179f5..35bf005ba 100644
--- a/src/core/game/GameImpl.ts
+++ b/src/core/game/GameImpl.ts
@@ -1,10 +1,6 @@
import { renderNumber } from "../../client/Utils";
import { Config } from "../configuration/Config";
-import {
- AbstractGraph,
- AbstractGraphBuilder,
-} from "../pathfinding/algorithms/AbstractGraph";
-import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
+import { AbstractGraph } from "../pathfinding/algorithms/AbstractGraph";
import { PathFinder } from "../pathfinding/types";
import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import { ATTACK_INDEX_SENT } from "../StatsSchemas";
@@ -52,6 +48,7 @@ import { StatsImpl } from "./StatsImpl";
import { assignTeams } from "./TeamAssignment";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
+import { WaterManager } from "./WaterManager";
export function createGame(
humans: PlayerInfo[],
@@ -109,8 +106,7 @@ export class GameImpl implements Game {
private _isPaused: boolean = false;
private _winner: Player | Team | null = null;
- private _miniWaterGraph: AbstractGraph | null = null;
- private _miniWaterHPA: AStarWaterHierarchical | null = null;
+ private _waterManager: WaterManager;
private _teamGameSpawnAreas: TeamGameSpawnAreas | undefined;
constructor(
@@ -129,23 +125,17 @@ export class GameImpl implements Game {
this._width = _map.width();
this._height = _map.height();
this.unitGrid = new UnitGrid(this._map);
+ this._waterManager = new WaterManager(
+ this._map,
+ this.miniGameMap,
+ _config.disableNavMesh(),
+ );
if (_config.gameConfig().gameMode === GameMode.Team) {
this.populateTeams();
}
this.addPlayers();
- if (!_config.disableNavMesh()) {
- const graphBuilder = new AbstractGraphBuilder(this.miniGameMap);
- this._miniWaterGraph = graphBuilder.build();
-
- this._miniWaterHPA = new AStarWaterHierarchical(
- this.miniGameMap,
- this._miniWaterGraph,
- { cachePaths: true },
- );
- }
-
console.log(
`[GameImpl] Constructor total: ${(performance.now() - constructorStart).toFixed(0)}ms`,
);
@@ -269,6 +259,31 @@ export class GameImpl implements Game {
this.recordTileUpdate(tile);
}
+ setWater(tile: TileRef): void {
+ if (!this.isLand(tile)) return;
+ if (this.hasOwner(tile)) {
+ throw Error(`cannot set water, tile ${tile} has owner`);
+ }
+ // Clear fallout if present (water tiles shouldn't have fallout)
+ if (this._map.hasFallout(tile)) {
+ this._map.setFallout(tile, false);
+ }
+ this._map.setWater(tile);
+ this.recordTileUpdate(tile);
+ }
+
+ queueWaterConversion(tile: TileRef): void {
+ if (!this.isLand(tile)) return;
+ if (this.hasOwner(tile)) {
+ throw Error(`cannot queue water conversion, tile ${tile} has owner`);
+ }
+ if (!this._config.waterNukes()) {
+ this.setFallout(tile, true);
+ return;
+ }
+ this._waterManager.queueTile(tile);
+ }
+
units(...types: UnitType[]): Unit[] {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
}
@@ -429,12 +444,22 @@ export class GameImpl implements Game {
hash: this.hash(),
});
}
+ // Flush pending water conversions + throttled graph rebuild
+ const waterChangedTiles = this._waterManager.tick(this._ticks);
+ for (const tile of waterChangedTiles) {
+ this.recordTileUpdate(tile);
+ }
this._ticks++;
return this.updates;
}
private recordTileUpdate(tile: TileRef): void {
- this.tileUpdatePairs.push(tile, this._map.tileState(tile));
+ // Low 16 bits: tile state, bits 16-23: terrain byte
+ this.tileUpdatePairs.push(
+ tile,
+ (this._map.tileState(tile) & 0xffff) |
+ (this._map.terrainByte(tile) << 16),
+ );
}
drainPackedTileUpdates(): Uint32Array {
@@ -1034,6 +1059,21 @@ export class GameImpl implements Game {
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
+ terrainByte(ref: TileRef): number {
+ return this._map.terrainByte(ref);
+ }
+ setShorelineBit(ref: TileRef): void {
+ this._map.setShorelineBit(ref);
+ }
+ clearShorelineBit(ref: TileRef): void {
+ this._map.clearShorelineBit(ref);
+ }
+ setOcean(ref: TileRef): void {
+ this._map.setOcean(ref);
+ }
+ setMagnitude(ref: TileRef, value: number): void {
+ this._map.setMagnitude(ref, value);
+ }
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
@@ -1101,8 +1141,8 @@ export class GameImpl implements Game {
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
- updateTile(tile: TileRef, state: number): void {
- this._map.updateTile(tile, state);
+ updateTile(tile: TileRef, state: number): boolean {
+ return this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
@@ -1114,78 +1154,19 @@ export class GameImpl implements Game {
return this._railNetwork;
}
miniWaterHPA(): PathFinder | null {
- return this._miniWaterHPA;
+ return this._waterManager.miniWaterHPA();
}
miniWaterGraph(): AbstractGraph | null {
- return this._miniWaterGraph;
+ return this._waterManager.miniWaterGraph();
+ }
+ waterGraphVersion(): number {
+ return this._waterManager.waterGraphVersion();
}
getWaterComponent(tile: TileRef): number | null {
- // Permissive fallback for tests with disableNavMesh
- if (!this._miniWaterGraph) return 0;
-
- const miniX = Math.floor(this._map.x(tile) / 2);
- const miniY = Math.floor(this._map.y(tile) / 2);
- const miniTile = this.miniGameMap.ref(miniX, miniY);
-
- if (this.miniGameMap.isWater(miniTile)) {
- return this._miniWaterGraph.getComponentId(miniTile);
- }
-
- // Shore tile: find water neighbor (expand search for minimap resolution loss)
- for (const n of this.miniGameMap.neighbors(miniTile)) {
- if (this.miniGameMap.isWater(n)) {
- return this._miniWaterGraph.getComponentId(n);
- }
- }
-
- // Extended search: check 2-hop neighbors for narrow straits
- for (const n of this.miniGameMap.neighbors(miniTile)) {
- for (const n2 of this.miniGameMap.neighbors(n)) {
- if (this.miniGameMap.isWater(n2)) {
- return this._miniWaterGraph.getComponentId(n2);
- }
- }
- }
- return null;
+ return this._waterManager.getWaterComponent(tile);
}
hasWaterComponent(tile: TileRef, component: number): boolean {
- // Permissive fallback for tests with disableNavMesh
- if (!this._miniWaterGraph) return true;
-
- const miniX = Math.floor(this._map.x(tile) / 2);
- const miniY = Math.floor(this._map.y(tile) / 2);
- const miniTile = this.miniGameMap.ref(miniX, miniY);
-
- // Check miniTile itself (shore in full map may be water in minimap)
- if (
- this.miniGameMap.isWater(miniTile) &&
- this._miniWaterGraph.getComponentId(miniTile) === component
- ) {
- return true;
- }
-
- // Check neighbors
- for (const n of this.miniGameMap.neighbors(miniTile)) {
- if (
- this.miniGameMap.isWater(n) &&
- this._miniWaterGraph.getComponentId(n) === component
- ) {
- return true;
- }
- }
-
- // Extended search: check 2-hop neighbors for narrow straits
- for (const n of this.miniGameMap.neighbors(miniTile)) {
- for (const n2 of this.miniGameMap.neighbors(n)) {
- if (
- this.miniGameMap.isWater(n2) &&
- this._miniWaterGraph.getComponentId(n2) === component
- ) {
- return true;
- }
- }
- }
- return false;
+ return this._waterManager.hasWaterComponent(tile, component);
}
conquerPlayer(conqueror: Player, conquered: Player) {
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts
index 7ddb686e1..b885a9403 100644
--- a/src/core/game/GameMap.ts
+++ b/src/core/game/GameMap.ts
@@ -13,12 +13,19 @@ export interface GameMap {
numLandTiles(): number;
isValidCoord(x: number, y: number): boolean;
- // Terrain getters (immutable)
+ // Terrain getters
isLand(ref: TileRef): boolean;
isOceanShore(ref: TileRef): boolean;
isOcean(ref: TileRef): boolean;
isShoreline(ref: TileRef): boolean;
magnitude(ref: TileRef): number;
+ terrainByte(ref: TileRef): number;
+ // Terrain setters
+ setWater(ref: TileRef): void;
+ setShorelineBit(ref: TileRef): void;
+ clearShorelineBit(ref: TileRef): void;
+ setOcean(ref: TileRef): void;
+ setMagnitude(ref: TileRef, value: number): void;
// State getters and setters (mutable)
ownerID(ref: TileRef): number;
hasOwner(ref: TileRef): boolean;
@@ -60,8 +67,10 @@ export interface GameMap {
*
* `state` must be an unsigned 16-bit value (`0..65535`). Implementations may
* store this in a `Uint16Array` and will truncate higher bits if provided.
+ *
+ * Returns `true` when the terrain byte changed (land/water/shoreline/magnitude).
*/
- updateTile(tile: TileRef, state: number): void;
+ updateTile(tile: TileRef, state: number): boolean;
numTilesWithFallout(): number;
}
@@ -184,6 +193,34 @@ export class GameMapImpl implements GameMap {
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
}
+ terrainByte(ref: TileRef): number {
+ return this.terrain[ref];
+ }
+
+ setWater(ref: TileRef): void {
+ if (!this.isLand(ref)) return;
+ this.terrain[ref] = 0; // Lake water: no land, no ocean, no shoreline, magnitude 0
+ this.numLandTiles_--;
+ }
+
+ setShorelineBit(ref: TileRef): void {
+ this.terrain[ref] |= 1 << GameMapImpl.SHORELINE_BIT;
+ }
+
+ clearShorelineBit(ref: TileRef): void {
+ this.terrain[ref] &= ~(1 << GameMapImpl.SHORELINE_BIT);
+ }
+
+ setOcean(ref: TileRef): void {
+ this.terrain[ref] |= 1 << GameMapImpl.OCEAN_BIT;
+ }
+
+ setMagnitude(ref: TileRef, value: number): void {
+ this.terrain[ref] =
+ (this.terrain[ref] & ~GameMapImpl.MAGNITUDE_MASK) |
+ (value & GameMapImpl.MAGNITUDE_MASK);
+ }
+
// State getters and setters (mutable)
ownerID(ref: TileRef): number {
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
@@ -357,7 +394,15 @@ export class GameMapImpl implements GameMap {
return this.state[tile];
}
- updateTile(tile: TileRef, state: number): void {
+ /**
+ * Update a tile from a packed uint32:
+ * bits 0-15: tile state (owner, fallout, etc.)
+ * bits 16-23: terrain byte (land, ocean, shoreline, magnitude)
+ */
+ updateTile(tile: TileRef, packed: number): boolean {
+ const state = packed & 0xffff;
+ const terrainByte = (packed >>> 16) & 0xff;
+
const existingFallout = this.hasFallout(tile);
this.state[tile] = state;
const newFallout = this.hasFallout(tile);
@@ -367,6 +412,17 @@ export class GameMapImpl implements GameMap {
if (!existingFallout && newFallout) {
this._numTilesWithFallout++;
}
+
+ // Update terrain if the packed value includes a terrain byte that differs
+ const terrainChanged = this.terrain[tile] !== terrainByte;
+ if (terrainChanged) {
+ const wasLand = this.isLand(tile);
+ this.terrain[tile] = terrainByte;
+ const isNowLand = Boolean(terrainByte & (1 << GameMapImpl.IS_LAND_BIT));
+ if (wasLand && !isNowLand) this.numLandTiles_--;
+ else if (!wasLand && isNowLand) this.numLandTiles_++;
+ }
+ return terrainChanged;
}
}
diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts
index da1b5f6bf..780e25e9c 100644
--- a/src/core/game/GameView.ts
+++ b/src/core/game/GameView.ts
@@ -648,6 +648,7 @@ export class GameView implements GameMap {
private _players = new Map();
private _units = new Map();
private updatedTiles: TileRef[] = [];
+ private updatedTerrainTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
@@ -758,12 +759,16 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
+ this.updatedTerrainTiles = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
- this.updateTile(tile, state);
+ const terrainChanged = this.updateTile(tile, state);
this.updatedTiles.push(tile);
+ if (terrainChanged) {
+ this.updatedTerrainTiles.push(tile);
+ }
}
if (gu.packedMotionPlans) {
@@ -1078,6 +1083,10 @@ export class GameView implements GameMap {
return this.updatedTiles;
}
+ recentlyUpdatedTerrainTiles(): TileRef[] {
+ return this.updatedTerrainTiles;
+ }
+
nearbyUnits(
tile: TileRef,
searchRange: number,
@@ -1261,6 +1270,24 @@ export class GameView implements GameMap {
magnitude(ref: TileRef): number {
return this._map.magnitude(ref);
}
+ terrainByte(ref: TileRef): number {
+ return this._map.terrainByte(ref);
+ }
+ setWater(ref: TileRef): void {
+ this._map.setWater(ref);
+ }
+ setShorelineBit(ref: TileRef): void {
+ this._map.setShorelineBit(ref);
+ }
+ clearShorelineBit(ref: TileRef): void {
+ this._map.clearShorelineBit(ref);
+ }
+ setOcean(ref: TileRef): void {
+ this._map.setOcean(ref);
+ }
+ setMagnitude(ref: TileRef, value: number): void {
+ this._map.setMagnitude(ref, value);
+ }
ownerID(ref: TileRef): number {
return this._map.ownerID(ref);
}
@@ -1322,8 +1349,8 @@ export class GameView implements GameMap {
tileState(tile: TileRef): number {
return this._map.tileState(tile);
}
- updateTile(tile: TileRef, state: number): void {
- this._map.updateTile(tile, state);
+ updateTile(tile: TileRef, state: number): boolean {
+ return this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts
new file mode 100644
index 000000000..d3960b010
--- /dev/null
+++ b/src/core/game/WaterManager.ts
@@ -0,0 +1,428 @@
+import {
+ AbstractGraph,
+ AbstractGraphBuilder,
+} from "../pathfinding/algorithms/AbstractGraph";
+import { AStarWaterHierarchical } from "../pathfinding/algorithms/AStar.WaterHierarchical";
+import { PathFinder } from "../pathfinding/types";
+import { GameMap, TileRef } from "./GameMap";
+
+const WATER_GRAPH_REBUILD_INTERVAL = 20;
+
+export class WaterManager {
+ private _miniWaterGraph: AbstractGraph | null = null;
+ private _miniWaterHPA: AStarWaterHierarchical | null = null;
+ private _waterGraphVersion: number = 0;
+ private _waterGraphDirty: boolean = false;
+ private _waterGraphLastRebuildTick: number = 0;
+
+ private _pendingWaterTiles: Set = new Set();
+
+ // Reusable stamp-based distance tracking for magnitude BFS (avoids allocation per nuke)
+ private _waterDistArr: Uint16Array | null = null;
+ private _waterStampArr: Uint16Array | null = null;
+ private _waterStamp: number = 0;
+
+ constructor(
+ private map: GameMap,
+ private miniMap: GameMap,
+ private disableNavMesh: boolean,
+ ) {
+ if (!disableNavMesh) {
+ const graphBuilder = new AbstractGraphBuilder(miniMap);
+ this._miniWaterGraph = graphBuilder.build();
+ this._miniWaterHPA = new AStarWaterHierarchical(
+ miniMap,
+ this._miniWaterGraph,
+ { cachePaths: true },
+ );
+ }
+ }
+
+ queueTile(tile: TileRef): void {
+ this._pendingWaterTiles.add(tile);
+ }
+
+ /**
+ * Flush pending water conversions, run terrain fixup (ocean/magnitude/shoreline/minimap),
+ * and throttled graph rebuild. Returns tiles whose terrain changed (for recording).
+ */
+ tick(currentTick: number): TileRef[] {
+ const changedTiles: TileRef[] = [];
+
+ if (this._pendingWaterTiles.size > 0) {
+ const converted: TileRef[] = [];
+ for (const tile of this._pendingWaterTiles) {
+ // Tile may have been conquered between queueing and flushing
+ if (this.map.isLand(tile) && !this.map.hasOwner(tile)) {
+ if (this.map.hasFallout(tile)) {
+ this.map.setFallout(tile, false);
+ }
+ this.map.setWater(tile);
+ converted.push(tile);
+ }
+ }
+ this._pendingWaterTiles.clear();
+ if (converted.length > 0) {
+ this.finalizeWaterChanges(converted, changedTiles);
+ }
+ }
+
+ // Throttled water graph rebuild: at most once every 20 ticks
+ if (
+ this._waterGraphDirty &&
+ !this.disableNavMesh &&
+ currentTick - this._waterGraphLastRebuildTick >=
+ WATER_GRAPH_REBUILD_INTERVAL
+ ) {
+ this._waterGraphDirty = false;
+ this._waterGraphLastRebuildTick = currentTick;
+ const graphBuilder = new AbstractGraphBuilder(this.miniMap);
+ this._miniWaterGraph = graphBuilder.build();
+ this._miniWaterHPA = new AStarWaterHierarchical(
+ this.miniMap,
+ this._miniWaterGraph,
+ { cachePaths: true },
+ );
+ this._waterGraphVersion++;
+ }
+
+ return changedTiles;
+ }
+
+ waterGraphVersion(): number {
+ return this._waterGraphVersion;
+ }
+
+ miniWaterHPA(): PathFinder | null {
+ return this._miniWaterHPA;
+ }
+
+ miniWaterGraph(): AbstractGraph | null {
+ return this._miniWaterGraph;
+ }
+
+ getWaterComponent(tile: TileRef): number | null {
+ // Permissive fallback for tests with disableNavMesh
+ if (!this._miniWaterGraph) return 0;
+
+ const miniX = Math.floor(this.map.x(tile) / 2);
+ const miniY = Math.floor(this.map.y(tile) / 2);
+ const miniTile = this.miniMap.ref(miniX, miniY);
+
+ if (this.miniMap.isWater(miniTile)) {
+ return this._miniWaterGraph.getComponentId(miniTile);
+ }
+
+ // Shore tile: find water neighbor (expand search for minimap resolution loss)
+ for (const n of this.miniMap.neighbors(miniTile)) {
+ if (this.miniMap.isWater(n)) {
+ return this._miniWaterGraph.getComponentId(n);
+ }
+ }
+
+ // Extended search: check 2-hop neighbors for narrow straits
+ for (const n of this.miniMap.neighbors(miniTile)) {
+ for (const n2 of this.miniMap.neighbors(n)) {
+ if (this.miniMap.isWater(n2)) {
+ return this._miniWaterGraph.getComponentId(n2);
+ }
+ }
+ }
+ return null;
+ }
+
+ hasWaterComponent(tile: TileRef, component: number): boolean {
+ // Permissive fallback for tests with disableNavMesh
+ if (!this._miniWaterGraph) return true;
+
+ const miniX = Math.floor(this.map.x(tile) / 2);
+ const miniY = Math.floor(this.map.y(tile) / 2);
+ const miniTile = this.miniMap.ref(miniX, miniY);
+
+ // Check miniTile itself (shore in full map may be water in minimap)
+ if (
+ this.miniMap.isWater(miniTile) &&
+ this._miniWaterGraph.getComponentId(miniTile) === component
+ ) {
+ return true;
+ }
+
+ // Check neighbors
+ for (const n of this.miniMap.neighbors(miniTile)) {
+ if (
+ this.miniMap.isWater(n) &&
+ this._miniWaterGraph.getComponentId(n) === component
+ ) {
+ return true;
+ }
+ }
+
+ // Extended search: check 2-hop neighbors for narrow straits
+ for (const n of this.miniMap.neighbors(miniTile)) {
+ for (const n2 of this.miniMap.neighbors(n)) {
+ if (
+ this.miniMap.isWater(n2) &&
+ this._miniWaterGraph.getComponentId(n2) === component
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private finalizeWaterChanges(
+ convertedTiles: TileRef[],
+ changedTiles: TileRef[],
+ ): void {
+ const converted = new Set(convertedTiles);
+ if (converted.size === 0) return;
+
+ const map = this.map;
+ const w = map.width();
+ const totalTiles = w * map.height();
+
+ // Track changed tiles in a set for dedup, drain into output at end
+ const changed = new Set();
+ // All converted tiles definitely changed (they just became water).
+ for (const tile of converted) changed.add(tile);
+
+ // Inline neighbor helper (no allocation, cardinal only)
+ const pushNeighbors = (
+ tile: TileRef,
+ out: TileRef[],
+ start: number,
+ ): number => {
+ if (tile >= w) out[start++] = (tile - w) as TileRef;
+ if (tile < totalTiles - w) out[start++] = (tile + w) as TileRef;
+ const x = tile % w;
+ if (x > 0) out[start++] = (tile - 1) as TileRef;
+ if (x < w - 1) out[start++] = (tile + 1) as TileRef;
+ return start;
+ };
+
+ // Reusable scratch buffer for neighbors.
+ const nb: TileRef[] = new Array(8);
+
+ // ── 1. Propagate ocean bit ─────────────────────────────────────
+ const oceanQueue: TileRef[] = [];
+ for (const tile of converted) {
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ if (!converted.has(nb[i]) && map.isOcean(nb[i])) {
+ map.setOcean(tile);
+ oceanQueue.push(tile);
+ break;
+ }
+ }
+ }
+ // 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++];
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ if (map.isWater(nb[i]) && !map.isOcean(nb[i])) {
+ map.setOcean(nb[i]);
+ changed.add(nb[i]);
+ oceanQueue.push(nb[i]);
+ }
+ }
+ }
+
+ // ── 2. Recompute magnitude via BFS from remaining land outward ─
+ if (!this._waterDistArr || this._waterDistArr.length !== totalTiles) {
+ this._waterDistArr = new Uint16Array(totalTiles);
+ this._waterStampArr = new Uint16Array(totalTiles);
+ this._waterStamp = 0;
+ }
+ this._waterStamp++;
+ if (this._waterStamp >= 0xffff) {
+ this._waterStampArr!.fill(0);
+ this._waterStamp = 1;
+ }
+ const stamp = this._waterStamp;
+ const stampArr = this._waterStampArr!;
+ const distArr = this._waterDistArr;
+
+ const magQueue: TileRef[] = [];
+
+ // Seed candidates: converted tiles + their immediate water neighbors
+ const seedCandidates = new Set(converted);
+ for (const tile of converted) {
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ if (map.isWater(nb[i]) && !converted.has(nb[i])) {
+ seedCandidates.add(nb[i]);
+ }
+ }
+ }
+ // Seed: water tiles adjacent to remaining land get distance 0
+ for (const tile of seedCandidates) {
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ if (map.isLand(nb[i])) {
+ if (stampArr[tile] !== stamp) {
+ stampArr[tile] = stamp;
+ distArr[tile] = 0;
+ if (map.magnitude(tile) !== 0) {
+ map.setMagnitude(tile, 0);
+ changed.add(tile);
+ }
+ magQueue.push(tile);
+ }
+ break;
+ }
+ }
+ }
+ // BFS outward through water, stopping at convergence.
+ let magHead = 0;
+ while (magHead < magQueue.length) {
+ const tile = magQueue[magHead++];
+ const dist = distArr[tile];
+ const nextDist = dist + 1;
+ const nextMag = Math.min(Math.ceil(nextDist / 2), 31);
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ const n = nb[i];
+ if (!map.isWater(n) || stampArr[n] === stamp) continue;
+ const oldMag = map.magnitude(n);
+ if (oldMag === nextMag && !seedCandidates.has(n)) continue;
+ stampArr[n] = stamp;
+ distArr[n] = nextDist;
+ magQueue.push(n);
+ if (oldMag !== nextMag) {
+ map.setMagnitude(n, nextMag);
+ changed.add(n);
+ }
+ }
+ }
+ // Phase 2: unreached seed candidates (fully destroyed island)
+ const MAX_DEEP_DIST = 30;
+ const DEEP_OCEAN_MAGNITUDE = 20;
+ const deepQueue: TileRef[] = [];
+ for (const tile of seedCandidates) {
+ if (stampArr[tile] !== stamp && map.isWater(tile)) {
+ stampArr[tile] = stamp;
+ distArr[tile] = 0;
+ if (map.magnitude(tile) !== DEEP_OCEAN_MAGNITUDE) {
+ map.setMagnitude(tile, DEEP_OCEAN_MAGNITUDE);
+ changed.add(tile);
+ }
+ deepQueue.push(tile);
+ }
+ }
+ let deepHead = 0;
+ while (deepHead < deepQueue.length) {
+ const tile = deepQueue[deepHead++];
+ const dist = distArr[tile];
+ if (dist >= MAX_DEEP_DIST) continue;
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ const n = nb[i];
+ if (!map.isWater(n) || stampArr[n] === stamp) continue;
+ const oldMag = map.magnitude(n);
+ if (oldMag >= DEEP_OCEAN_MAGNITUDE) continue;
+ stampArr[n] = stamp;
+ distArr[n] = dist + 1;
+ map.setMagnitude(n, DEEP_OCEAN_MAGNITUDE);
+ changed.add(n);
+ deepQueue.push(n);
+ }
+ }
+
+ // ── 3. Fix shoreline bits ──────────────────────────────────────
+ const tilesToCheck = new Set();
+ for (const tile of converted) {
+ tilesToCheck.add(tile);
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ tilesToCheck.add(nb[i]);
+ const end2 = pushNeighbors(nb[i], nb, end);
+ for (let j = end; j < end2; j++) {
+ tilesToCheck.add(nb[j]);
+ }
+ }
+ }
+ for (let i = 0; i < magQueue.length; i++) {
+ const tile = magQueue[i];
+ tilesToCheck.add(tile);
+ const end = pushNeighbors(tile, nb, 0);
+ for (let j = 0; j < end; j++) {
+ tilesToCheck.add(nb[j]);
+ }
+ }
+ for (const tile of tilesToCheck) {
+ const tileIsLand = map.isLand(tile);
+ let hasOpposite = false;
+ const end = pushNeighbors(tile, nb, 0);
+ for (let i = 0; i < end; i++) {
+ if (map.isLand(nb[i]) !== tileIsLand) {
+ hasOpposite = true;
+ break;
+ }
+ }
+ const oldShoreline = map.isShoreline(tile);
+ if (hasOpposite) {
+ if (!oldShoreline) {
+ map.setShorelineBit(tile);
+ changed.add(tile);
+ }
+ } else {
+ if (oldShoreline) {
+ map.clearShorelineBit(tile);
+ changed.add(tile);
+ }
+ }
+ }
+
+ // ── 4. Update minimap terrain ──────────────────────────────────
+ const miniTilesToCheck = new Set();
+ const convertedMiniTiles = new Set();
+ for (const tile of converted) {
+ const miniX = Math.floor(map.x(tile) / 2);
+ const miniY = Math.floor(map.y(tile) / 2);
+ if (this.miniMap.isValidCoord(miniX, miniY)) {
+ miniTilesToCheck.add(this.miniMap.ref(miniX, miniY));
+ }
+ }
+ for (const miniTile of miniTilesToCheck) {
+ if (!this.miniMap.isLand(miniTile)) continue;
+ const fx = this.miniMap.x(miniTile) * 2;
+ const fy = this.miniMap.y(miniTile) * 2;
+ let waterCount = 0;
+ let totalCount = 0;
+ for (let dy = 0; dy < 2; dy++) {
+ for (let dx = 0; dx < 2; dx++) {
+ if (map.isValidCoord(fx + dx, fy + dy)) {
+ totalCount++;
+ if (map.isWater(map.ref(fx + dx, fy + dy))) {
+ waterCount++;
+ }
+ }
+ }
+ }
+ if (waterCount >= Math.min(3, totalCount)) {
+ this.miniMap.setWater(miniTile);
+ convertedMiniTiles.add(miniTile);
+ }
+ }
+
+ // ── 5. Mark water graph dirty (rebuilt lazily, throttled) ─────
+ if (convertedMiniTiles.size > 0) {
+ this._waterGraphDirty = true;
+ }
+
+ // Drain changed set into output array
+ for (const tile of changed) {
+ changedTiles.push(tile);
+ }
+ }
+}
diff --git a/src/core/pathfinding/PathFinder.ts b/src/core/pathfinding/PathFinder.ts
index f77776c36..81a76113d 100644
--- a/src/core/pathfinding/PathFinder.ts
+++ b/src/core/pathfinding/PathFinder.ts
@@ -15,7 +15,7 @@ import { ComponentCheckTransformer } from "./transformers/ComponentCheckTransfor
import { MiniMapTransformer } from "./transformers/MiniMapTransformer";
import { ShoreCoercingTransformer } from "./transformers/ShoreCoercingTransformer";
import { SmoothingWaterTransformer } from "./transformers/SmoothingWaterTransformer";
-import { PathStatus, SteppingPathFinder } from "./types";
+import { PathResult, PathStatus, SteppingPathFinder } from "./types";
/**
* Pathfinders that work with GameMap - usable in both simulation and UI layers
@@ -89,6 +89,52 @@ export class PathFinding {
}
}
+/**
+ * Water pathfinder that auto-rebuilds when the water graph changes.
+ * Wraps SteppingPathFinder and tracks waterGraphVersion internally.
+ */
+export class WaterPathFinder implements SteppingPathFinder {
+ private inner: SteppingPathFinder;
+ private _waterGraphVersion: number;
+ private _rebuilt = false;
+
+ constructor(private game: Game) {
+ this.inner = PathFinding.Water(game);
+ this._waterGraphVersion = game.waterGraphVersion();
+ }
+
+ /** True if the pathfinder was rebuilt since the last call to `rebuilt`. Resets on read. */
+ get rebuilt(): boolean {
+ this.ensureFresh();
+ const v = this._rebuilt;
+ this._rebuilt = false;
+ return v;
+ }
+
+ private ensureFresh(): void {
+ const v = this.game.waterGraphVersion();
+ if (v !== this._waterGraphVersion) {
+ this._waterGraphVersion = v;
+ this.inner = PathFinding.Water(this.game);
+ this._rebuilt = true;
+ }
+ }
+
+ next(from: TileRef, to: TileRef, dist?: number): PathResult {
+ this.ensureFresh();
+ return this.inner.next(from, to, dist);
+ }
+
+ findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
+ this.ensureFresh();
+ return this.inner.findPath(from, to);
+ }
+
+ invalidate(): void {
+ this.inner.invalidate();
+ }
+}
+
function tileStepperConfig(game: Game): StepperConfig {
return {
equals: (a, b) => a === b,
diff --git a/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts b/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts
index 82da9f604..36f4c958a 100644
--- a/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts
+++ b/src/core/pathfinding/algorithms/AStar.AbstractGraph.ts
@@ -234,12 +234,15 @@ export class AbstractGraphAStar implements PathFinder {
return null;
}
- private buildPathFromGoal(goalId: number): number[] {
+ private buildPathFromGoal(goalId: number): number[] | null {
const path: number[] = [];
let current = goalId;
+ const maxLen = this.cameFrom.length;
while (current !== -1) {
+ if (current < 0 || current >= maxLen) return null;
path.push(current);
+ if (path.length > maxLen) return null;
current = this.cameFrom[current];
}
diff --git a/src/core/pathfinding/algorithms/AbstractGraph.ts b/src/core/pathfinding/algorithms/AbstractGraph.ts
index f22b30c65..3ab83b5ce 100644
--- a/src/core/pathfinding/algorithms/AbstractGraph.ts
+++ b/src/core/pathfinding/algorithms/AbstractGraph.ts
@@ -68,7 +68,12 @@ export class AbstractGraph {
getNodeEdges(nodeId: number): AbstractEdge[] {
const edgeIds = this._nodeEdgeIds[nodeId];
if (!edgeIds) return [];
- return edgeIds.map((id) => this._edges[id]);
+ const edges: AbstractEdge[] = [];
+ for (let i = 0; i < edgeIds.length; i++) {
+ const e = this._edges[edgeIds[i]];
+ if (e) edges.push(e);
+ }
+ return edges;
}
getEdgeBetween(nodeA: number, nodeB: number): AbstractEdge | undefined {
@@ -203,7 +208,7 @@ export class AbstractGraphBuilder {
private readonly clustersX: number;
private readonly clustersY: number;
private readonly tileBFS: BFSGrid;
- private readonly waterComponents: ConnectedComponents;
+ private waterComponents: ConnectedComponents;
// Build state
private graph!: AbstractGraph;
diff --git a/src/core/pathfinding/algorithms/ConnectedComponents.ts b/src/core/pathfinding/algorithms/ConnectedComponents.ts
index 0d379d3c1..5f42888d2 100644
--- a/src/core/pathfinding/algorithms/ConnectedComponents.ts
+++ b/src/core/pathfinding/algorithms/ConnectedComponents.ts
@@ -194,10 +194,6 @@ export class ConnectedComponents {
}
}
- /**
- * Get the component ID for a tile.
- * Returns 0 for land tiles or if not initialized.
- */
getComponentId(tile: TileRef): number {
if (!this.componentIds) return 0;
return this.componentIds[tile] ?? 0;
diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts
index fbb63b925..7f17974b7 100644
--- a/src/server/GameServer.ts
+++ b/src/server/GameServer.ts
@@ -172,6 +172,9 @@ export class GameServer {
this.gameConfig.disableAlliances =
gameConfig.disableAlliances ?? undefined;
}
+ if (gameConfig.waterNukes !== undefined) {
+ this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
+ }
}
private isKicked(clientID: ClientID): boolean {
diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts
index 295df83cd..5e57b0bf3 100644
--- a/src/server/MapPlaylist.ts
+++ b/src/server/MapPlaylist.ts
@@ -117,12 +117,13 @@ type ModifierKey =
| "isPortsDisabled"
| "isNukesDisabled"
| "isSAMsDisabled"
- | "isPeaceTime";
+ | "isPeaceTime"
+ | "isWaterNukes";
// Each entry represents one "ticket" in the pool. More tickets = higher chance of selection.
const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array(2).fill("isRandomSpawn"),
- ...Array(5).fill("isCompact"),
+ ...Array(4).fill("isCompact"),
...Array(2).fill("isCrowded"),
...Array(1).fill("isHardNations"),
...Array(3).fill("startingGold1M"),
@@ -134,8 +135,18 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array(1).fill("isNukesDisabled"),
...Array(1).fill("isSAMsDisabled"),
...Array(1).fill("isPeaceTime"),
+ ...Array(3).fill("isWaterNukes"),
];
+// Maps where water nukes have a higher chance on top of the normal pool
+// Water nukes are especially fun here
+const WATER_NUKES_BOOSTED_MAPS: ReadonlySet = new Set([
+ GameMapType.FourIslands,
+ GameMapType.Baikal,
+ GameMapType.Alps,
+ GameMapType.TheBox,
+]);
+
// Modifiers that cannot be active at the same time.
const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["startingGold5M", "startingGold25M"],
@@ -143,6 +154,7 @@ const MUTUALLY_EXCLUSIVE_MODIFIERS: [ModifierKey, ModifierKey][] = [
["startingGold25M", "startingGold1M"],
["isHardNations", "startingGold25M"],
["isNukesDisabled", "isSAMsDisabled"],
+ ["isNukesDisabled", "isWaterNukes"],
];
export class MapPlaylist {
@@ -242,7 +254,19 @@ export class MapPlaylist {
excludedModifiers.push("isPeaceTime"); // Nations don't have PVP immunity
}
- const poolResult = this.getRandomSpecialGameModifiers(excludedModifiers);
+ // Boost water nukes chance
+ // When boosted, water nukes is forced on and takes one modifier slot.
+ const boostWaterNukes =
+ WATER_NUKES_BOOSTED_MAPS.has(map) && Math.random() < 0.5;
+ if (boostWaterNukes) {
+ excludedModifiers.push("isWaterNukes", "isNukesDisabled");
+ }
+
+ const poolResult = this.getRandomSpecialGameModifiers(
+ excludedModifiers,
+ undefined,
+ boostWaterNukes ? 1 : 0,
+ );
let {
isCrowded,
startingGold,
@@ -255,7 +279,11 @@ export class MapPlaylist {
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
+ isWaterNukes,
} = poolResult;
+ if (boostWaterNukes) {
+ isWaterNukes = true;
+ }
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
// set player count to MAX_PLAYER_COUNT (or 60 if compact map is also enabled)
@@ -278,7 +306,8 @@ export class MapPlaylist {
!isPortsDisabled &&
!isNukesDisabled &&
!isSAMsDisabled &&
- !isPeaceTime
+ !isPeaceTime &&
+ !isWaterNukes
) {
excludedModifiers.push("isCrowded");
const fallback = this.getRandomSpecialGameModifiers(
@@ -295,6 +324,7 @@ export class MapPlaylist {
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
+ isWaterNukes,
} = fallback);
({ isHardNations } = fallback);
}
@@ -354,6 +384,7 @@ export class MapPlaylist {
isNukesDisabled,
isSAMsDisabled,
isPeaceTime,
+ isWaterNukes,
},
startingGold,
goldMultiplier,
@@ -375,6 +406,7 @@ export class MapPlaylist {
peaceTimeDuration ??
this.getSpawnImmunityDuration(playerTeams, startingGold),
disabledUnits,
+ waterNukes: isWaterNukes ? true : undefined,
} satisfies GameConfig;
}
@@ -552,6 +584,7 @@ export class MapPlaylist {
isNukesDisabled: selected.has("isNukesDisabled") || undefined,
isSAMsDisabled: selected.has("isSAMsDisabled") || undefined,
isPeaceTime: selected.has("isPeaceTime") || undefined,
+ isWaterNukes: selected.has("isWaterNukes") || undefined,
};
}
diff --git a/tests/nukes/WaterNukes.test.ts b/tests/nukes/WaterNukes.test.ts
new file mode 100644
index 000000000..2607c4e14
--- /dev/null
+++ b/tests/nukes/WaterNukes.test.ts
@@ -0,0 +1,246 @@
+import { NukeExecution } from "../../src/core/execution/NukeExecution";
+import { SpawnExecution } from "../../src/core/execution/SpawnExecution";
+import {
+ Game,
+ Player,
+ PlayerInfo,
+ PlayerType,
+ UnitType,
+} from "../../src/core/game/Game";
+import { TileRef } from "../../src/core/game/GameMap";
+import { GameID } from "../../src/core/Schemas";
+import { setup } from "../util/Setup";
+import { constructionExecution } from "../util/utils";
+
+const gameID: GameID = "game_id";
+
+function launchNukeAt(game: Game, player: Player, target: TileRef): void {
+ game.addExecution(new NukeExecution(UnitType.AtomBomb, player, target, null));
+ // init + build
+ game.executeNextTick();
+ game.executeNextTick();
+}
+
+function tickUntilNukeLands(game: Game, maxTicks = 50): void {
+ for (let i = 0; i < maxTicks; i++) {
+ game.executeNextTick();
+ }
+}
+
+describe("Water Nukes", () => {
+ let game: Game;
+ let player: Player;
+
+ describe("when waterNukes is enabled", () => {
+ beforeEach(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);
+
+ // Build a missile silo
+ constructionExecution(game, player, 1, 1, UnitType.MissileSilo);
+ });
+
+ test("nuke converts land tiles to water instead of fallout", () => {
+ const target = game.ref(10, 10);
+ // Confirm target is land before nuke
+ expect(game.isLand(target)).toBe(true);
+
+ launchNukeAt(game, player, target);
+ tickUntilNukeLands(game);
+
+ // Target should now be water, not land
+ expect(game.isLand(target)).toBe(false);
+ expect(game.isWater(target)).toBe(true);
+ // Should NOT have fallout
+ expect(game.hasFallout(target)).toBe(false);
+ });
+
+ test("converted tiles get shoreline bits updated", () => {
+ const target = game.ref(10, 10);
+ launchNukeAt(game, player, target);
+ tickUntilNukeLands(game);
+
+ // With nukeMagnitudes { inner: 1, outer: 1 }, the target and its
+ // cardinal neighbors (dist² <= 1) are all converted to water.
+ // Shoreline tiles are the land tiles just outside the blast radius.
+ const x = game.x(target);
+ const y = game.y(target);
+
+ // 2 tiles away should still be land and now be shoreline
+ const outerNeighbors: TileRef[] = [];
+ if (game.isValidCoord(x - 2, y)) outerNeighbors.push(game.ref(x - 2, y));
+ if (game.isValidCoord(x + 2, y)) outerNeighbors.push(game.ref(x + 2, y));
+ if (game.isValidCoord(x, y - 2)) outerNeighbors.push(game.ref(x, y - 2));
+ if (game.isValidCoord(x, y + 2)) outerNeighbors.push(game.ref(x, y + 2));
+
+ for (const n of outerNeighbors) {
+ expect(game.isLand(n)).toBe(true);
+ expect(game.isShoreline(n)).toBe(true);
+ }
+ });
+
+ test("queueWaterConversion skips tiles conquered before flush", () => {
+ // Pick an unowned land tile and queue it for water conversion directly
+ const target = game.ref(10, 10);
+ expect(game.isLand(target)).toBe(true);
+ expect(game.hasOwner(target)).toBe(false);
+
+ // Queue the tile for water conversion (simulates nuke queueing)
+ game.queueWaterConversion(target);
+
+ // Another actor conquers the tile before the tick flushes the queue
+ player.conquer(target);
+ expect(game.hasOwner(target)).toBe(true);
+
+ // Flush: the pending conversion should be skipped because the tile is now owned
+ game.executeNextTick();
+
+ // Tile should remain land and owned
+ expect(game.isLand(target)).toBe(true);
+ expect(game.hasOwner(target)).toBe(true);
+ expect(game.isWater(target)).toBe(false);
+ });
+
+ test("waterGraphVersion increments after water conversion", async () => {
+ // Need a game with nav mesh enabled for graph rebuilds
+ const navGame = await setup("plains", {
+ infiniteGold: true,
+ instantBuild: true,
+ waterNukes: true,
+ disableNavMesh: false,
+ });
+ const info2 = new PlayerInfo("p2", PlayerType.Human, null, "p2");
+ navGame.addPlayer(info2);
+ navGame.addExecution(
+ new SpawnExecution(gameID, info2, navGame.ref(1, 1)),
+ );
+ while (navGame.inSpawnPhase()) navGame.executeNextTick();
+ const player2 = navGame.player(info2.id);
+ constructionExecution(navGame, player2, 1, 1, UnitType.MissileSilo);
+
+ const versionBefore = navGame.waterGraphVersion();
+
+ // Launch multiple nukes in a cluster to ensure enough tiles convert
+ // for at least one minimap tile to flip (need >= 3 of 4 source tiles)
+ const target = navGame.ref(50, 50);
+ navGame.addExecution(
+ new NukeExecution(UnitType.AtomBomb, player2, target, null),
+ );
+ // Tick enough for nuke to land + graph rebuild throttle (20 ticks)
+ for (let i = 0; i < 80; i++) navGame.executeNextTick();
+
+ expect(navGame.waterGraphVersion()).toBeGreaterThan(versionBefore);
+ });
+ });
+
+ describe("when waterNukes is disabled (default)", () => {
+ beforeEach(async () => {
+ game = await setup("plains", {
+ infiniteGold: true,
+ instantBuild: true,
+ waterNukes: false,
+ });
+ 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);
+ });
+
+ test("nuke applies fallout instead of converting to water", () => {
+ const target = game.ref(10, 10);
+ expect(game.isLand(target)).toBe(true);
+
+ launchNukeAt(game, player, target);
+ tickUntilNukeLands(game);
+
+ // Should remain land with fallout
+ expect(game.isLand(target)).toBe(true);
+ expect(game.hasFallout(target)).toBe(true);
+ });
+
+ test("waterGraphVersion does not change", () => {
+ const versionBefore = game.waterGraphVersion();
+ const target = game.ref(10, 10);
+
+ launchNukeAt(game, player, target);
+ tickUntilNukeLands(game);
+
+ expect(game.waterGraphVersion()).toBe(versionBefore);
+ });
+ });
+
+ 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", {
+ 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);
+ const terrainBefore = game.terrainByte(target);
+ expect(game.isLand(target)).toBe(true);
+
+ launchNukeAt(game, player, target);
+ tickUntilNukeLands(game);
+
+ const terrainAfter = game.terrainByte(target);
+ // Terrain should have changed (was land, now water)
+ expect(terrainAfter).not.toBe(terrainBefore);
+ expect(game.isWater(target)).toBe(true);
+ });
+ });
+});
diff --git a/tests/pathfinding/utils.ts b/tests/pathfinding/utils.ts
index 8fb17c1d2..ed9ee771a 100644
--- a/tests/pathfinding/utils.ts
+++ b/tests/pathfinding/utils.ts
@@ -101,11 +101,12 @@ export function getAdapter(
originalGame._stats,
);
- (clonedGame as any)._miniWaterHPA = new AStarWaterHierarchical(
- clonedGame.miniMap(),
- (clonedGame as any)._miniWaterGraph!,
- { cachePaths: false },
- );
+ (clonedGame as any)._waterManager._miniWaterHPA =
+ new AStarWaterHierarchical(
+ clonedGame.miniMap(),
+ (clonedGame as any)._waterManager._miniWaterGraph!,
+ { cachePaths: false },
+ );
return PathFinding.Water(clonedGame);
}