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); }