mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:50:43 +00:00
Enhance nuke alliance breaking logic to account for allied structures in blast radius 💣 (#2887)
## Description: Doesn't need a description :D https://github.com/user-attachments/assets/8de576fd-050b-4b35-8526-e4c88d1a9f25 https://github.com/user-attachments/assets/c99147a1-efdf-426b-96d1-e996e01f89aa ## 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:
@@ -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())),
|
||||
|
||||
@@ -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<number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-15
@@ -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<number>;
|
||||
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<number, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,8 @@ const _structureTypes: ReadonlySet<UnitType> = 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user