From 36415a75e92a240625efb7bc28746b8afc39272e Mon Sep 17 00:00:00 2001 From: Marius Achim Date: Sat, 27 Dec 2025 22:18:24 +0200 Subject: [PATCH 1/5] feat: destroy incoming nukes on alliance --- src/core/execution/NukeExecution.ts | 38 +++- src/core/game/Game.ts | 10 + src/core/game/GameImpl.ts | 192 +++++++++++++++++++- tests/AllianceAcceptNukes.test.ts | 186 +++++++++++++++++++ tests/core/executions/NukeExecution.test.ts | 22 +++ 5 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 tests/AllianceAcceptNukes.test.ts diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index 17cf878ea7..abc54bb9b5 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -42,7 +42,10 @@ export class NukeExecution implements Execution { this.pathFinder = new ParabolaPathFinder(mg); } - public target(): Player | TerraNullius { + public target(): Player | TerraNullius | null { + // Not initialized yet -> queued execution + if (!this.mg) return null; + return this.mg.owner(this.dst); } @@ -64,6 +67,25 @@ export class NukeExecution implements Execution { return this.tilesToDestroyCache; } + public targetTile(): TileRef { + return this.dst; + } + + public isInFlight(): boolean { + return this.nuke !== null; + } + + public destroyInFlight(): void { + if (!this.active) return; + + if (this.nuke) { + this.nuke.delete(false); + this.nuke = null; + } + + this.active = false; + } + /** * Break alliances with players significantly affected by the nuke strike. * Uses weighted tile counting (inner=1, outer=0.5). @@ -247,12 +269,12 @@ export class NukeExecution implements Execution { throw new Error("Not initialized"); } + const target = this.target(); const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); const toDestroy = this.tilesToDestroy(); - const maxTroops = this.target().isPlayer() - ? this.mg.config().maxTroops(this.target() as Player) - : 1; + const maxTroops = + target && target.isPlayer() ? this.mg.config().maxTroops(target) : 1; for (const tile of toDestroy) { const owner = this.mg.owner(tile); @@ -319,9 +341,11 @@ export class NukeExecution implements Execution { this.nuke.delete(false); // Record stats - this.mg - .stats() - .bombLand(this.player, this.target(), this.nuke.type() as NukeType); + if (target) { + this.mg + .stats() + .bombLand(this.player, target, this.nuke.type() as NukeType); + } } private redrawBuildings(range: number) { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ec54e4a515..642d5d9203 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -779,6 +779,16 @@ export interface Game extends GameMap { addUpdate(update: GameUpdate): void; railNetwork(): RailNetwork; conquerPlayer(conqueror: Player, conquered: Player): void; + + destroyNukesBetween( + p1: Player, + p2: Player, + ): { + inFlight: number; + queued: number; + }; + + executions(): Execution[]; } export interface PlayerActions { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 465eeaa25a..5408f55682 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,5 +1,6 @@ import { renderNumber } from "../../client/Utils"; import { Config } from "../configuration/Config"; +import { NukeExecution } from "../execution/NukeExecution"; import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; @@ -56,6 +57,13 @@ export function createGame( export type CellString = string; +export type DestroyNukesResult = { + inFlight: number; + queued: number; + fromRequestorToRecipient: number; + fromRecipientToRequestor: number; +}; + export class GameImpl implements Game { private _ticks = 0; @@ -277,6 +285,75 @@ export class GameImpl implements Game { return ar; } + public destroyNukesBetween(p1: Player, p2: Player): DestroyNukesResult { + let inFlight = 0; + let queued = 0; + let fromRequestorToRecipient = 0; + let fromRecipientToRequestor = 0; + + const destroy = (exec: Execution, isQueued: boolean) => { + if (!(exec instanceof NukeExecution)) return; + + const launcher = exec.owner(); + + // queued execution -> target not resolvable yet + const target = + exec.isInFlight() && exec.target() + ? exec.target() + : exec instanceof NukeExecution + ? exec.targetTile() + : null; + + if (!target) return; + + let targetOwner: Player | TerraNullius; + if (typeof target === "object" && "isPlayer" in target) { + // target is already a Player or TerraNullius (in-flight nuke) + targetOwner = target as Player | TerraNullius; + } else { + // target is a TileRef (queued nuke) + targetOwner = this.owner(target as TileRef); + } + + const isRequestorToRecipient = launcher === p1 && targetOwner === p2; + const isRecipientToRequestor = launcher === p2 && targetOwner === p1; + + const isBetween = isRequestorToRecipient || isRecipientToRequestor; + + if (!isBetween) { + return; + } + + if (isQueued) queued++; + else inFlight++; + + if (isRequestorToRecipient) fromRequestorToRecipient++; + else fromRecipientToRequestor++; + + exec.destroyInFlight(); + }; + + for (const exec of this.execs) { + if (exec instanceof NukeExecution && !exec.isInFlight()) { + // initialized but not launched yet -> queued + destroy(exec, true); + } else { + destroy(exec, false); + } + } + + for (const exec of this.unInitExecs) { + destroy(exec, true); + } + + return { + inFlight, + queued, + fromRequestorToRecipient, + fromRecipientToRequestor, + }; + } + acceptAllianceRequest(request: AllianceRequestImpl) { this.allianceRequests = this.allianceRequests.filter( (ar) => ar !== request, @@ -311,6 +388,110 @@ export class GameImpl implements Game { if (recipient.hasEmbargoAgainst(requestor)) recipient.endTemporaryEmbargo(requestor); + const { + inFlight, + queued, + fromRequestorToRecipient, + fromRecipientToRequestor, + } = this.destroyNukesBetween(requestor, recipient); + + // Destroy counts available for display messages + this.unInitExecs = this.unInitExecs.filter((e) => e.isActive()); + + if (fromRequestorToRecipient > 0) { + const requestorMsg = `${fromRequestorToRecipient} nuke${ + fromRequestorToRecipient > 1 ? "s" : "" + } launched towards ${recipient.displayName()} ${ + fromRequestorToRecipient > 1 ? "were" : "was" + } destroyed due to the alliance`; + + const recipientMsg = `${fromRequestorToRecipient} nuke${ + fromRequestorToRecipient > 1 ? "s" : "" + } launched by ${requestor.displayName()} towards you ${ + fromRequestorToRecipient > 1 ? "were" : "was" + } destroyed due to the alliance`; + + this.displayMessage( + requestorMsg, + MessageType.ALLIANCE_ACCEPTED, + requestor.id(), + ); + this.displayMessage( + recipientMsg, + MessageType.ALLIANCE_ACCEPTED, + recipient.id(), + ); + } + + if (fromRecipientToRequestor > 0) { + const requestorMsg = `${fromRecipientToRequestor} nuke${ + fromRecipientToRequestor > 1 ? "s" : "" + } launched by ${recipient.displayName()} towards you ${ + fromRecipientToRequestor > 1 ? "were" : "was" + } destroyed due to the alliance`; + + const recipientMsg = `${fromRecipientToRequestor} nuke${ + fromRecipientToRequestor > 1 ? "s" : "" + } launched towards ${requestor.displayName()} ${ + fromRecipientToRequestor > 1 ? "were" : "was" + } destroyed due to the alliance`; + + this.displayMessage( + requestorMsg, + MessageType.ALLIANCE_ACCEPTED, + requestor.id(), + ); + this.displayMessage( + recipientMsg, + MessageType.ALLIANCE_ACCEPTED, + recipient.id(), + ); + } + + if (inFlight > 0) { + const baseMsg = `${inFlight} nuke${inFlight > 1 ? "s" : ""} in flight ${ + inFlight > 1 ? "were" : "was" + } neutralized due to alliance formation with`; + + const requestorMsg = `${inFlight} nuke${ + inFlight > 1 ? "s" : "" + } in flight ${ + inFlight > 1 ? "were" : "was" + } neutralized due to alliance formation with ${recipient.displayName()}`; + const recipientMsg = baseMsg + ` ${requestor.displayName()}`; + + this.displayMessage( + requestorMsg, + MessageType.ALLIANCE_ACCEPTED, + requestor.id(), + ); + this.displayMessage( + recipientMsg, + MessageType.ALLIANCE_ACCEPTED, + recipient.id(), + ); + } + + if (queued > 0) { + const baseMsg = `${queued} planned nuke${queued > 1 ? "s" : ""} ${ + queued > 1 ? "were" : "was" + } canceled due to alliance formation with`; + + const requestorMsg = baseMsg + ` ${recipient.displayName()}`; + const recipientMsg = baseMsg + ` ${requestor.displayName()}`; + + this.displayMessage( + requestorMsg, + MessageType.ALLIANCE_ACCEPTED, + requestor.id(), + ); + this.displayMessage( + recipientMsg, + MessageType.ALLIANCE_ACCEPTED, + recipient.id(), + ); + } + this.addUpdate({ type: GameUpdateType.AllianceRequestReply, request: request.toUpdate(), @@ -357,6 +538,7 @@ export class GameImpl implements Game { } executeNextTick(): GameUpdates { + const pending = this.updates; this.updates = createGameUpdatesMap(); this.execs.forEach((e) => { if ( @@ -393,7 +575,15 @@ export class GameImpl implements Game { }); } this._ticks++; - return this.updates; + + const merged = createGameUpdatesMap(); + + for (const k in merged) { + merged[k] = [...pending[k], ...this.updates[k]]; + } + + this.updates = createGameUpdatesMap(); + return merged; } private hash(): number { diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts new file mode 100644 index 0000000000..c3bc9edd82 --- /dev/null +++ b/tests/AllianceAcceptNukes.test.ts @@ -0,0 +1,186 @@ +import { NukeExecution } from "../src/core/execution/NukeExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +import { GameUpdateType } from "../src/core/game/GameUpdates"; +import { setup } from "./util/Setup"; +import { TestConfig } from "./util/TestConfig"; + +let game: Game; +let player1: Player; +let player2: Player; +let player3: Player; + +describe("Alliance acceptance destroys nukes", () => { + beforeEach(async () => { + game = await setup( + "plains", + { + infiniteGold: true, + instantBuild: true, + infiniteTroops: true, + }, + [ + new PlayerInfo("player1", PlayerType.Human, "c1", "p1"), + new PlayerInfo("player2", PlayerType.Human, "c2", "p2"), + new PlayerInfo("player3", PlayerType.Human, "c3", "p3"), + ], + ); + + (game.config() as TestConfig).nukeAllianceBreakThreshold = () => 0; + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + player1 = game.player("p1"); + player2 = game.player("p2"); + player3 = game.player("p3"); + + player1.conquer(game.ref(0, 0)); + player2.conquer(game.ref(0, 1)); + player3.conquer(game.ref(10, 10)); + }); + + test("accepting alliance destroys queued nukes between players", () => { + // Ensure the target tile is owned by player2 + player2.conquer(game.ref(5, 5)); + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), + ); + + expect(game.executions().length).toBe(1); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + game.executeNextTick(); + + expect(game.executions().length).toBe(0); + }); + + test("accepting alliance destroys in-flight nukes between players", () => { + // Ensure target owned by player2 + player2.conquer(game.ref(5, 5)); + + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + + const exec = new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + ); + + game.addExecution(exec); + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + expect(exec.isInFlight()).toBe(true); + expect(exec.isActive()).toBe(true); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + game.executeNextTick(); + + expect(exec.isActive()).toBe(false); + }); + + test("queued and in-flight nukes are counted correctly", () => { + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + + player2.conquer(game.ref(5, 5)); + player2.conquer(game.ref(6, 6)); + + const inFlight = new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + ); + + const queued = new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(6, 6), + null, + ); + + // Spawn the in-flight nuke first + game.addExecution(inFlight); + game.executeNextTick(); + game.executeNextTick(); // spawn first + + // Add queued after the first has spawned so it remains queued + game.addExecution(queued); + + const result = game.destroyNukesBetween(player1, player2); + + expect(result.inFlight).toBe(1); + expect(result.queued).toBe(1); + }); + + test("accepting alliance does not destroy nukes targeting third players", () => { + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null), + ); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + game.executeNextTick(); + + expect(game.executions().length).toBe(1); + }); + + test("queued nukes never spawn after alliance acceptance (race condition)", () => { + const exec = new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(20, 20), + null, + ); + + game.addExecution(exec); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + + for (let i = 0; i < 5; i++) { + game.executeNextTick(); + } + + expect(exec.isActive()).toBe(false); + expect(game.executions().length).toBe(0); + }); + + test("accepting alliance displays correct nuke cancellation messages", () => { + // Ensure target owned by player2 + player2.conquer(game.ref(5, 5)); + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), + ); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + const updates = game.executeNextTick(); + + const messages = + updates[GameUpdateType.DisplayEvent]?.map((e) => e.message) ?? []; + + // expect(messages.some((m) => m.includes("planned nuke"))).toBe(true); + + // Expect both the queued (planned) message and a directional message + expect( + messages.some( + (m) => + m.includes("planned nuke") || + m.includes("launched towards") || + m.includes("launched at") || + m.includes("launched by"), + ), + ).toBe(true); + }); +}); diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index d93b83d9f6..4ec328ce9f 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -125,4 +125,26 @@ describe("NukeExecution", () => { expect(player.isTraitor()).toBe(true); expect(player.isAlliedWith(otherPlayer)).toBe(false); }); + + test("destroyInFlight deactivates execution and removes nuke unit", async () => { + player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); + + const exec = new NukeExecution( + UnitType.AtomBomb, + player, + game.ref(10, 10), + game.ref(1, 1), + ); + + game.addExecution(exec); + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + expect(exec.isInFlight()).toBe(true); + expect(exec.isActive()).toBe(true); + + exec.destroyInFlight(); + + expect(exec.isActive()).toBe(false); + }); }); From 8fb22dfebfb66f6176f1b7a7ac03a6f889e646b6 Mon Sep 17 00:00:00 2001 From: Marius Achim Date: Sun, 28 Dec 2025 00:40:44 +0200 Subject: [PATCH 2/5] fix: Fixed destroyNukesBetween from Game interface --- src/core/game/Game.ts | 15 ++++++++------- src/core/game/GameImpl.ts | 8 +------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 642d5d9203..2a1f6fcf61 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -780,13 +780,7 @@ export interface Game extends GameMap { railNetwork(): RailNetwork; conquerPlayer(conqueror: Player, conquered: Player): void; - destroyNukesBetween( - p1: Player, - p2: Player, - ): { - inFlight: number; - queued: number; - }; + destroyNukesBetween(p1: Player, p2: Player): DestroyNukesResult; executions(): Execution[]; } @@ -835,6 +829,13 @@ export interface EmojiMessage { createdAt: Tick; } +export type DestroyNukesResult = { + inFlight: number; + queued: number; + fromRequestorToRecipient: number; + fromRecipientToRequestor: number; +}; + export enum MessageType { ATTACK_FAILED, ATTACK_CANCELLED, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 5408f55682..078e268430 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -10,6 +10,7 @@ import { AllianceRequest, Cell, ColoredTeams, + DestroyNukesResult, Duos, EmojiMessage, Execution, @@ -57,13 +58,6 @@ export function createGame( export type CellString = string; -export type DestroyNukesResult = { - inFlight: number; - queued: number; - fromRequestorToRecipient: number; - fromRecipientToRequestor: number; -}; - export class GameImpl implements Game { private _ticks = 0; From 578f1bc670aa865689c9b771ef40d2f87a88f811 Mon Sep 17 00:00:00 2001 From: Marius Achim Date: Sun, 28 Dec 2025 00:56:05 +0200 Subject: [PATCH 3/5] fix: Fixed tests for queued nukes --- tests/AllianceAcceptNukes.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts index c3bc9edd82..d5f3b325fa 100644 --- a/tests/AllianceAcceptNukes.test.ts +++ b/tests/AllianceAcceptNukes.test.ts @@ -136,6 +136,9 @@ describe("Alliance acceptance destroys nukes", () => { }); test("queued nukes never spawn after alliance acceptance (race condition)", () => { + // Ensure the target tile is owned by player2 + player2.conquer(game.ref(20, 20)); + const exec = new NukeExecution( UnitType.AtomBomb, player1, From 8d2efaea6dc8c55f248c5b0d97f788dc507adec3 Mon Sep 17 00:00:00 2001 From: Marius Achim Date: Sun, 4 Jan 2026 09:46:54 +0200 Subject: [PATCH 4/5] Moved logic into PlayerExecution and NukeExecution no longer exposes states --- src/core/execution/NukeExecution.ts | 38 +---- src/core/execution/PlayerExecution.ts | 52 +++++- src/core/game/Game.ts | 11 -- src/core/game/GameImpl.ts | 172 -------------------- tests/AllianceAcceptNukes.test.ts | 167 ++++++++----------- tests/core/executions/NukeExecution.test.ts | 22 --- 6 files changed, 124 insertions(+), 338 deletions(-) diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index abc54bb9b5..17cf878ea7 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -42,10 +42,7 @@ export class NukeExecution implements Execution { this.pathFinder = new ParabolaPathFinder(mg); } - public target(): Player | TerraNullius | null { - // Not initialized yet -> queued execution - if (!this.mg) return null; - + public target(): Player | TerraNullius { return this.mg.owner(this.dst); } @@ -67,25 +64,6 @@ export class NukeExecution implements Execution { return this.tilesToDestroyCache; } - public targetTile(): TileRef { - return this.dst; - } - - public isInFlight(): boolean { - return this.nuke !== null; - } - - public destroyInFlight(): void { - if (!this.active) return; - - if (this.nuke) { - this.nuke.delete(false); - this.nuke = null; - } - - this.active = false; - } - /** * Break alliances with players significantly affected by the nuke strike. * Uses weighted tile counting (inner=1, outer=0.5). @@ -269,12 +247,12 @@ export class NukeExecution implements Execution { throw new Error("Not initialized"); } - const target = this.target(); const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type()); const toDestroy = this.tilesToDestroy(); - const maxTroops = - target && target.isPlayer() ? this.mg.config().maxTroops(target) : 1; + const maxTroops = this.target().isPlayer() + ? this.mg.config().maxTroops(this.target() as Player) + : 1; for (const tile of toDestroy) { const owner = this.mg.owner(tile); @@ -341,11 +319,9 @@ export class NukeExecution implements Execution { this.nuke.delete(false); // Record stats - if (target) { - this.mg - .stats() - .bombLand(this.player, target, this.nuke.type() as NukeType); - } + this.mg + .stats() + .bombLand(this.player, this.target(), this.nuke.type() as NukeType); } private redrawBuildings(range: number) { diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index c7e452e5d3..301976e4d4 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -1,5 +1,5 @@ import { Config } from "../configuration/Config"; -import { Execution, Game, Player, UnitType } from "../game/Game"; +import { Execution, Game, MessageType, Player, UnitType } from "../game/Game"; import { TileRef } from "../game/GameMap"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; @@ -34,6 +34,56 @@ export class PlayerExecution implements Execution { tick(ticks: number) { this.player.decayRelations(); + + // Neutralize incoming nukes launched by friendly players + const neutralizedByPlayer = new Map(); + for (const unit of this.mg.units( + UnitType.AtomBomb, + UnitType.HydrogenBomb, + )) { + if (!unit.isActive() || unit.reachedTarget()) continue; + + const targetTile = unit.targetTile(); + if (targetTile === undefined) continue; + + const targetOwner = this.mg.owner(targetTile); + if (targetOwner !== this.player) continue; + + const launcher = unit.owner(); + if (launcher === this.player) continue; + + if (launcher.isFriendly(this.player)) { + unit.delete(false); + + neutralizedByPlayer.set( + launcher, + (neutralizedByPlayer.get(launcher) ?? 0) + 1, + ); + } + } + + for (const [launcher, count] of neutralizedByPlayer) { + if (!count) continue; + + // Message for me + this.mg.displayMessage( + `${count} nuke${count > 1 ? "s" : ""} launched by ${launcher.displayName()} toward you ${ + count > 1 ? "were" : "was" + } destroyed due to the alliance`, + MessageType.ALLIANCE_ACCEPTED, + this.player.id(), + ); + + // Message for the launcher + this.mg.displayMessage( + `${count} nuke${count > 1 ? "s" : ""} launched toward ${this.player.displayName()} ${ + count > 1 ? "were" : "was" + } destroyed due to the alliance`, + MessageType.ALLIANCE_ACCEPTED, + launcher.id(), + ); + } + for (const u of this.player.units()) { if (!u.info().territoryBound) { continue; diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 2a1f6fcf61..ec54e4a515 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -779,10 +779,6 @@ export interface Game extends GameMap { addUpdate(update: GameUpdate): void; railNetwork(): RailNetwork; conquerPlayer(conqueror: Player, conquered: Player): void; - - destroyNukesBetween(p1: Player, p2: Player): DestroyNukesResult; - - executions(): Execution[]; } export interface PlayerActions { @@ -829,13 +825,6 @@ export interface EmojiMessage { createdAt: Tick; } -export type DestroyNukesResult = { - inFlight: number; - queued: number; - fromRequestorToRecipient: number; - fromRecipientToRequestor: number; -}; - export enum MessageType { ATTACK_FAILED, ATTACK_CANCELLED, diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 078e268430..60592dde7e 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,6 +1,5 @@ import { renderNumber } from "../../client/Utils"; import { Config } from "../configuration/Config"; -import { NukeExecution } from "../execution/NukeExecution"; import { AllPlayersStats, ClientID, Winner } from "../Schemas"; import { simpleHash } from "../Util"; import { AllianceImpl } from "./AllianceImpl"; @@ -10,7 +9,6 @@ import { AllianceRequest, Cell, ColoredTeams, - DestroyNukesResult, Duos, EmojiMessage, Execution, @@ -279,75 +277,6 @@ export class GameImpl implements Game { return ar; } - public destroyNukesBetween(p1: Player, p2: Player): DestroyNukesResult { - let inFlight = 0; - let queued = 0; - let fromRequestorToRecipient = 0; - let fromRecipientToRequestor = 0; - - const destroy = (exec: Execution, isQueued: boolean) => { - if (!(exec instanceof NukeExecution)) return; - - const launcher = exec.owner(); - - // queued execution -> target not resolvable yet - const target = - exec.isInFlight() && exec.target() - ? exec.target() - : exec instanceof NukeExecution - ? exec.targetTile() - : null; - - if (!target) return; - - let targetOwner: Player | TerraNullius; - if (typeof target === "object" && "isPlayer" in target) { - // target is already a Player or TerraNullius (in-flight nuke) - targetOwner = target as Player | TerraNullius; - } else { - // target is a TileRef (queued nuke) - targetOwner = this.owner(target as TileRef); - } - - const isRequestorToRecipient = launcher === p1 && targetOwner === p2; - const isRecipientToRequestor = launcher === p2 && targetOwner === p1; - - const isBetween = isRequestorToRecipient || isRecipientToRequestor; - - if (!isBetween) { - return; - } - - if (isQueued) queued++; - else inFlight++; - - if (isRequestorToRecipient) fromRequestorToRecipient++; - else fromRecipientToRequestor++; - - exec.destroyInFlight(); - }; - - for (const exec of this.execs) { - if (exec instanceof NukeExecution && !exec.isInFlight()) { - // initialized but not launched yet -> queued - destroy(exec, true); - } else { - destroy(exec, false); - } - } - - for (const exec of this.unInitExecs) { - destroy(exec, true); - } - - return { - inFlight, - queued, - fromRequestorToRecipient, - fromRecipientToRequestor, - }; - } - acceptAllianceRequest(request: AllianceRequestImpl) { this.allianceRequests = this.allianceRequests.filter( (ar) => ar !== request, @@ -382,110 +311,9 @@ export class GameImpl implements Game { if (recipient.hasEmbargoAgainst(requestor)) recipient.endTemporaryEmbargo(requestor); - const { - inFlight, - queued, - fromRequestorToRecipient, - fromRecipientToRequestor, - } = this.destroyNukesBetween(requestor, recipient); - // Destroy counts available for display messages this.unInitExecs = this.unInitExecs.filter((e) => e.isActive()); - if (fromRequestorToRecipient > 0) { - const requestorMsg = `${fromRequestorToRecipient} nuke${ - fromRequestorToRecipient > 1 ? "s" : "" - } launched towards ${recipient.displayName()} ${ - fromRequestorToRecipient > 1 ? "were" : "was" - } destroyed due to the alliance`; - - const recipientMsg = `${fromRequestorToRecipient} nuke${ - fromRequestorToRecipient > 1 ? "s" : "" - } launched by ${requestor.displayName()} towards you ${ - fromRequestorToRecipient > 1 ? "were" : "was" - } destroyed due to the alliance`; - - this.displayMessage( - requestorMsg, - MessageType.ALLIANCE_ACCEPTED, - requestor.id(), - ); - this.displayMessage( - recipientMsg, - MessageType.ALLIANCE_ACCEPTED, - recipient.id(), - ); - } - - if (fromRecipientToRequestor > 0) { - const requestorMsg = `${fromRecipientToRequestor} nuke${ - fromRecipientToRequestor > 1 ? "s" : "" - } launched by ${recipient.displayName()} towards you ${ - fromRecipientToRequestor > 1 ? "were" : "was" - } destroyed due to the alliance`; - - const recipientMsg = `${fromRecipientToRequestor} nuke${ - fromRecipientToRequestor > 1 ? "s" : "" - } launched towards ${requestor.displayName()} ${ - fromRecipientToRequestor > 1 ? "were" : "was" - } destroyed due to the alliance`; - - this.displayMessage( - requestorMsg, - MessageType.ALLIANCE_ACCEPTED, - requestor.id(), - ); - this.displayMessage( - recipientMsg, - MessageType.ALLIANCE_ACCEPTED, - recipient.id(), - ); - } - - if (inFlight > 0) { - const baseMsg = `${inFlight} nuke${inFlight > 1 ? "s" : ""} in flight ${ - inFlight > 1 ? "were" : "was" - } neutralized due to alliance formation with`; - - const requestorMsg = `${inFlight} nuke${ - inFlight > 1 ? "s" : "" - } in flight ${ - inFlight > 1 ? "were" : "was" - } neutralized due to alliance formation with ${recipient.displayName()}`; - const recipientMsg = baseMsg + ` ${requestor.displayName()}`; - - this.displayMessage( - requestorMsg, - MessageType.ALLIANCE_ACCEPTED, - requestor.id(), - ); - this.displayMessage( - recipientMsg, - MessageType.ALLIANCE_ACCEPTED, - recipient.id(), - ); - } - - if (queued > 0) { - const baseMsg = `${queued} planned nuke${queued > 1 ? "s" : ""} ${ - queued > 1 ? "were" : "was" - } canceled due to alliance formation with`; - - const requestorMsg = baseMsg + ` ${recipient.displayName()}`; - const recipientMsg = baseMsg + ` ${requestor.displayName()}`; - - this.displayMessage( - requestorMsg, - MessageType.ALLIANCE_ACCEPTED, - requestor.id(), - ); - this.displayMessage( - recipientMsg, - MessageType.ALLIANCE_ACCEPTED, - recipient.id(), - ); - } - this.addUpdate({ type: GameUpdateType.AllianceRequestReply, request: request.toUpdate(), diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts index d5f3b325fa..36c5f4ce4d 100644 --- a/tests/AllianceAcceptNukes.test.ts +++ b/tests/AllianceAcceptNukes.test.ts @@ -1,4 +1,6 @@ +import { GameUpdateType } from "src/core/game/GameUpdates"; import { NukeExecution } from "../src/core/execution/NukeExecution"; +import { PlayerExecution } from "../src/core/execution/PlayerExecution"; import { Game, Player, @@ -6,7 +8,6 @@ import { PlayerType, UnitType, } from "../src/core/game/Game"; -import { GameUpdateType } from "../src/core/game/GameUpdates"; import { setup } from "./util/Setup"; import { TestConfig } from "./util/TestConfig"; @@ -42,85 +43,47 @@ describe("Alliance acceptance destroys nukes", () => { player3 = game.player("p3"); player1.conquer(game.ref(0, 0)); - player2.conquer(game.ref(0, 1)); - player3.conquer(game.ref(10, 10)); - }); - - test("accepting alliance destroys queued nukes between players", () => { - // Ensure the target tile is owned by player2 player2.conquer(game.ref(5, 5)); - game.addExecution( - new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), - ); - - expect(game.executions().length).toBe(1); - - const req = player1.createAllianceRequest(player2); - req!.accept(); - game.executeNextTick(); + player3.conquer(game.ref(10, 10)); - expect(game.executions().length).toBe(0); + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); }); test("accepting alliance destroys in-flight nukes between players", () => { - // Ensure target owned by player2 - player2.conquer(game.ref(5, 5)); - - player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); - - const exec = new NukeExecution( - UnitType.AtomBomb, - player1, - game.ref(5, 5), - game.ref(0, 0), + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), ); - game.addExecution(exec); game.executeNextTick(); // init game.executeNextTick(); // spawn nuke - expect(exec.isInFlight()).toBe(true); - expect(exec.isActive()).toBe(true); - - const req = player1.createAllianceRequest(player2); - req!.accept(); - game.executeNextTick(); - - expect(exec.isActive()).toBe(false); - }); + const nukesBefore = game.units(UnitType.AtomBomb).length; + expect(nukesBefore).toBe(1); - test("queued and in-flight nukes are counted correctly", () => { - player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); - player2.conquer(game.ref(5, 5)); - player2.conquer(game.ref(6, 6)); + const req = player2.createAllianceRequest(player1); + req!.accept(); - const inFlight = new NukeExecution( - UnitType.AtomBomb, - player1, - game.ref(5, 5), - game.ref(0, 0), - ); + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); - const queued = new NukeExecution( - UnitType.AtomBomb, - player1, - game.ref(6, 6), - null, - ); + // Run PlayerExecution.tick() for the target player, so it doesn't depend on tick ordering. + const pe = new PlayerExecution(player2); + pe.init(game, game.ticks()); + pe.tick(game.ticks()); - // Spawn the in-flight nuke first - game.addExecution(inFlight); + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); game.executeNextTick(); - game.executeNextTick(); // spawn first - - // Add queued after the first has spawned so it remains queued - game.addExecution(queued); - - const result = game.destroyNukesBetween(player1, player2); - - expect(result.inFlight).toBe(1); - expect(result.queued).toBe(1); + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); }); test("accepting alliance does not destroy nukes targeting third players", () => { @@ -128,62 +91,64 @@ describe("Alliance acceptance destroys nukes", () => { new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null), ); - const req = player1.createAllianceRequest(player2); - req!.accept(); - game.executeNextTick(); - - expect(game.executions().length).toBe(1); - }); - - test("queued nukes never spawn after alliance acceptance (race condition)", () => { - // Ensure the target tile is owned by player2 - player2.conquer(game.ref(20, 20)); - - const exec = new NukeExecution( - UnitType.AtomBomb, - player1, - game.ref(20, 20), - null, - ); + game.executeNextTick(); // init + game.executeNextTick(); // spawn - game.addExecution(exec); + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); const req = player1.createAllianceRequest(player2); req!.accept(); - for (let i = 0; i < 5; i++) { - game.executeNextTick(); - } + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); - expect(exec.isActive()).toBe(false); - expect(game.executions().length).toBe(0); + game.executeNextTick(); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); }); test("accepting alliance displays correct nuke cancellation messages", () => { - // Ensure target owned by player2 - player2.conquer(game.ref(5, 5)); game.addExecution( - new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), ); - const req = player1.createAllianceRequest(player2); + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + const nukesBefore = game.units(UnitType.AtomBomb).length; + expect(nukesBefore).toBe(1); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + const req = player2.createAllianceRequest(player1); req!.accept(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + // Run PlayerExecution.tick() for the target player, so it doesn't depend on tick ordering. + const pe = new PlayerExecution(player2); + pe.init(game, game.ticks()); + pe.tick(game.ticks()); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); const updates = game.executeNextTick(); + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); const messages = updates[GameUpdateType.DisplayEvent]?.map((e) => e.message) ?? []; - // expect(messages.some((m) => m.includes("planned nuke"))).toBe(true); - - // Expect both the queued (planned) message and a directional message expect( - messages.some( - (m) => - m.includes("planned nuke") || - m.includes("launched towards") || - m.includes("launched at") || - m.includes("launched by"), - ), + messages.some((m) => m.includes("destroyed due to the alliance")), ).toBe(true); }); }); diff --git a/tests/core/executions/NukeExecution.test.ts b/tests/core/executions/NukeExecution.test.ts index 4ec328ce9f..d93b83d9f6 100644 --- a/tests/core/executions/NukeExecution.test.ts +++ b/tests/core/executions/NukeExecution.test.ts @@ -125,26 +125,4 @@ describe("NukeExecution", () => { expect(player.isTraitor()).toBe(true); expect(player.isAlliedWith(otherPlayer)).toBe(false); }); - - test("destroyInFlight deactivates execution and removes nuke unit", async () => { - player.buildUnit(UnitType.MissileSilo, game.ref(1, 1), {}); - - const exec = new NukeExecution( - UnitType.AtomBomb, - player, - game.ref(10, 10), - game.ref(1, 1), - ); - - game.addExecution(exec); - game.executeNextTick(); // init - game.executeNextTick(); // spawn nuke - - expect(exec.isInFlight()).toBe(true); - expect(exec.isActive()).toBe(true); - - exec.destroyInFlight(); - - expect(exec.isActive()).toBe(false); - }); }); From 1d4fb1f1bc8b6de06d2a77a8e3ed4d2e3f16362e Mon Sep 17 00:00:00 2001 From: Marius Achim Date: Sun, 4 Jan 2026 10:34:09 +0200 Subject: [PATCH 5/5] chore: clarified comment --- src/core/game/GameImpl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 60592dde7e..aabb234468 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -311,7 +311,7 @@ export class GameImpl implements Game { if (recipient.hasEmbargoAgainst(requestor)) recipient.endTemporaryEmbargo(requestor); - // Destroy counts available for display messages + // Remove inactive executions from the queue before next tick this.unInitExecs = this.unInitExecs.filter((e) => e.isActive()); this.addUpdate({