diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 0c896f717..a8a7862dc 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -274,7 +274,7 @@ export class StructureIconsLayer implements Layer { const allies = myPlayer.allies(); if (allies.length > 0) { targetingAlly = wouldNukeBreakAlliance({ - gm: this.game, + game: this.game, targetTile: tileRef, magnitude: this.game.config().nukeMagnitudes(nukeType), allySmallIds: new Set(allies.map((a) => a.smallID())), diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 6cc3965ad..277057c5f 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -4,6 +4,7 @@ import { isStructureType, MessageType, Player, + StructureTypes, TerraNullius, TrajectoryTile, Unit, @@ -72,7 +73,7 @@ export class NukeExecution implements Execution { /** * Break alliances with players significantly affected by the nuke strike. - * Uses weighted tile counting (inner=1, outer=0.5). + * Uses weighted tile counting (inner=1, outer=0.5) OR if any allied structure would be destroyed. */ private maybeBreakAlliances() { if (this.nuke === null) { @@ -93,29 +94,48 @@ export class NukeExecution implements Execution { magnitude, }); + // Collect all players that should have alliance broken: + // either exceeds tile threshold OR has a structure in blast radius + const playersToBreakAllianceWith = new Set(); + for (const [playerSmallId, totalWeight] of blastCounts) { if (totalWeight > threshold) { - const attackedPlayer = this.mg.playerBySmallID(playerSmallId); - if (!attackedPlayer.isPlayer()) { - continue; - } + playersToBreakAllianceWith.add(playerSmallId); + } + } - // Resolves exploit of alliance breaking in which a pending alliance request - // was accepted in the middle of a missile attack. - const allianceRequest = attackedPlayer - .incomingAllianceRequests() - .find((ar) => ar.requestor() === this.player); - if (allianceRequest) { - allianceRequest.reject(); - } + // Also check if any allied structures would be destroyed + this.mg + .nearbyUnits(this.dst, magnitude.outer, [...StructureTypes]) + .filter( + ({ unit }) => + unit.owner().isPlayer() && this.player.isAlliedWith(unit.owner()), + ) + .forEach(({ unit }) => + playersToBreakAllianceWith.add(unit.owner().smallID()), + ); - const alliance = this.player.allianceWith(attackedPlayer); - if (alliance !== null) { - this.player.breakAlliance(alliance); - } - if (attackedPlayer !== this.player) { - attackedPlayer.updateRelation(this.player, -100); - } + for (const playerSmallId of playersToBreakAllianceWith) { + const attackedPlayer = this.mg.playerBySmallID(playerSmallId); + if (!attackedPlayer.isPlayer()) { + continue; + } + + // Resolves exploit of alliance breaking in which a pending alliance request + // was accepted in the middle of a missile attack. + const allianceRequest = attackedPlayer + .incomingAllianceRequests() + .find((ar) => ar.requestor() === this.player); + if (allianceRequest) { + allianceRequest.reject(); + } + + const alliance = this.player.allianceWith(attackedPlayer); + if (alliance !== null) { + this.player.breakAlliance(alliance); + } + if (attackedPlayer !== this.player) { + attackedPlayer.updateRelation(this.player, -100); } } } diff --git a/src/core/execution/Util.ts b/src/core/execution/Util.ts index 32052c137..f0b5b95b1 100644 --- a/src/core/execution/Util.ts +++ b/src/core/execution/Util.ts @@ -1,6 +1,7 @@ import { NukeMagnitude } from "../configuration/Config"; -import { Game, Player } from "../game/Game"; +import { Game, Player, StructureTypes } from "../game/Game"; import { euclDistFN, GameMap, TileRef } from "../game/GameMap"; +import { GameView } from "../game/GameView"; export interface NukeBlastParams { gm: GameMap; @@ -34,40 +35,60 @@ export function computeNukeBlastCounts( return counts; } -export interface NukeAllianceCheckParams extends NukeBlastParams { +export interface NukeAllianceCheckParams { + game: GameView; + targetTile: TileRef; + magnitude: NukeMagnitude; allySmallIds: Set; threshold: number; } // Checks if nuking this tile would break an alliance. +// Returns true if either: +// 1. The weighted tile count for any ally exceeds the threshold +// 2. Any allied structure would be destroyed export function wouldNukeBreakAlliance( params: NukeAllianceCheckParams, ): boolean { - const { gm, targetTile, magnitude, allySmallIds, threshold } = params; + const { game, targetTile, magnitude, allySmallIds, threshold } = params; if (allySmallIds.size === 0) { return false; } + // Check if any allied structure would be destroyed + const wouldDestroyAlliedStructure = game.anyUnitNearby( + targetTile, + magnitude.outer, + StructureTypes, + (unit) => + unit.owner().isPlayer() && allySmallIds.has(unit.owner().smallID()), + ); + if (wouldDestroyAlliedStructure) return true; + const inner2 = magnitude.inner * magnitude.inner; const allyTileCounts = new Map(); let result = false; - gm.circleSearch(targetTile, magnitude.outer, (tile: TileRef, d2: number) => { - const ownerSmallId = gm.ownerID(tile); - if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) { - const weight = d2 <= inner2 ? 1 : 0.5; - const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight; - allyTileCounts.set(ownerSmallId, newCount); + game.circleSearch( + targetTile, + magnitude.outer, + (tile: TileRef, d2: number) => { + const ownerSmallId = game.ownerID(tile); + if (ownerSmallId > 0 && allySmallIds.has(ownerSmallId)) { + const weight = d2 <= inner2 ? 1 : 0.5; + const newCount = (allyTileCounts.get(ownerSmallId) ?? 0) + weight; + allyTileCounts.set(ownerSmallId, newCount); - if (newCount > threshold) { - result = true; - return false; // Found one! Stop searching. + if (newCount > threshold) { + result = true; + return false; // Found one! Stop searching. + } } - } - return true; - }); + return true; + }, + ); return result; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 53de9657c..8b8d3dd70 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -254,6 +254,8 @@ const _structureTypes: ReadonlySet = new Set([ UnitType.Factory, ]); +export const StructureTypes: readonly UnitType[] = [..._structureTypes]; + export function isStructureType(type: UnitType): boolean { return _structureTypes.has(type); } @@ -762,6 +764,14 @@ export interface Game extends GameMap { playerId?: PlayerID, includeUnderConstruction?: boolean, ): boolean; + anyUnitNearby( + tile: TileRef, + searchRange: number, + types: readonly UnitType[], + predicate: (unit: Unit) => boolean, + playerId?: PlayerID, + includeUnderConstruction?: boolean, + ): boolean; nearbyUnits( tile: TileRef, searchRange: number, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 926466208..a2bd1c902 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -838,6 +838,24 @@ export class GameImpl implements Game { ); } + anyUnitNearby( + tile: TileRef, + searchRange: number, + types: readonly UnitType[], + predicate: (unit: Unit) => boolean, + playerId?: PlayerID, + includeUnderConstruction?: boolean, + ): boolean { + return this.unitGrid.anyUnitNearby( + tile, + searchRange, + types, + predicate, + playerId, + includeUnderConstruction, + ); + } + nearbyUnits( tile: TileRef, searchRange: number, diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 04bf79d52..15ce0d564 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -717,8 +717,33 @@ export class GameView implements GameMap { searchRange: number, type: UnitType, playerId?: PlayerID, + includeUnderConstruction?: boolean, ) { - return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId); + return this.unitGrid.hasUnitNearby( + tile, + searchRange, + type, + playerId, + includeUnderConstruction, + ); + } + + anyUnitNearby( + tile: TileRef, + searchRange: number, + types: readonly UnitType[], + predicate: (unit: UnitView) => boolean, + playerId?: PlayerID, + includeUnderConstruction?: boolean, + ): boolean { + return this.unitGrid.anyUnitNearby( + tile, + searchRange, + types, + predicate, + playerId, + includeUnderConstruction, + ); } myClientID(): ClientID { diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index 68b20fac5..34cc60920 100644 --- a/src/core/game/UnitGrid.ts +++ b/src/core/game/UnitGrid.ts @@ -225,4 +225,45 @@ export class UnitGrid { } return false; } + + // Return true if any unit of the given types matches the predicate + anyUnitNearby( + tile: TileRef, + searchRange: number, + types: readonly UnitType[], + predicate: (unit: Unit | UnitView) => boolean, + playerId?: PlayerID, + includeUnderConstruction: boolean = false, + ): boolean { + const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange( + tile, + searchRange, + ); + const rangeSquared = searchRange * searchRange; + for (let cy = startGridY; cy <= endGridY; cy++) { + for (let cx = startGridX; cx <= endGridX; cx++) { + for (const type of types) { + const unitSet = this.grid[cy][cx].get(type); + if (unitSet === undefined) continue; + for (const unit of unitSet) { + if ( + !this.unitIsInRange( + unit, + tile, + rangeSquared, + playerId, + includeUnderConstruction, + ) + ) { + continue; + } + if (predicate(unit)) { + return true; + } + } + } + } + } + return false; + } } diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index d93b83d9f..bfc895547 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -125,4 +125,36 @@ describe("NukeExecution", () => { expect(player.isTraitor()).toBe(true); expect(player.isAlliedWith(otherPlayer)).toBe(false); }); + + test("nuke should break alliance when destroying ally's building even with few tiles", async () => { + const req = player.createAllianceRequest(otherPlayer); + req!.accept(); + + expect(player.isAlliedWith(otherPlayer)).toBe(true); + + player.conquer(game.ref(1, 1)); + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); + + // Give the other player just a few tiles (below the threshold of 5) + // and build a port on one of them + otherPlayer.conquer(game.ref(50, 50)); + otherPlayer.conquer(game.ref(51, 50)); + otherPlayer.conquer(game.ref(50, 51)); + otherPlayer.buildUnit(UnitType.Port, game.ref(50, 50), {}); + + expect(otherPlayer.units(UnitType.Port)).toHaveLength(1); + + // Nuke targeting the ally's port - this should break alliance + // even though the tile count is below threshold + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player, game.ref(50, 50), null), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // exec + + // Alliance should be broken because we're destroying ally's building + expect(player.isTraitor()).toBe(true); + expect(player.isAlliedWith(otherPlayer)).toBe(false); + }); });