From f7d3c2e0bcc91b9f18aa0019af07328b07c11e1e Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:33:04 +0100 Subject: [PATCH] =?UTF-8?q?Nations=20donate=20troops=20now=20=F0=9F=92=80?= =?UTF-8?q?=20(In=20team=20games)=20(#2984)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: For v29, balances the HvN winrate. In team games, nations now donate troops to their weakest team members (if they have no attack options available). How often they donate depends on the difficulty. This PR also has some other little fixes: - For HvN games, always return true in `shouldAttack()` (make nations a bit more aggressive). - Early exit in `attackWithRandomBoat()` for performance - Early exit in `findNearestIslandEnemy()` for performance AND to make sure nations which are encircled by friends don't run into this method (=> no donation happening!) ## 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 --- .../execution/nation/NationEmojiBehavior.ts | 55 +---- src/core/execution/utils/AiAttackBehavior.ts | 206 ++++++++++++++---- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 6 + 4 files changed, 184 insertions(+), 84 deletions(-) diff --git a/src/core/execution/nation/NationEmojiBehavior.ts b/src/core/execution/nation/NationEmojiBehavior.ts index 7507466f4..e62515bfd 100644 --- a/src/core/execution/nation/NationEmojiBehavior.ts +++ b/src/core/execution/nation/NationEmojiBehavior.ts @@ -6,7 +6,6 @@ import { Player, PlayerType, Relation, - Team, Tick, } from "../../game/Game"; import { PseudoRandom } from "../../PseudoRandom"; @@ -55,6 +54,8 @@ export class NationEmojiBehavior { ) {} maybeSendCasualEmoji() { + if (this.gameOver) return; + this.checkOverwhelmedByAttacks(); this.checkVerySmallAttack(); this.congratulateWinner(); @@ -107,60 +108,23 @@ export class NationEmojiBehavior { // Check if game is over - send congratulations private congratulateWinner(): void { - if (this.gameOver) return; + const winner = this.game.getWinner(); + if (winner === null) return; + + this.gameOver = true; - 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; - - this.gameOver = true; - // Don't congratulate if it's our own team - if (winningTeam === this.player.team()) return; + if (winner === this.player.team()) return; 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; - - this.gameOver = true; - - // Only send if first place is a human - if (firstPlace.type() !== PlayerType.Human) return; + if (typeof winner === "string") return; // It's a team, not a player // Only the largest nation sends the congratulation const largestNation = this.game @@ -169,13 +133,12 @@ export class NationEmojiBehavior { .sort((a, b) => b.numTilesOwned() - a.numTilesOwned())[0]; if (largestNation !== this.player) return; - this.sendEmoji(firstPlace, EMOJI_CONGRATULATE); + this.sendEmoji(winner, 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/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index fd252316b..5d6cedc08 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -1,11 +1,14 @@ import { Difficulty, Game, + GameMode, + HumansVsNations, Player, PlayerID, PlayerType, Relation, TerraNullius, + UnitType, } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; import { canBuildTransportShip } from "../../game/TransportShipUtils"; @@ -16,6 +19,7 @@ import { calculateBoundingBoxCenter, } from "../../Util"; import { AttackExecution } from "../AttackExecution"; +import { DonateTroopsExecution } from "../DonateTroopExecution"; import { NationAllianceBehavior } from "../nation/NationAllianceBehavior"; import { EMOJI_ASSIST_ACCEPT, @@ -94,6 +98,16 @@ export class AiAttackBehavior { private attackWithRandomBoat(borderingEnemies: Player[] = []) { if (this.player === null) throw new Error("not initialized"); + + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return; + } + + // Check if we have any ocean shore tiles to launch from const oceanShore = Array.from(this.player.borderTiles()).filter((t) => this.game.isOceanShore(t), ); @@ -309,6 +323,8 @@ export class AiAttackBehavior { return false; }; + const donate = (): boolean => this.donateTroops(); + // Return strategies in order based on difficulty // Easy nations get the dumbest order, impossible nations get the smartest order switch (difficulty) { @@ -317,13 +333,13 @@ export class AiAttackBehavior { return [nuked, bots, retaliate, assist, betray, hated, weakest]; case Difficulty.Medium: // prettier-ignore - return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island]; + return [bots, nuked, retaliate, assist, betray, hated, afk, traitor, weakest, island, donate]; case Difficulty.Hard: // prettier-ignore - return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island]; + return [bots, retaliate, assist, betray, nuked, traitor, afk, hated, veryWeak, victim, weakest, island, donate]; case Difficulty.Impossible: // prettier-ignore - return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island]; + return [retaliate, bots, veryWeak, assist, traitor, afk, betray, victim, nuked, hated, weakest, island, donate]; default: assertNever(difficulty); } @@ -519,54 +535,67 @@ export class AiAttackBehavior { } private findNearestIslandEnemy(): Player | null { - const myBorder = this.player.borderTiles(); - if (myBorder.size === 0) return null; + // Check if we've already sent out the maximum number of transport ships + if ( + this.player.unitCount(UnitType.TransportShip) >= + this.game.config().boatMaxNumber() + ) { + return null; + } + + // Check if we have any ocean shore tiles to launch from + const hasOceanShore = Array.from(this.player.borderTiles()).some((t) => + this.game.isOceanShore(t), + ); + if (!hasOceanShore) return null; const filteredPlayers = this.game.players().filter((p) => { if (p === this.player) return false; - if (!p.isAlive()) return false; - if (p.borderTiles().size === 0) return false; if (this.player.isFriendly(p)) return false; // Don't spam boats into players with more troops return p.troops() < this.player.troops(); }); - if (filteredPlayers.length > 0) { - const playerCenter = this.getPlayerCenter(this.player); + if (filteredPlayers.length === 0) return null; - const sortedPlayers = filteredPlayers - .map((filteredPlayer) => { - const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); + const playerCenter = this.getPlayerCenter(this.player); - const playerCenterTile = this.game.ref( - playerCenter.x, - playerCenter.y, - ); - const filteredPlayerCenterTile = this.game.ref( - filteredPlayerCenter.x, - filteredPlayerCenter.y, - ); + const sortedPlayers = filteredPlayers + .map((filteredPlayer) => { + const filteredPlayerCenter = this.getPlayerCenter(filteredPlayer); - const distance = this.game.manhattanDist( - playerCenterTile, - filteredPlayerCenterTile, - ); - return { player: filteredPlayer, distance }; - }) - .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) + const playerCenterTile = this.game.ref(playerCenter.x, playerCenter.y); + const filteredPlayerCenterTile = this.game.ref( + filteredPlayerCenter.x, + filteredPlayerCenter.y, + ); - // Select the nearest or second-nearest enemy (So our boat doesn't always run into the same warship, if there is one) - let selectedEnemy: Player | null; - if (sortedPlayers.length > 1 && this.random.chance(2)) { - selectedEnemy = sortedPlayers[1].player; - } else { - selectedEnemy = sortedPlayers[0].player; - } + const distance = this.game.manhattanDist( + playerCenterTile, + filteredPlayerCenterTile, + ); + return { player: filteredPlayer, distance }; + }) + .sort((a, b) => a.distance - b.distance); // Sort by distance (ascending) - if (selectedEnemy !== null) { - return selectedEnemy; + // Try players in order of distance until we find one reachable by boat + for (const entry of sortedPlayers) { + const closest = closestTwoTiles( + this.game, + Array.from(this.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + Array.from(entry.player.borderTiles()).filter((t) => + this.game.isOceanShore(t), + ), + ); + if (closest === null) continue; + + if (canBuildTransportShip(this.game, this.player, closest.y)) { + return entry.player; } } + return null; } @@ -646,12 +675,14 @@ export class AiAttackBehavior { } shouldAttack(other: Player | TerraNullius): boolean { - // Always attack Terra Nullius, non-humans and traitors (or if we are a bot) if ( + // Always attack Terra Nullius, non-humans and traitors other.isPlayer() === false || other.type() !== PlayerType.Human || other.isTraitor() || - this.player.type() === PlayerType.Bot + // Always attack if we are a bot or in an HvN game + this.player.type() === PlayerType.Bot || + this.game.config().gameConfig().playerTeams === HumansVsNations ) { return true; } @@ -718,6 +749,10 @@ export class AiAttackBehavior { return; } + if (!canBuildTransportShip(this.game, this.player, closest.y)) { + return; + } + let troops; if (target.type() === PlayerType.Bot) { troops = this.calculateBotAttackTroops(target, this.player.troops() / 5); @@ -759,4 +794,99 @@ export class AiAttackBehavior { this.botAttackTroopsSent += troops; return troops; } + + private donateTroops(): boolean { + // Only donate in team games + if (this.game.config().gameConfig().gameMode !== GameMode.Team) { + return false; + } + + // Check if donating troops is allowed + if (this.game.config().donateTroops() === false) { + return false; + } + + // Don't donate if the game has a winner + if (this.game.getWinner() !== null) { + return false; + } + + // Skip donating based on difficulty + const { difficulty } = this.game.config().gameConfig(); + switch (difficulty) { + case Difficulty.Easy: + // Easy nations don't donate + return false; + case Difficulty.Medium: + // Medium nations donate 25% of the time + if (!this.random.chance(4)) { + return false; + } + break; + case Difficulty.Hard: + // Hard nations donate 50% of the time + if (!this.random.chance(2)) { + return false; + } + break; + case Difficulty.Impossible: + // Impossible nations always try to donate + break; + default: + assertNever(difficulty); + } + + // Find teammates who are currently in combat + const teammates = this.game + .players() + .filter((p) => this.player.isOnSameTeam(p)) + .filter( + (p) => p.incomingAttacks().length > 0 || p.outgoingAttacks().length > 0, + ); + + if (teammates.length === 0) { + return false; + } + + // Find teammate with lowest troop percentage (troops / maxTroops) + const teammatesWithTroopPercentage = teammates + .map((teammate) => { + const maxTroops = this.game.config().maxTroops(teammate); + const troopPercentage = teammate.troops() / Math.max(maxTroops, 1); + return { teammate, troopPercentage }; + }) + .sort((a, b) => a.troopPercentage - b.troopPercentage); + + // Try to donate to teammates in order of lowest troop percentage + let selectedTeammate: Player | null = null; + for (const entry of teammatesWithTroopPercentage) { + if (this.player.canDonateTroops(entry.teammate)) { + selectedTeammate = entry.teammate; + break; + } + } + + if (selectedTeammate === null) { + return false; + } + + // Donate a portion of our troops (keeping reserve) + const maxTroops = this.game.config().maxTroops(this.player); + const troopsToKeep = maxTroops * this.reserveRatio; + const availableTroops = this.player.troops() - troopsToKeep; + + if (availableTroops < 1) { + return false; + } + + this.game.addExecution( + new DonateTroopsExecution( + this.player, + selectedTeammate.id(), + availableTroops, + ), + ); + + return true; + } } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 313bd58de..1c56d5d46 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -755,6 +755,7 @@ export interface Game extends GameMap { inSpawnPhase(): boolean; executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; + getWinner(): Player | Team | null; config(): Config; isPaused(): boolean; setPaused(paused: boolean): void; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index a2bd1c902..4a82a20ea 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -92,6 +92,7 @@ export class GameImpl implements Game { private nextAllianceID: number = 0; private _isPaused: boolean = false; + private _winner: Player | Team | null = null; private _miniWaterGraph: AbstractGraph | null = null; private _miniWaterHPA: AStarWaterHierarchical | null = null; @@ -712,6 +713,7 @@ export class GameImpl implements Game { } setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { + this._winner = winner; this.addUpdate({ type: GameUpdateType.Win, winner: this.makeWinner(winner), @@ -719,6 +721,10 @@ export class GameImpl implements Game { }); } + getWinner(): Player | Team | null { + return this._winner; + } + private makeWinner(winner: string | Player): Winner | undefined { if (typeof winner === "string") { return [