diff --git a/resources/lang/en.json b/resources/lang/en.json index c18d7be1a..da453ae7b 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -699,6 +699,8 @@ "alliance_request_status": "{name} {status} your alliance request", "alliance_accepted": "accepted", "alliance_rejected": "rejected", + "alliance_nukes_destroyed_outgoing": "{count, plural, one {# nuke launched toward {name} was destroyed due to the alliance} other {# nukes launched toward {name} were destroyed due to the alliance}}", + "alliance_nukes_destroyed_incoming": "{count, plural, one {# nuke launched by {name} was destroyed due to the alliance} other {# nukes launched by {name} were destroyed due to the alliance}}", "duration_second": "1 second", "betrayal_description": "You broke your alliance with {name}, making you a TRAITOR ({malusPercent}% defense debuff for {durationText})", "duration_seconds_plural": "{seconds} seconds", diff --git a/src/core/execution/alliance/AllianceRequestReplyExecution.ts b/src/core/execution/alliance/AllianceRequestReplyExecution.ts index b1a79fc44..35ec2ef69 100644 --- a/src/core/execution/alliance/AllianceRequestReplyExecution.ts +++ b/src/core/execution/alliance/AllianceRequestReplyExecution.ts @@ -1,4 +1,11 @@ -import { Execution, Game, Player, PlayerID } from "../../game/Game"; +import { + Execution, + Game, + MessageType, + Player, + PlayerID, + UnitType, +} from "../../game/Game"; export class AllianceRequestReplyExecution implements Execution { private active = true; @@ -10,6 +17,57 @@ export class AllianceRequestReplyExecution implements Execution { private accept: boolean, ) {} + private cancelNukesBetweenAlliedPlayers( + mg: Game, + p1: Player, + p2: Player, + ): void { + const neutralized = new Map(); + + const players = [p1, p2]; + + for (const launcher of players) { + for (const unit of launcher.units( + UnitType.AtomBomb, + UnitType.HydrogenBomb, + )) { + if (!unit.isActive() || unit.reachedTarget()) continue; + + const targetTile = unit.targetTile(); + if (!targetTile) continue; + + const targetOwner = mg.owner(targetTile); + if (!targetOwner.isPlayer()) continue; + + const other = launcher === p1 ? p2 : p1; + if (targetOwner !== other) continue; + + unit.delete(false); + neutralized.set(launcher, (neutralized.get(launcher) ?? 0) + 1); + } + } + + for (const [launcher, count] of neutralized) { + const other = launcher === p1 ? p2 : p1; + + mg.displayMessage( + "events_display.alliance_nukes_destroyed_outgoing", + MessageType.ALLIANCE_ACCEPTED, + launcher.id(), + undefined, + { name: other.displayName(), count }, + ); + + mg.displayMessage( + "events_display.alliance_nukes_destroyed_incoming", + MessageType.ALLIANCE_ACCEPTED, + other.id(), + undefined, + { name: launcher.displayName(), count }, + ); + } + } + init(mg: Game, ticks: number): void { if (!mg.hasPlayer(this.requestorID)) { console.warn( @@ -33,6 +91,12 @@ export class AllianceRequestReplyExecution implements Execution { request.accept(); this.requestor.updateRelation(this.recipient, 100); this.recipient.updateRelation(this.requestor, 100); + + this.cancelNukesBetweenAlliedPlayers( + mg, + this.requestor, + this.recipient, + ); } else { request.reject(); } diff --git a/tests/AllianceAcceptNukes.test.ts b/tests/AllianceAcceptNukes.test.ts new file mode 100644 index 000000000..d3da89172 --- /dev/null +++ b/tests/AllianceAcceptNukes.test.ts @@ -0,0 +1,163 @@ +import { AllianceRequestReplyExecution } from "src/core/execution/alliance/AllianceRequestReplyExecution"; +import { GameUpdateType } from "src/core/game/GameUpdates"; +import { NukeExecution } from "../src/core/execution/NukeExecution"; +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 immediately destroys in-flight 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 the newly allied 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 + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player2.createAllianceRequest(player1); + game.addExecution( + new AllianceRequestReplyExecution(player2.id(), player1, true), + ); + + game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); + }); + + test("accepting alliance destroys only nukes between allied players", () => { + player1.buildUnit(UnitType.MissileSilo, game.ref(0, 0), {}); + + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(5, 5), null), + ); + game.addExecution( + new NukeExecution(UnitType.AtomBomb, player1, game.ref(10, 10), null), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn nukes + + expect(game.units(UnitType.AtomBomb)).toHaveLength(2); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player1.createAllianceRequest(player2); + game.addExecution( + new AllianceRequestReplyExecution(player1.id(), player2, true), + ); + + game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + // Ensure remaining nuke targets player3 + const remainingNuke = game.units(UnitType.AtomBomb)[0]; + expect(remainingNuke.targetTile()).toBe(game.ref(10, 10)); + }); + + test("accepting alliance displays a nuke-cancellation display message", () => { + game.addExecution( + new NukeExecution( + UnitType.AtomBomb, + player1, + game.ref(5, 5), + game.ref(0, 0), + -1, + 5, + ), + ); + + game.executeNextTick(); // init + game.executeNextTick(); // spawn nuke + + expect(game.units(UnitType.AtomBomb)).toHaveLength(1); + + expect(player2.isAlliedWith(player1)).toBe(false); + expect(player1.isFriendly(player2)).toBe(false); + + player2.createAllianceRequest(player1); + game.addExecution( + new AllianceRequestReplyExecution(player2.id(), player1, true), + ); + + const updates = game.executeNextTick(); + + expect(player2.isAlliedWith(player1)).toBe(true); + expect(player1.isFriendly(player2)).toBe(true); + + expect(game.units(UnitType.AtomBomb)).toHaveLength(0); + + const messages = + updates[GameUpdateType.DisplayEvent]?.map((e) => e.message) ?? []; + + expect( + messages.some( + (m) => + m === "events_display.alliance_nukes_destroyed_outgoing" || + m === "events_display.alliance_nukes_destroyed_incoming", + ), + ).toBe(true); + }); +});