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 [