Water-Nukes 💧 (#3604)

## 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):

<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/e896870b-bc9d-493d-8bc8-b3a5427d69d3"
/>

<img width="1472" height="920" alt="image"
src="https://github.com/user-attachments/assets/677065aa-0159-48cd-af44-a91b0f57adfc"
/>

<img width="1296" height="892" alt="image"
src="https://github.com/user-attachments/assets/886ffaba-541f-4e46-97c6-ce963f632fe0"
/>

## 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
This commit is contained in:
FloPinguin
2026-04-09 05:56:02 +02:00
committed by GitHub
parent f3cbca059f
commit 7f7cbba12f
28 changed files with 1113 additions and 129 deletions
+5 -1
View File
@@ -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"
+11
View File
@@ -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<GameConfig>,
},
bubbles: true,
+7
View File
@@ -552,6 +552,13 @@ export class JoinLobbyModal extends BaseModal {
.value=${translateText("common.disabled")}
></lobby-config-item>`,
);
if (c.waterNukes)
cards.push(
html`<lobby-config-item
.label=${translateText("public_game_modifier.water_nukes_label")}
.value=${translateText("common.enabled")}
></lobby-config-item>`,
);
if ((isTeam && !c.donateGold) || (!isTeam && c.donateGold))
cards.push(
html`<lobby-config-item
+12
View File
@@ -58,6 +58,7 @@ const DEFAULT_OPTIONS = {
startingGoldValue: undefined as number | undefined,
disabledUnits: [] as UnitType[],
disableAlliances: false,
waterNukes: false,
} as const;
@customElement("single-player-modal")
@@ -93,6 +94,7 @@ export class SinglePlayerModal extends BaseModal {
...DEFAULT_OPTIONS.disabledUnits,
];
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
private mapLoader = terrainMapFileLoader;
@@ -313,6 +315,10 @@ export class SinglePlayerModal extends BaseModal {
labelKey: "single_modal.disable_alliances",
checked: this.disableAlliances,
},
{
labelKey: "single_modal.water_nukes",
checked: this.waterNukes,
},
],
inputCards,
},
@@ -384,6 +390,7 @@ export class SinglePlayerModal extends BaseModal {
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
this.disabledUnits.length > 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
},
+6
View File
@@ -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;
}
@@ -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);
}
}
}
+8 -1
View File
@@ -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) => {
+2
View File
@@ -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
+1
View File
@@ -75,6 +75,7 @@ export interface Config {
instantBuild(): boolean;
disableNavMesh(): boolean;
disableAlliances(): boolean;
waterNukes(): boolean;
isRandomSpawn(): boolean;
numSpawnPhaseTurns(): number;
userSettings(): UserSettings;
+3
View File
@@ -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;
}
+3
View File
@@ -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()
+56 -5
View File
@@ -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<TileRef>();
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);
}
}
+8 -4
View File
@@ -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<TileRef>;
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,
+19 -4
View File
@@ -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<TileRef>;
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(
+4 -4
View File
@@ -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<TileRef>;
private pathfinder: WaterPathFinder;
private lastShellAttack = 0;
private alreadySentShell = new Set<Unit>();
@@ -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;
+6
View File
@@ -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 {
+68 -87
View File
@@ -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<number> | 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)) {
+59 -3
View File
@@ -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;
}
}
+30 -3
View File
@@ -648,6 +648,7 @@ export class GameView implements GameMap {
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
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();
+428
View File
@@ -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<TileRef> = 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<number> | 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<TileRef>(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<TileRef>();
// 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<TileRef>(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<TileRef>();
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<TileRef>();
const convertedMiniTiles = new Set<TileRef>();
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);
}
}
}
+47 -1
View File
@@ -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<TileRef> {
private inner: SteppingPathFinder<TileRef>;
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<TileRef> {
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<TileRef> {
return {
equals: (a, b) => a === b,
@@ -234,12 +234,15 @@ export class AbstractGraphAStar implements PathFinder<number> {
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];
}
@@ -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;
@@ -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;
+3
View File
@@ -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 {
+37 -4
View File
@@ -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<ModifierKey>(2).fill("isRandomSpawn"),
...Array<ModifierKey>(5).fill("isCompact"),
...Array<ModifierKey>(4).fill("isCompact"),
...Array<ModifierKey>(2).fill("isCrowded"),
...Array<ModifierKey>(1).fill("isHardNations"),
...Array<ModifierKey>(3).fill("startingGold1M"),
@@ -134,8 +135,18 @@ const SPECIAL_MODIFIER_POOL: ModifierKey[] = [
...Array<ModifierKey>(1).fill("isNukesDisabled"),
...Array<ModifierKey>(1).fill("isSAMsDisabled"),
...Array<ModifierKey>(1).fill("isPeaceTime"),
...Array<ModifierKey>(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<GameMapType> = 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,
};
}
+246
View File
@@ -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);
});
});
});
+6 -5
View File
@@ -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);
}