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/GameImpl.ts b/src/core/game/GameImpl.ts index 465eeaa25a..aabb234468 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -311,6 +311,9 @@ export class GameImpl implements Game { if (recipient.hasEmbargoAgainst(requestor)) recipient.endTemporaryEmbargo(requestor); + // Remove inactive executions from the queue before next tick + this.unInitExecs = this.unInitExecs.filter((e) => e.isActive()); + this.addUpdate({ type: GameUpdateType.AllianceRequestReply, request: request.toUpdate(), @@ -357,6 +360,7 @@ export class GameImpl implements Game { } executeNextTick(): GameUpdates { + const pending = this.updates; this.updates = createGameUpdatesMap(); this.execs.forEach((e) => { if ( @@ -393,7 +397,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..36c5f4ce4d --- /dev/null +++ b/tests/AllianceAcceptNukes.test.ts @@ -0,0 +1,154 @@ +import { GameUpdateType } from "src/core/game/GameUpdates"; +import { NukeExecution } from "../src/core/execution/NukeExecution"; +import { PlayerExecution } from "../src/core/execution/PlayerExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../src/core/game/Game"; +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(5, 5)); + player3.conquer(game.ref(10, 10)); + + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + }); + + test("accepting alliance destroys in-flight nukes between players", () => { + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), + ); + + 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); + game.executeNextTick(); + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); + }); + + test("accepting alliance does not destroy nukes targeting third players", () => { + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + const req = player1.createAllianceRequest(player2); + req!.accept(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + game.executeNextTick(); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + }); + + test("accepting alliance displays correct nuke cancellation messages", () => { + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), + ); + + 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("destroyed due to the alliance")), + ).toBe(true); + }); +});