Nations donate troops now 💀 (In team games) (#2984)

## 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
This commit is contained in:
FloPinguin
2026-01-23 21:33:04 +01:00
committed by GitHub
parent 9415162f51
commit f7d3c2e0bc
4 changed files with 184 additions and 84 deletions
@@ -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<Team, number>();
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
+168 -38
View File
@@ -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;
}
}
+1
View File
@@ -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;
+6
View File
@@ -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 [