diff --git a/src/core/execution/DonateGoldExecution.ts b/src/core/execution/DonateGoldExecution.ts index 623fe6f90..c12575b18 100644 --- a/src/core/execution/DonateGoldExecution.ts +++ b/src/core/execution/DonateGoldExecution.ts @@ -62,7 +62,10 @@ export class DonateGoldExecution implements Execution { } // Only AI nations auto-respond with emojis, human players should not - if (this.recipient.type() === PlayerType.Nation) { + if ( + this.recipient.type() === PlayerType.Nation && + this.recipient.canSendEmoji(this.sender) + ) { // Select emoji based on donation value const emoji = relationUpdate >= 50 diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index 54992a2c3..fabe7e82e 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -62,7 +62,10 @@ export class DonateTroopsExecution implements Execution { } // Only AI nations auto-respond with emojis, human players should not - if (this.recipient.type() === PlayerType.Nation) { + if ( + this.recipient.type() === PlayerType.Nation && + this.recipient.canSendEmoji(this.sender) + ) { this.mg.addExecution( new EmojiExecution( this.recipient, diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index ce03b7650..cbf78304b 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -258,10 +258,10 @@ export class NationAllianceBehavior { case Difficulty.Easy: return false; // On easy we never think we have enough alliances case Difficulty.Medium: - return this.player.alliances().length >= this.random.nextInt(5, 8); + return this.player.alliances().length >= this.random.nextInt(4, 6); case Difficulty.Hard: case Difficulty.Impossible: { - // On hard and impossible we try to not ally with all our neighbors (If we have 3+ neighbors) + // On hard and impossible we try to not ally with all our neighbors (If we have 2+ neighbors) const borderingPlayers = this.player .neighbors() .filter( @@ -271,15 +271,15 @@ export class NationAllianceBehavior { (o) => this.player?.isFriendly(o) === true, ); if ( - borderingPlayers.length >= 3 && + borderingPlayers.length >= 2 && borderingPlayers.includes(otherPlayer) ) { return borderingPlayers.length <= borderingFriends.length + 1; } if (difficulty === Difficulty.Hard) { - return this.player.alliances().length >= this.random.nextInt(3, 6); + return this.player.alliances().length >= this.random.nextInt(3, 5); } - return this.player.alliances().length >= this.random.nextInt(2, 5); + return this.player.alliances().length >= this.random.nextInt(2, 4); } default: assertNever(difficulty); @@ -310,30 +310,43 @@ export class NationAllianceBehavior { // It would make a lot of sense to use nextFloat here, but "there's a chance floats can cause desyncs" private isAlliancePartnerSimilarlyStrong(otherPlayer: Player): boolean { const { difficulty } = this.game.config().gameConfig(); - switch (difficulty) { - case Difficulty.Easy: - return ( - otherPlayer.troops() > - this.player.troops() * (this.random.nextInt(60, 70) / 100) - ); - case Difficulty.Medium: - return ( - otherPlayer.troops() > - this.player.troops() * (this.random.nextInt(70, 80) / 100) - ); - case Difficulty.Hard: - return ( - otherPlayer.troops() > - this.player.troops() * (this.random.nextInt(75, 85) / 100) - ); - case Difficulty.Impossible: - return ( - otherPlayer.troops() > - this.player.troops() * (this.random.nextInt(80, 90) / 100) - ); - default: - assertNever(difficulty); - } + const troopPercentRangeByDifficulty = { + [Difficulty.Easy]: [60, 70], + [Difficulty.Medium]: [70, 80], + [Difficulty.Hard]: [75, 85], + [Difficulty.Impossible]: [80, 90], + } as const; + const tilePercentRangeByDifficulty = { + [Difficulty.Easy]: [70, 80], + [Difficulty.Medium]: [80, 90], + [Difficulty.Hard]: [85, 95], + [Difficulty.Impossible]: [90, 100], + } as const; + + const troopRange = troopPercentRangeByDifficulty[difficulty]; + const tileRange = tilePercentRangeByDifficulty[difficulty]; + + const playerOutgoingTroops = this.player + .outgoingAttacks() + .reduce((sum, attack) => sum + attack.troops(), 0); + const otherOutgoingTroops = otherPlayer + .outgoingAttacks() + .reduce((sum, attack) => sum + attack.troops(), 0); + const playerTotalTroops = this.player.troops() + playerOutgoingTroops; + const otherTotalTroops = otherPlayer.troops() + otherOutgoingTroops; + + const troopThreshold = + playerTotalTroops * + (this.random.nextInt(troopRange[0], troopRange[1]) / 100); + const tileThreshold = + this.player.numTilesOwned() * + (this.random.nextInt(tileRange[0], tileRange[1]) / 100); + + const hasComparableTroops = otherTotalTroops > troopThreshold; + const hasComparableTiles = + otherPlayer.numTilesOwned() > tileThreshold && + otherTotalTroops > playerTotalTroops * 0.5; + return hasComparableTroops || hasComparableTiles; } maybeBetray(otherPlayer: Player, borderingPlayerCount: number): boolean { diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index ebb2e0f02..7507466f4 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -46,7 +46,7 @@ export const EMOJI_DONATION_TOO_SMALL = (["❓", "🥱"] as const).map(emojiId); export class NationEmojiBehavior { private readonly lastEmojiSent = new Map(); - private hasSentWinnerClap = false; + private gameOver = false; constructor( private random: PseudoRandom, @@ -107,7 +107,7 @@ export class NationEmojiBehavior { // Check if game is over - send congratulations private congratulateWinner(): void { - if (this.hasSentWinnerClap) return; + if (this.gameOver) return; const percentToWin = this.game.config().percentageTilesOwnedToWin(); const numTilesWithoutFallout = @@ -136,10 +136,11 @@ export class NationEmojiBehavior { const winningPercent = (winningTiles / numTilesWithoutFallout) * 100; if (winningPercent < percentToWin) return; + this.gameOver = true; + // 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 @@ -156,6 +157,8 @@ export class NationEmojiBehavior { (firstPlace.numTilesOwned() / numTilesWithoutFallout) * 100; if (firstPlacePercent < percentToWin) return; + this.gameOver = true; + // Only send if first place is a human if (firstPlace.type() !== PlayerType.Human) return; @@ -166,13 +169,13 @@ export class NationEmojiBehavior { .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.gameOver) return; if (!this.random.chance(300)) return; const sorted = this.game diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index 7c2166c99..ef0ebea24 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -60,9 +60,16 @@ export class NationNukeBehavior { const hydroCost = this.getPerceivedNukeCost(UnitType.HydrogenBomb); const atomCost = this.getPerceivedNukeCost(UnitType.AtomBomb); let nukeType: UnitType; - if (this.player.gold() >= hydroCost) { + if ( + !this.game.config().isUnitDisabled(UnitType.HydrogenBomb) && + this.player.gold() >= hydroCost + ) { nukeType = UnitType.HydrogenBomb; - } else if (!this.isHydroNation && this.player.gold() >= atomCost) { + } else if ( + !this.game.config().isUnitDisabled(UnitType.AtomBomb) && + (!this.isHydroNation || this.isUnderHeavyAttack()) && + this.player.gold() >= atomCost + ) { nukeType = UnitType.AtomBomb; } else { return; @@ -342,6 +349,11 @@ export class NationNukeBehavior { // Simulate saving up for a MIRV private getPerceivedNukeCost(type: UnitType): Gold { + // If MIRVs are disabled, return the actual cost + if (this.game.config().isUnitDisabled(UnitType.MIRV)) { + return this.cost(type); + } + // Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that) // or if we already have enough gold to buy both a MIRV and a hydro if ( @@ -352,7 +364,7 @@ export class NationNukeBehavior { return this.cost(type); } - // On Hard & Impossible, ignore perceived cost when under heavy attack (2x troops) + // On Hard & Impossible, ignore perceived cost when under heavy attack // The nation is probably going to get destroyed soon, so go all-in on nukes const difficulty = this.game.config().gameConfig().difficulty; if ( @@ -380,8 +392,7 @@ export class NationNukeBehavior { const myTroops = this.player.troops(); - // Consider it a heavy attack if total incoming attacks have 2x our troops - return totalIncomingTroops >= myTroops * 2; + return totalIncomingTroops >= myTroops; } private removeOldNukeEvents() { @@ -654,13 +665,13 @@ export class NationNukeBehavior { this.recentlySentNukes.push([tick, tile, nukeType]); if (nukeType === UnitType.AtomBomb) { this.atomBombsLaunched++; - // Increase perceived cost by 25% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame) - this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 125n) / 100n; + // Increase perceived cost by 35% each time to simulate saving up for a MIRV (higher than hydro to make atom bombs less attractive for the lategame) + this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 135n) / 100n; } else if (nukeType === UnitType.HydrogenBomb) { this.hydrogenBombsLaunched++; - // Increase perceived cost by 15% each time to simulate saving up for a MIRV + // Increase perceived cost by 25% each time to simulate saving up for a MIRV this.hydrogenBombPerceivedCost = - (this.hydrogenBombPerceivedCost * 115n) / 100n; + (this.hydrogenBombPerceivedCost * 125n) / 100n; } this.game.addExecution(new NukeExecution(nukeType, this.player, tile)); this.emojiBehavior.maybeSendEmoji(targetPlayer, EMOJI_NUKE);