From 5d52f732780d0f75d004d774399e3adca000da4d Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 26 Dec 2025 22:07:31 +0100 Subject: [PATCH] =?UTF-8?q?The=20clown=20is=20gone!=20=F0=9F=A4=A1=20Natio?= =?UTF-8?q?ns=20send=20much=20better=20emojis=20now=20(#2696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Previously, nations just spammed these two rather toxic emojis: 🤡😡 They now send fewer emojis while attacking, and the clown emoji is reserved for special cases. They got the ability to send emojis in much more cases: - Human didn't donate enough for relation update - Human did donate an ok amount - Human did donate a lot - Responding to emojis that they get sent from a human - Nuke sent - MIRV sent - Retaliation warship sent - Traitor tries to ally - Threat asks for / accepts an alliance request - Disliked human tries to ally - Friendly human tries to ally - They are getting attacked by very much troops - They are getting attacked by very little troops - Congratulating the winner - Bragging with their crown - Charming their allies - Clown-Emoting traitors - Easteregg: Sending a rat emoji to very small humans ## 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 --------- Co-authored-by: iamlewis --- src/core/execution/DonateGoldExecution.ts | 28 +- src/core/execution/DonateTroopExecution.ts | 16 + src/core/execution/EmojiExecution.ts | 31 +- src/core/execution/NationExecution.ts | 8 +- .../nation/NationAllianceBehavior.ts | 34 +- .../execution/nation/NationEmojiBehavior.ts | 320 +++++++++++++++++- .../execution/nation/NationMIRVBehavior.ts | 6 +- .../execution/nation/NationWarshipBehavior.ts | 16 +- src/core/execution/utils/AiAttackBehavior.ts | 20 +- src/core/game/Game.ts | 1 + src/core/game/UnitImpl.ts | 6 + tests/NationAllianceBehavior.test.ts | 9 +- 12 files changed, 441 insertions(+), 54 deletions(-) diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 1eb3a57f6..da292be9f 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -6,14 +6,23 @@ import { Player, PlayerID, } from "../game/Game"; +import { PseudoRandom } from "../PseudoRandom"; import { assertNever, toInt } from "../Util"; +import { EmojiExecution } from "./EmojiExecution"; +import { + EMOJI_DONATION_OK, + EMOJI_DONATION_TOO_SMALL, + EMOJI_LOVE, +} from "./nation/NationEmojiBehavior"; export class DonateGoldExecution implements Execution { private recipient: Player; + private gold: Gold; + private mg: Game; + private random: PseudoRandom; private active = true; - private gold: Gold; constructor( private sender: Player, @@ -25,6 +34,7 @@ export class DonateGoldExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; + this.random = new PseudoRandom(mg.ticks()); if (!mg.hasPlayer(this.recipientID)) { console.warn( @@ -49,6 +59,22 @@ export class DonateGoldExecution implements Execution { if (relationUpdate > 0) { this.recipient.updateRelation(this.sender, relationUpdate); } + + // Select emoji based on donation value + const emoji = + relationUpdate >= 50 + ? EMOJI_LOVE + : relationUpdate > 0 + ? EMOJI_DONATION_OK + : EMOJI_DONATION_TOO_SMALL; + + this.mg.addExecution( + new EmojiExecution( + this.recipient, + this.sender.id(), + this.random.randElement(emoji), + ), + ); } else { console.warn( `cannot send gold from ${this.sender.name()} to ${this.recipient.name()}`, diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index fd2bcbd76..d91313079 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -1,9 +1,15 @@ import { Difficulty, Execution, Game, Player, PlayerID } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { assertNever } from "../Util"; +import { EmojiExecution } from "./EmojiExecution"; +import { + EMOJI_DONATION_TOO_SMALL, + EMOJI_LOVE, +} from "./nation/NationEmojiBehavior"; export class DonateTroopsExecution implements Execution { private recipient: Player; + private random: PseudoRandom; private mg: Game; @@ -47,6 +53,16 @@ export class DonateTroopsExecution implements Execution { if (this.troops >= minTroops) { this.recipient.updateRelation(this.sender, 50); } + + this.mg.addExecution( + new EmojiExecution( + this.recipient, + this.sender.id(), + this.random.randElement( + this.troops >= minTroops ? EMOJI_LOVE : EMOJI_DONATION_TOO_SMALL, + ), + ), + ); } else { console.warn( `cannot send troops from ${this.sender} to ${this.recipient}`, diff --git a/src/core/execution/EmojiExecution.ts b/src/core/execution/EmojiExecution.ts index 3a3283fba..bb248adc9 100644 --- a/src/core/execution/EmojiExecution.ts +++ b/src/core/execution/EmojiExecution.ts @@ -1,16 +1,14 @@ -import { - AllPlayers, - Execution, - Game, - Player, - PlayerID, - PlayerType, -} from "../game/Game"; +import { AllPlayers, Execution, Game, Player, PlayerID } from "../game/Game"; +import { PseudoRandom } from "../PseudoRandom"; import { flattenedEmojiTable } from "../Util"; +import { respondToEmoji } from "./nation/NationEmojiBehavior"; export class EmojiExecution implements Execution { private recipient: Player | typeof AllPlayers; + private mg: Game; + private random: PseudoRandom; + private active = true; constructor( @@ -20,6 +18,9 @@ export class EmojiExecution implements Execution { ) {} init(mg: Game, ticks: number): void { + this.mg = mg; + this.random = new PseudoRandom(mg.ticks()); + if (this.recipientID !== AllPlayers && !mg.hasPlayer(this.recipientID)) { console.warn(`EmojiExecution: recipient ${this.recipientID} not found`); this.active = false; @@ -40,13 +41,13 @@ export class EmojiExecution implements Execution { ); } else if (this.requestor.canSendEmoji(this.recipient)) { this.requestor.sendEmoji(this.recipient, emojiString); - if ( - emojiString === "🖕" && - this.recipient !== AllPlayers && - this.recipient.type() === PlayerType.Nation - ) { - this.recipient.updateRelation(this.requestor, -100); - } + respondToEmoji( + this.mg, + this.random, + this.requestor, + this.recipient, + emojiString, + ); } else { console.warn( `cannot send emoji from ${this.requestor} to ${this.recipient}`, diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index ce7491505..835998a1b 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -26,7 +26,7 @@ import { } from "../Util"; import { ConstructionExecution } from "./ConstructionExecution"; import { NationAllianceBehavior } from "./nation/NationAllianceBehavior"; -import { NationEmojiBehavior } from "./nation/NationEmojiBehavior"; +import { EMOJI_NUKE, NationEmojiBehavior } from "./nation/NationEmojiBehavior"; import { NationMIRVBehavior } from "./nation/NationMIRVBehavior"; import { NationWarshipBehavior } from "./nation/NationWarshipBehavior"; import { structureSpawnTileValue } from "./nation/structureSpawnTileValue"; @@ -136,6 +136,7 @@ export class NationExecution implements Execution { } if ( + this.emojiBehavior === null || this.mirvBehavior === null || this.attackBehavior === null || this.allianceBehavior === null || @@ -157,11 +158,13 @@ export class NationExecution implements Execution { this.random, this.mg, this.player, + this.emojiBehavior, ); this.warshipBehavior = new NationWarshipBehavior( this.random, this.mg, this.player, + this.emojiBehavior, ); this.attackBehavior = new AiAttackBehavior( this.random, @@ -179,6 +182,7 @@ export class NationExecution implements Execution { return; } + this.emojiBehavior.maybeSendCasualEmoji(); this.updateRelationsFromEmbargos(); this.allianceBehavior.handleAllianceRequests(); this.allianceBehavior.handleAllianceExtensionRequests(); @@ -676,7 +680,7 @@ export class NationExecution implements Execution { const tick = this.mg.ticks(); this.lastNukeSent.push([tick, tile]); this.mg.addExecution(new NukeExecution(nukeType, this.player, tile)); - this.emojiBehavior.maybeSendHeckleEmoji(targetPlayer); + this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE); } private randTerritoryTileArray(numTiles: number): TileRef[] { diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index 0408eed3f..036ad9d3e 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -9,17 +9,25 @@ import { PseudoRandom } from "../../PseudoRandom"; import { assertNever } from "../../Util"; import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution"; import { AllianceRequestExecution } from "../alliance/AllianceRequestExecution"; +import { + EMOJI_CONFUSED, + EMOJI_HANDSHAKE, + EMOJI_LOVE, + EMOJI_SCARED_OF_THREAT, + NationEmojiBehavior, +} from "./NationEmojiBehavior"; export class NationAllianceBehavior { constructor( private random: PseudoRandom, private game: Game, private player: Player, + private emojiBehavior: NationEmojiBehavior, ) {} handleAllianceRequests() { for (const req of this.player.incomingAllianceRequests()) { - if (this.getAllianceRequestDecision(req.requestor())) { + if (this.getAllianceDecision(req.requestor(), true)) { req.accept(); } else { req.reject(); @@ -34,7 +42,7 @@ export class NationAllianceBehavior { if (!alliance.onlyOneAgreedToExtend()) continue; const human = alliance.other(this.player); - if (!this.getAllianceRequestDecision(human)) continue; + if (!this.getAllianceDecision(human, true)) continue; this.game.addExecution( new AllianceExtensionExecution(this.player, human.id()), @@ -54,7 +62,7 @@ export class NationAllianceBehavior { this.random.chance(20) && isAcceptablePlayerType(enemy) && this.player.canSendAllianceRequest(enemy) && - this.getAllianceRequestDecision(enemy) + this.getAllianceDecision(enemy, false) ) { this.game.addExecution( new AllianceRequestExecution(this.player, enemy.id()), @@ -63,27 +71,45 @@ export class NationAllianceBehavior { } } - private getAllianceRequestDecision(otherPlayer: Player): boolean { + private getAllianceDecision( + otherPlayer: Player, + isResponse: boolean, + ): boolean { // Easy (dumb) nations sometimes get confused and accept/reject randomly (Just like dumb humans do) if (this.isConfused()) { return this.random.chance(2); } // Nearly always reject traitors if (otherPlayer.isTraitor() && this.random.nextInt(0, 100) >= 10) { + if (isResponse && this.random.chance(3)) { + this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_CONFUSED); + } return false; } // Before caring about the relation, first check if the otherPlayer is a threat // Easy (dumb) nations are blinded by hatred, they don't care about threats, they care about the relation // Impossible (smart) nations on the other hand are analyzing the facts if (this.isAlliancePartnerThreat(otherPlayer)) { + if (!isResponse && this.random.chance(3)) { + this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_SCARED_OF_THREAT); + } + if (isResponse && this.random.chance(3)) { + this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_LOVE); + } return true; } // Reject if relation is bad if (this.player.relation(otherPlayer) < Relation.Neutral) { + if (isResponse && this.random.chance(3)) { + this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_CONFUSED); + } return false; } // Maybe accept if relation is friendly if (this.isAlliancePartnerFriendly(otherPlayer)) { + if (this.random.chance(3)) { + this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_HANDSHAKE); + } return true; } // Reject if we already have some alliances, we don't want to ally with the entire map diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 4aa65e8a2..1a23b6b5a 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -1,4 +1,14 @@ -import { Game, Player, PlayerType, Tick } from "../../game/Game"; +import { + AllPlayers, + Difficulty, + Game, + GameMode, + Player, + PlayerType, + Relation, + Team, + Tick, +} from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; import { flattenedEmojiTable } from "../../Util"; import { EmojiExecution } from "../EmojiExecution"; @@ -13,10 +23,32 @@ export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const ); export const EMOJI_ASSIST_TARGET_ME = (["🥺", "💀"] as const).map(emojiId); export const EMOJI_ASSIST_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId); -export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId); +export const EMOJI_AGGRESSIVE_ATTACK = (["😈"] as const).map(emojiId); +export const EMOJI_ATTACK = (["😡"] as const).map(emojiId); +export const EMOJI_WARSHIP_RETALIATION = (["⛵"] as const).map(emojiId); +export const EMOJI_NUKE = (["☢️", "💥"] as const).map(emojiId); +export const EMOJI_GOT_INSULTED = (["🖕", "😡", "🤡", "😞", "😭"] as const).map( + emojiId, +); +export const EMOJI_LOVE = (["❤️", "😊", "🥰"] as const).map(emojiId); +export const EMOJI_CONFUSED = (["❓", "🤡"] as const).map(emojiId); +export const EMOJI_BRAG = (["👑", "🥇", "💪"] as const).map(emojiId); +export const EMOJI_CHARM_ALLIES = (["🤝", "😇", "💪"] as const).map(emojiId); +export const EMOJI_CLOWN = (["🤡"] as const).map(emojiId); +export const EMOJI_RAT = (["🐀"] as const).map(emojiId); +export const EMOJI_OVERWHELMED = ( + ["💀", "🆘", "😱", "🥺", "😭", "😞", "🫡", "👋"] as const +).map(emojiId); +export const EMOJI_CONGRATULATE = (["👏"] as const).map(emojiId); +export const EMOJI_SCARED_OF_THREAT = (["🙏", "🥺"] as const).map(emojiId); +export const EMOJI_BORED = (["🥱"] as const).map(emojiId); +export const EMOJI_HANDSHAKE = (["🤝"] as const).map(emojiId); +export const EMOJI_DONATION_OK = (["👍"] as const).map(emojiId); +export const EMOJI_DONATION_TOO_SMALL = (["❓", "🥱"] as const).map(emojiId); export class NationEmojiBehavior { private readonly lastEmojiSent = new Map(); + private hasSentWinnerClap = false; constructor( private random: PseudoRandom, @@ -24,28 +56,286 @@ export class NationEmojiBehavior { private player: Player, ) {} - sendEmoji(player: Player, emojisList: number[]) { - if (player.type() !== PlayerType.Human) return; + maybeSendCasualEmoji() { + this.checkOverwhelmedByAttacks(); + this.checkVerySmallAttack(); + this.congratulateWinner(); + this.brag(); + this.charmAllies(); + this.annoyTraitors(); + this.findRat(); + } + + private checkOverwhelmedByAttacks(): void { + if (!this.random.chance(4)) return; + + const incomingAttacks = this.player.incomingAttacks(); + if (incomingAttacks.length === 0) return; + + const incomingTroops = incomingAttacks.reduce( + (sum, attack) => sum + attack.troops(), + 0, + ); + const ourTroops = this.player.troops(); + + // If incoming troops are at least 3x our troops, we're overwhelmed + if (incomingTroops >= ourTroops * 3) { + this.sendEmoji(AllPlayers, EMOJI_OVERWHELMED); + } + } + + private checkVerySmallAttack(): void { + if (!this.random.chance(4)) return; + + const incomingAttacks = this.player.incomingAttacks(); + if (incomingAttacks.length === 0) return; + + const ourTroops = this.player.troops(); + if (ourTroops <= 0) return; + + // Find attacks from humans that are very small (less than 10% of our troops) + for (const attack of incomingAttacks) { + const attacker = attack.attacker(); + if (attacker.type() !== PlayerType.Human) continue; + + if (attack.troops() < ourTroops * 0.1) { + this.maybeSendEmoji( + attacker, + this.random.chance(2) ? EMOJI_CONFUSED : EMOJI_BORED, + ); + } + } + } + + // Check if game is over - send congratulations + private congratulateWinner(): void { + if (this.hasSentWinnerClap) return; + + const percentToWin = this.game.config().percentageTilesOwnedToWin(); + const numTilesWithoutFallout = + this.game.numLandTiles() - this.game.numTilesWithFallout(); + const isTeamGame = + this.game.config().gameConfig().gameMode === GameMode.Team; + + if (isTeamGame) { + // Team game: all nations congratulate if another team won + const teamToTiles = new Map(); + for (const player of this.game.players()) { + const team = player.team(); + if (team === null) continue; + teamToTiles.set( + team, + (teamToTiles.get(team) ?? 0) + player.numTilesOwned(), + ); + } + + const sorted = Array.from(teamToTiles.entries()).sort( + (a, b) => b[1] - a[1], + ); + if (sorted.length === 0) return; + + const [winningTeam, winningTiles] = sorted[0]; + const winningPercent = (winningTiles / numTilesWithoutFallout) * 100; + if (winningPercent < percentToWin) return; + + // Don't congratulate if it's our own team + if (winningTeam === this.player.team()) return; + + this.hasSentWinnerClap = true; + this.sendEmoji(AllPlayers, EMOJI_CONGRATULATE); + } else { + // FFA game: The largest nation congratulates if a human player won + const sorted = this.game + .players() + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + + if (sorted.length === 0) return; + + const firstPlace = sorted[0]; + + // Check if first place has won (crossed the win threshold) + const firstPlacePercent = + (firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100; + if (firstPlacePercent < percentToWin) return; + + // Only send if first place is a human + if (firstPlace.type() !== PlayerType.Human) return; + + // Only the largest nation sends the congratulation + const largestNation = this.game + .players() + .filter((p) => p.type() === PlayerType.Nation) + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0]; + if (largestNation !== this.player) return; + + this.hasSentWinnerClap = true; + this.sendEmoji(firstPlace, EMOJI_CONGRATULATE); + } + } + + // Brag with our crown + private brag(): void { + if (!this.random.chance(100)) return; + + const sorted = this.game + .players() + .sort((a, b) => b.numTilesOwned() - a.numTilesOwned()); + + if (sorted.length === 0 || sorted[0] !== this.player) return; + + this.sendEmoji(AllPlayers, EMOJI_BRAG); + } + + private charmAllies(): void { + if (!this.random.chance(250)) return; + + const humanAllies = this.player + .allies() + .filter((p) => p.type() === PlayerType.Human); + if (humanAllies.length === 0) return; + + const ally = this.random.randElement(humanAllies); + const emojiList = this.random.chance(3) ? EMOJI_LOVE : EMOJI_CHARM_ALLIES; + this.sendEmoji(ally, emojiList); + } + + private annoyTraitors(): void { + if (!this.random.chance(40)) return; + + const traitors = this.game + .players() + .filter( + (p) => + p.type() === PlayerType.Human && + !p.isFriendly(this.player) && + p.isTraitor(), + ); + + if (traitors.length === 0) return; + + const traitor = this.random.randElement(traitors); + this.sendEmoji(traitor, EMOJI_CLOWN); + } + + private findRat(): void { + if (!this.random.chance(10000)) return; + + const totalLand = this.game.numLandTiles(); + const threshold = totalLand * 0.01; // 1% of land + + const smallPlayers = this.game + .players() + .filter( + (p) => + p.type() === PlayerType.Human && + p.numTilesOwned() < threshold && + p.numTilesOwned() > 0, + ); + + if (smallPlayers.length === 0) return; + + const smallPlayer = this.random.randElement(smallPlayers); + this.sendEmoji(smallPlayer, EMOJI_RAT); + } + + maybeSendEmoji( + otherPlayer: Player | typeof AllPlayers, + emojisList: number[], + ) { + if (!this.shouldSendEmoji(otherPlayer)) return; + + return this.sendEmoji(otherPlayer, emojisList); + } + + maybeSendAttackEmoji(otherPlayer: Player) { + if (!this.shouldSendEmoji(otherPlayer)) return; + + // If we have a good relation to the other player, we are probably attacking first (aggressive) + if (this.player.relation(otherPlayer) >= Relation.Neutral) { + if (!this.random.chance(2)) return; + this.sendEmoji(otherPlayer, EMOJI_AGGRESSIVE_ATTACK); + return; + } + + // We are probably retaliating + if (!this.random.chance(4)) return; + this.sendEmoji(otherPlayer, EMOJI_ATTACK); + } + + sendEmoji(otherPlayer: Player | typeof AllPlayers, emojisList: number[]) { + if (!this.shouldSendEmoji(otherPlayer, false)) return; + this.game.addExecution( new EmojiExecution( this.player, - player.id(), + otherPlayer === AllPlayers ? AllPlayers : otherPlayer.id(), this.random.randElement(emojisList), ), ); } - maybeSendHeckleEmoji(enemy: Player) { - if (this.player.type() === PlayerType.Bot) return; - if (enemy.type() !== PlayerType.Human) return; - const lastSent = this.lastEmojiSent.get(enemy) ?? -300; - if (this.game.ticks() - lastSent <= 300) return; - this.lastEmojiSent.set(enemy, this.game.ticks()); - this.game.addExecution( + private shouldSendEmoji( + otherPlayer: Player | typeof AllPlayers, + limitEmojisByTime: boolean = true, + ): boolean { + if (otherPlayer === AllPlayers) return true; + if (this.player.type() === PlayerType.Bot) return false; + if (otherPlayer.type() !== PlayerType.Human) return false; + + if (limitEmojisByTime) { + const lastSent = this.lastEmojiSent.get(otherPlayer) ?? -300; + if (this.game.ticks() - lastSent <= 300) return false; + this.lastEmojiSent.set(otherPlayer, this.game.ticks()); + } + + return true; + } +} + +export function respondToEmoji( + game: Game, + random: PseudoRandom, + sender: Player, + recipient: Player | typeof AllPlayers, + emojiString: string, +): void { + if (recipient === AllPlayers || recipient.type() !== PlayerType.Nation) { + return; + } + + if (emojiString === "🖕") { + recipient.updateRelation(sender, -100); + game.addExecution( new EmojiExecution( - this.player, - enemy.id(), - this.random.randElement(EMOJI_HECKLE), + recipient, + sender.id(), + random.randElement(EMOJI_GOT_INSULTED), + ), + ); + } + + if (emojiString === "🤡") { + recipient.updateRelation(sender, -10); + game.addExecution( + new EmojiExecution( + recipient, + sender.id(), + random.randElement(EMOJI_CONFUSED), + ), + ); + } + + if (["🕊️", "🏳️", "❤️", "🥰", "👏"].includes(emojiString)) { + if (game.config().gameConfig().difficulty === Difficulty.Easy) { + recipient.updateRelation(sender, 15); + } + game.addExecution( + new EmojiExecution( + recipient, + sender.id(), + sender.relation(recipient) >= Relation.Neutral + ? random.randElement(EMOJI_LOVE) + : random.randElement(EMOJI_CONFUSED), ), ); } diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts index a760b89b2..1f5dfd374 100644 --- a/src/core/execution/nation/NationMIRVBehavior.ts +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -1,4 +1,5 @@ import { + AllPlayers, Difficulty, Game, Gold, @@ -11,7 +12,7 @@ import { PseudoRandom } from "../../PseudoRandom"; import { assertNever } from "../../Util"; import { MirvExecution } from "../MIRVExecution"; import { calculateTerritoryCenter } from "../Util"; -import { NationEmojiBehavior } from "./NationEmojiBehavior"; +import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior"; export class NationMIRVBehavior { constructor( @@ -251,11 +252,12 @@ export class NationMIRVBehavior { private maybeSendMIRV(enemy: Player): void { if (this.player === null) throw new Error("not initialized"); - this.emojiBehavior.maybeSendHeckleEmoji(enemy); + this.emojiBehavior.maybeSendAttackEmoji(enemy); const centerTile = this.calculateTerritoryCenter(enemy); if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) { this.game.addExecution(new MirvExecution(this.player, centerTile)); + this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE); } } diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts index e98a490eb..ef7425ffb 100644 --- a/src/core/execution/nation/NationWarshipBehavior.ts +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -1,4 +1,5 @@ import { + AllPlayers, Difficulty, Game, Gold, @@ -10,6 +11,10 @@ import { import { TileRef } from "../../game/GameMap"; import { PseudoRandom } from "../../PseudoRandom"; import { ConstructionExecution } from "../ConstructionExecution"; +import { + EMOJI_WARSHIP_RETALIATION, + NationEmojiBehavior, +} from "./NationEmojiBehavior"; export class NationWarshipBehavior { // Track our transport ships we currently own @@ -21,6 +26,7 @@ export class NationWarshipBehavior { private random: PseudoRandom, private game: Game, private player: Player, + private emojiBehavior: NationEmojiBehavior, ) {} trackShipsAndRetaliate(): void { @@ -39,8 +45,8 @@ export class NationWarshipBehavior { for (const ship of Array.from(this.trackedTransportShips)) { if (!ship.isActive()) { // Distinguish between arrival/retreat and enemy destruction - if (ship.wasDestroyedByEnemy()) { - this.maybeRetaliateWithWarship(ship.tile()); + if (ship.wasDestroyedByEnemy() && ship.destroyer() !== undefined) { + this.maybeRetaliateWithWarship(ship.tile(), ship.destroyer()!); } this.trackedTransportShips.delete(ship); } @@ -62,13 +68,13 @@ export class NationWarshipBehavior { } if (ship.owner().id() !== this.player.id()) { // Ship was ours and is now owned by someone else -> captured - this.maybeRetaliateWithWarship(ship.tile()); + this.maybeRetaliateWithWarship(ship.tile(), ship.owner()); this.trackedTradeShips.delete(ship); } } } - private maybeRetaliateWithWarship(tile: TileRef): void { + private maybeRetaliateWithWarship(tile: TileRef, enemy: Player): void { const { difficulty } = this.game.config().gameConfig(); // In Easy never retaliate. In Medium retaliate with 15% chance. Hard with 50%, Impossible with 80%. if ( @@ -83,6 +89,7 @@ export class NationWarshipBehavior { this.game.addExecution( new ConstructionExecution(this.player, UnitType.Warship, tile), ); + this.emojiBehavior.maybeSendEmoji(enemy, EMOJI_WARSHIP_RETALIATION); } } @@ -256,6 +263,7 @@ export class NationWarshipBehavior { target.warship.tile(), ), ); + this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_WARSHIP_RETALIATION); } private cost(type: UnitType): Gold { diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 031bb9e7c..853bb2855 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -478,6 +478,11 @@ export class AiAttackBehavior { return; } + if (target.isPlayer() && this.player.type() === PlayerType.Nation) { + if (this.emojiBehavior === undefined) throw new Error("not initialized"); + this.emojiBehavior.maybeSendAttackEmoji(target); + } + this.game.addExecution( new AttackExecution( troops, @@ -485,11 +490,6 @@ export class AiAttackBehavior { target.isPlayer() ? target.id() : this.game.terraNullius().id(), ), ); - - if (target.isPlayer() && this.player.type() === PlayerType.Nation) { - if (this.emojiBehavior === undefined) throw new Error("not initialized"); - this.emojiBehavior.maybeSendHeckleEmoji(target); - } } private sendBoatAttack(target: Player) { @@ -515,6 +515,11 @@ export class AiAttackBehavior { return; } + if (target.isPlayer() && this.player.type() === PlayerType.Nation) { + if (this.emojiBehavior === undefined) throw new Error("not initialized"); + this.emojiBehavior.maybeSendAttackEmoji(target); + } + this.game.addExecution( new TransportShipExecution( this.player, @@ -524,11 +529,6 @@ export class AiAttackBehavior { null, ), ); - - if (target.isPlayer() && this.player.type() === PlayerType.Nation) { - if (this.emojiBehavior === undefined) throw new Error("not initialized"); - this.emojiBehavior.maybeSendHeckleEmoji(target); - } } private calculateBotAttackTroops(target: Player, maxTroops: number): number { diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 0038e0a9b..4a33d9b80 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -459,6 +459,7 @@ export interface Unit { hasTrainStation(): boolean; setTrainStation(trainStation: boolean): void; wasDestroyedByEnemy(): boolean; + destroyer(): Player | undefined; // Train trainType(): TrainType | undefined; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index b4db4366e..98344ec4b 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -25,6 +25,7 @@ export class UnitImpl implements Unit { private _targetedBySAM = false; private _reachedTarget = false; private _wasDestroyedByEnemy: boolean = false; + private _destroyer: Player | undefined = undefined; private _lastSetSafeFromPirates: number; // Only for trade ships private _underConstruction: boolean = false; private _lastOwner: PlayerImpl | null = null; @@ -258,6 +259,7 @@ export class UnitImpl implements Unit { // Record whether this unit was destroyed by an enemy (vs. arrived / retreated) this._wasDestroyedByEnemy = destroyer !== undefined; + this._destroyer = destroyer ?? undefined; this._owner._units = this._owner._units.filter((b) => b !== this); this._active = false; @@ -302,6 +304,10 @@ export class UnitImpl implements Unit { return this._wasDestroyedByEnemy; } + destroyer(): Player | undefined { + return this._destroyer; + } + retreating(): boolean { return this._retreating; } diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts index a27480140..a7d3a5951 100644 --- a/tests/NationAllianceBehavior.test.ts +++ b/tests/NationAllianceBehavior.test.ts @@ -1,4 +1,5 @@ import { NationAllianceBehavior } from "../src/core/execution/nation/NationAllianceBehavior"; +import { NationEmojiBehavior } from "../src/core/execution/nation/NationEmojiBehavior"; import { AllianceRequest, Game, @@ -44,7 +45,12 @@ describe("AllianceBehavior.handleAllianceRequests", () => { // Use a fixed random seed for deterministic behavior const random = new PseudoRandom(46); - allianceBehavior = new NationAllianceBehavior(random, game, player); + allianceBehavior = new NationAllianceBehavior( + random, + game, + player, + new NationEmojiBehavior(random, game, player), + ); }); function setupAllianceRequest({ @@ -164,6 +170,7 @@ describe("AllianceBehavior.handleAllianceExtensionRequests", () => { mockRandom, mockGame, mockPlayer, + new NationEmojiBehavior(mockRandom, mockGame, mockPlayer), ); });