diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 3dd62c8e2..5f3227226 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -137,7 +137,7 @@ export class UnitLayer implements Layer { clickRef = this.game.ref(cell.x, cell.y); } - if (!this.game.isOcean(clickRef)) return; + if (!this.game.isWater(clickRef)) return; if (this.selectedUnit) { this.eventBus.emit( @@ -169,13 +169,13 @@ export class UnitLayer implements Layer { const clickRef = this.game.ref(cell.x, cell.y); if (this.game.inSpawnPhase()) { // No Radial Menu during spawn phase, only spawn point selection - if (!this.game.isOcean(clickRef)) { + if (!this.game.isWater(clickRef)) { this.eventBus.emit(new MouseUpEvent(event.x, event.y)); } return; } - if (!this.game.isOcean(clickRef)) { + if (!this.game.isWater(clickRef)) { // No warship to find because no Ocean tile, open Radial Menu this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); return; diff --git a/src/core/execution/PortExecution.ts b/src/core/execution/PortExecution.ts index 9483f1b70..3dc05724e 100644 --- a/src/core/execution/PortExecution.ts +++ b/src/core/execution/PortExecution.ts @@ -97,10 +97,22 @@ export class PortExecution implements Execution { // It's a probability list, so if an element appears twice it's because it's // twice more likely to be picked later. tradingPorts(): Unit[] { + const sourceComponents = new Set(); + for (const neighbor of this.mg.neighbors(this.port!.tile())) { + if (!this.mg.isWater(neighbor)) continue; + const comp = this.mg.getWaterComponent(neighbor); + if (comp !== null) sourceComponents.add(comp); + } const ports = this.mg .players() .filter((p) => p !== this.port!.owner() && p.canTrade(this.port!.owner())) .flatMap((p) => p.units(UnitType.Port)) + .filter((p) => { + for (const comp of sourceComponents) { + if (this.mg.hasWaterComponent(p.tile(), comp)) return true; + } + return false; + }) .sort((p1, p2) => { return ( this.mg.manhattanDist(this.port!.tile(), p1.tile()) - diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 9eaa2c092..0f3df5f7b 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -90,13 +90,16 @@ export class TradeShipExecution implements Execution { this.wasCaptured && (tradeShipOwner !== dstPortOwner || !this._dstPort.isActive()) ) { + const myComponent = this.mg.getWaterComponent(curTile); const nearestPort = findClosestBy( tradeShipOwner.units(UnitType.Port), (port) => this.mg.manhattanDist(port.tile(), curTile), (port) => port.isActive() && !port.isMarkedForDeletion() && - !port.isUnderConstruction(), + !port.isUnderConstruction() && + myComponent !== null && + this.mg.hasWaterComponent(port.tile(), myComponent), ); if (nearestPort === null) { this.tradeShip.delete(false); diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index e8b736bc2..527056df6 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -254,7 +254,7 @@ export class WarshipExecution implements Execution { } const tile = this.mg.ref(x, y); if ( - !this.mg.isOcean(tile) || + !this.mg.isWater(tile) || (!allowShoreline && this.mg.isShoreline(tile)) ) { attempts++; diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index fe9698abd..43ec27a8a 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -90,6 +90,7 @@ export class NationStructureBehavior { cluster: Cluster | null; weight: number; }> | null = null; + private _sharedWaterComponents: Set | null = null; constructor( private random: PseudoRandom, @@ -107,7 +108,8 @@ export class NationStructureBehavior { Math.floor(this.player.numTilesOwned() / TILES_PER_CITY_EQUIVALENT), ) : this.player.unitsOwned(UnitType.City); - const hasCoastalTiles = this.hasCoastalTiles(); + this._sharedWaterComponents = this.sharedWaterComponents(); + const hasCoastalTiles = this._sharedWaterComponents !== null; // Build order for non-city structures (priority order) const buildOrder: UnitType[] = [ @@ -165,11 +167,37 @@ export class NationStructureBehavior { return false; } - private hasCoastalTiles(): boolean { + /** + * Returns the set of water components shared with at least one other player, + * or null if there are none. + */ + private sharedWaterComponents(): Set | null { + // Collect all water-component IDs reachable from this player's coast. + const playerComponents = new Set(); for (const tile of this.player.borderTiles()) { - if (this.game.isOceanShore(tile)) return true; + if (!this.game.isShore(tile)) continue; + for (const neighbor of this.game.neighbors(tile)) { + if (!this.game.isWater(neighbor)) continue; + const comp = this.game.getWaterComponent(neighbor); + if (comp !== null) playerComponents.add(comp); + } } - return false; + if (playerComponents.size === 0) return null; + + // Keep only components that at least one other player also touches. + const shared = new Set(); + for (const other of this.game.players()) { + if (other === this.player) continue; + for (const tile of other.borderTiles()) { + if (!this.game.isShore(tile)) continue; + for (const neighbor of this.game.neighbors(tile)) { + if (!this.game.isWater(neighbor)) continue; + const comp = this.game.getWaterComponent(neighbor); + if (comp !== null && playerComponents.has(comp)) shared.add(comp); + } + } + } + return shared.size > 0 ? shared : null; } /** @@ -471,10 +499,19 @@ export class NationStructureBehavior { return bestTile; } + /** Samples shore tiles adjacent to water reachable by another player (=> trading possible) */ private randCoastalTileArray(numTiles: number): TileRef[] { - const tiles = Array.from(this.player.borderTiles()).filter((t) => - this.game.isOceanShore(t), - ); + const shared = this._sharedWaterComponents; + const tiles = Array.from(this.player.borderTiles()).filter((t) => { + if (!this.game.isShore(t)) return false; + if (shared === null) return false; + for (const neighbor of this.game.neighbors(t)) { + if (!this.game.isWater(neighbor)) continue; + const comp = this.game.getWaterComponent(neighbor); + if (comp !== null && shared.has(comp)) return true; + } + return false; + }); return Array.from(this.arraySampler(tiles, numTiles)); } diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts index df240f03f..51b469954 100644 --- a/src/core/execution/nation/NationWarshipBehavior.ts +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -74,7 +74,7 @@ export class NationWarshipBehavior { } const tile = this.game.ref(randX, randY); // Sanity check - if (!this.game.isOcean(tile)) { + if (!this.game.isWater(tile)) { continue; } return tile; diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index c42301cc7..738fe6893 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -109,15 +109,15 @@ export class AiAttackBehavior { return; } - // Check if we have any ocean shore tiles to launch from - const oceanShore = Array.from(this.player.borderTiles()).filter((t) => - this.game.isOceanShore(t), + // Check if we have any shore tiles to launch from + const shore = Array.from(this.player.borderTiles()).filter((t) => + this.game.isShore(t), ); - if (oceanShore.length === 0) { + if (shore.length === 0) { return; } - const src = this.random.randElement(oceanShore); + const src = this.random.randElement(shore); // First look for high-interest targets (unowned or bot-owned). Mainly relevant for earlygame let dst = this.findRandomBoatTarget(src, borderingEnemies, true); @@ -574,11 +574,11 @@ export class AiAttackBehavior { return null; } - // Check if we have any ocean shore tiles to launch from - const hasOceanShore = Array.from(this.player.borderTiles()).some((t) => - this.game.isOceanShore(t), + // Check if we have any shore tiles to launch from + const hasShore = Array.from(this.player.borderTiles()).some((t) => + this.game.isShore(t), ); - if (!hasOceanShore) return null; + if (!hasShore) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; @@ -615,10 +615,10 @@ export class AiAttackBehavior { const closest = closestTwoTiles( this.game, Array.from(this.player.borderTiles()).filter((t) => - this.game.isOceanShore(t), + this.game.isShore(t), ), Array.from(entry.player.borderTiles()).filter((t) => - this.game.isOceanShore(t), + this.game.isShore(t), ), ); if (closest === null) continue; @@ -786,10 +786,8 @@ export class AiAttackBehavior { private sendBoatAttack(target: Player) { const closest = closestTwoTiles( this.game, - Array.from(this.player.borderTiles()).filter((t) => - this.game.isOceanShore(t), - ), - Array.from(target.borderTiles()).filter((t) => this.game.isOceanShore(t)), + Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)), + Array.from(target.borderTiles()).filter((t) => this.game.isShore(t)), ); if (closest === null) { return; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b223b2f62..05a90d1a6 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -1222,7 +1222,7 @@ export class PlayerImpl implements Player { manhattanDistFN(tile, this.mg.config().radiusPortSpawn()), ), ) - .filter((t) => this.mg.owner(t) === this && this.mg.isOceanShore(t)) + .filter((t) => this.mg.owner(t) === this && this.mg.isShore(t)) .sort( (a, b) => this.mg.manhattanDist(a, tile) - this.mg.manhattanDist(b, tile), @@ -1239,14 +1239,19 @@ export class PlayerImpl implements Player { } warshipSpawn(tile: TileRef): TileRef | false { - if (!this.mg.isOcean(tile)) { + if (!this.mg.isWater(tile)) { return false; } + const tileComponent = this.mg.getWaterComponent(tile); const bestPort = findClosestBy( this.units(UnitType.Port), (port) => this.mg.manhattanDist(port.tile(), tile), - (port) => port.isActive() && !port.isUnderConstruction(), + (port) => + port.isActive() && + !port.isUnderConstruction() && + tileComponent !== null && + this.mg.hasWaterComponent(port.tile(), tileComponent), ); return bestPort?.tile() ?? false; diff --git a/src/core/game/WaterManager.ts b/src/core/game/WaterManager.ts index d3960b010..69009d5be 100644 --- a/src/core/game/WaterManager.ts +++ b/src/core/game/WaterManager.ts @@ -216,14 +216,6 @@ export class WaterManager { } } } - // If no converted tile is adjacent to existing ocean (e.g. all-land map), - // mark all converted tiles as ocean so they're navigable for ports/boats. - if (oceanQueue.length === 0) { - for (const tile of converted) { - map.setOcean(tile); - oceanQueue.push(tile); - } - } let oHead = 0; while (oHead < oceanQueue.length) { const tile = oceanQueue[oHead++]; diff --git a/tests/nukes/WaterNukes.test.ts b/tests/nukes/WaterNukes.test.ts index 2607c4e14..91a65e739 100644 --- a/tests/nukes/WaterNukes.test.ts +++ b/tests/nukes/WaterNukes.test.ts @@ -180,42 +180,6 @@ describe("Water Nukes", () => { }); }); - describe("all-land map (no pre-existing ocean)", () => { - test("nuke-created water gets ocean bit so ports can be built", async () => { - game = await setup("plains", { - infiniteGold: true, - instantBuild: true, - waterNukes: true, - }); - const info = new PlayerInfo("p", PlayerType.Human, null, "p"); - game.addPlayer(info); - game.addExecution(new SpawnExecution(gameID, info, game.ref(1, 1))); - while (game.inSpawnPhase()) game.executeNextTick(); - player = game.player(info.id); - constructionExecution(game, player, 1, 1, UnitType.MissileSilo); - - const target = game.ref(10, 10); - - // Verify no ocean exists anywhere near the target before the nuke - expect(game.isLand(target)).toBe(true); - - launchNukeAt(game, player, target); - tickUntilNukeLands(game); - - // The converted tile should be ocean (not just lake water) - expect(game.isWater(target)).toBe(true); - expect(game.isOcean(target)).toBe(true); - - // Neighboring land tiles should be ocean-shore (required for port placement) - const x = game.x(target); - const y = game.y(target); - const shoreCandidate = game.ref(x + 2, y); - if (game.isLand(shoreCandidate)) { - expect(game.isOceanShore(shoreCandidate)).toBe(true); - } - }); - }); - describe("updateTile terrain byte round-trip", () => { test("terrain byte is packed and unpacked correctly", async () => { game = await setup("plains", {