Yet another nation improvement PR 🤖 (#2841)

## Description:

### `NationAllianceBehavior`

- `isAlliancePartnerSimilarlyStrong()` now also checks for
`numTilesOwned`, should make it a bit easier to get alliances. The troop
calculation now also uses the players `outgoingAttacks` to make it feel
less random. OF-Discord-Humans complained.
- Rebalanced `checkAlreadyEnoughAlliances()` a bit

### `NationNukeBehavior`

- Don't save up for MIRV if they are disabled
- Don't try to throw atom bombs / hydros if they are disabled
- Hydro-Nations are allowed to throw atom bombs if they are under heavy
attack
- Rebalance `isUnderHeavyAttack()` a bit
- Increased perceived cost

### `NationEmojiBehavior`

- Fix multiple nations congratulated the winner instead of one
- Don't brag with our crown if the game is already over

### `DonateGoldExecution` & `DonateTroopExecution`

- Added `canSendEmoji` checks

## 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
This commit is contained in:
FloPinguin
2026-01-10 05:46:21 +01:00
committed by GitHub
parent 5955f89fe8
commit 240690c574
5 changed files with 77 additions and 44 deletions
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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,
@@ -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 {
@@ -46,7 +46,7 @@ export const EMOJI_DONATION_TOO_SMALL = (["❓", "🥱"] as const).map(emojiId);
export class NationEmojiBehavior {
private readonly lastEmojiSent = new Map<Player, Tick>();
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
@@ -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);