mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-19 02:04:39 +00:00
Improve nations 🤖 (#3206)
## Description: - `AiAttackBehavior`: Because bots delete stolen structures now, nations prioritize attacking bots with structures - `NationMIRVBehavior`: Nations no longer MIRV enemies who already got MIRVed in the last 30 seconds. Some humans complained about getting double-MIRVed by nations. And in games with very high starting gold, ALL nations MIRVed the same player (stop steamroll logic). - `NationAllianceBehavior`: Fixes a comparison logic bug (Thanks to Deshack) - `NationNukeBehavior.ts`: Little atom bomb perceived cost balance change - `MIRVExecution`: To make sure the MIRVing nations are attacking the MIRVed nations (even if they don't share a border), the relation gets updated in both directions now. - `SinglePlayerModal` & `HostLobbyModal`: Update the default difficulty to "Medium" (to synchronize the defaults with the public game default) ## 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:
@@ -63,6 +63,7 @@ export class MirvExecution implements Execution {
|
|||||||
}
|
}
|
||||||
if (this.targetPlayer !== this.player) {
|
if (this.targetPlayer !== this.player) {
|
||||||
this.targetPlayer.updateRelation(this.player, -100);
|
this.targetPlayer.updateRelation(this.player, -100);
|
||||||
|
this.player.updateRelation(this.targetPlayer, -100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class NationAllianceBehavior {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Reject if otherPlayer has allied with 50% or more of all players (Hard and Impossible only)
|
// Reject if otherPlayer has allied with a lot of players (Hard and Impossible only)
|
||||||
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
|
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
|
||||||
if (this.hasTooManyAlliances(otherPlayer)) {
|
if (this.hasTooManyAlliances(otherPlayer)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -148,7 +148,7 @@ export class NationAllianceBehavior {
|
|||||||
.filter((p) => p.type() !== PlayerType.Bot).length;
|
.filter((p) => p.type() !== PlayerType.Bot).length;
|
||||||
const otherPlayerAlliances = otherPlayer.alliances().length;
|
const otherPlayerAlliances = otherPlayer.alliances().length;
|
||||||
|
|
||||||
if (difficulty !== Difficulty.Hard) {
|
if (difficulty === Difficulty.Hard) {
|
||||||
return otherPlayerAlliances >= totalPlayers * 0.5;
|
return otherPlayerAlliances >= totalPlayers * 0.5;
|
||||||
} else {
|
} else {
|
||||||
return otherPlayerAlliances >= totalPlayers * 0.25;
|
return otherPlayerAlliances >= totalPlayers * 0.25;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
Game,
|
Game,
|
||||||
Gold,
|
Gold,
|
||||||
Player,
|
Player,
|
||||||
|
PlayerID,
|
||||||
PlayerType,
|
PlayerType,
|
||||||
|
Tick,
|
||||||
UnitType,
|
UnitType,
|
||||||
} from "../../game/Game";
|
} from "../../game/Game";
|
||||||
import { TileRef } from "../../game/GameMap";
|
import { TileRef } from "../../game/GameMap";
|
||||||
@@ -18,7 +20,15 @@ import {
|
|||||||
respondToMIRV,
|
respondToMIRV,
|
||||||
} from "./NationEmojiBehavior";
|
} from "./NationEmojiBehavior";
|
||||||
|
|
||||||
|
// 30 seconds at 10 ticks/second
|
||||||
|
const MIRV_COOLDOWN_TICKS = 300;
|
||||||
|
|
||||||
export class NationMIRVBehavior {
|
export class NationMIRVBehavior {
|
||||||
|
// Shared across all NationMIRVBehavior instances.
|
||||||
|
// Tracks the last tick a MIRV was sent at each player, so multiple nations don't pile-on the same target.
|
||||||
|
// Especially important for games with very high starting gold settings.
|
||||||
|
private static recentMirvTargets = new Map<PlayerID, Tick>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private random: PseudoRandom,
|
private random: PseudoRandom,
|
||||||
private game: Game,
|
private game: Game,
|
||||||
@@ -119,19 +129,19 @@ export class NationMIRVBehavior {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inboundMIRVSender = this.selectCounterMirvTarget();
|
const inboundMIRVSender = this.selectCounterMirvTarget();
|
||||||
if (inboundMIRVSender) {
|
if (inboundMIRVSender && !this.wasRecentlyMirved(inboundMIRVSender)) {
|
||||||
this.maybeSendMIRV(inboundMIRVSender);
|
this.maybeSendMIRV(inboundMIRVSender);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
const victoryDenialTarget = this.selectVictoryDenialTarget();
|
||||||
if (victoryDenialTarget) {
|
if (victoryDenialTarget && !this.wasRecentlyMirved(victoryDenialTarget)) {
|
||||||
this.maybeSendMIRV(victoryDenialTarget);
|
this.maybeSendMIRV(victoryDenialTarget);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
const steamrollStopTarget = this.selectSteamrollStopTarget();
|
||||||
if (steamrollStopTarget) {
|
if (steamrollStopTarget && !this.wasRecentlyMirved(steamrollStopTarget)) {
|
||||||
this.maybeSendMIRV(steamrollStopTarget);
|
this.maybeSendMIRV(steamrollStopTarget);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -223,6 +233,17 @@ export class NationMIRVBehavior {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MIRV Cooldown Methods
|
||||||
|
private wasRecentlyMirved(target: Player): boolean {
|
||||||
|
const lastTick = NationMIRVBehavior.recentMirvTargets.get(target.id());
|
||||||
|
if (lastTick === undefined) return false;
|
||||||
|
return this.game.ticks() - lastTick < MIRV_COOLDOWN_TICKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordMirvHit(target: Player): void {
|
||||||
|
NationMIRVBehavior.recentMirvTargets.set(target.id(), this.game.ticks());
|
||||||
|
}
|
||||||
|
|
||||||
// MIRV Helper Methods
|
// MIRV Helper Methods
|
||||||
private getValidMirvTargetPlayers(): Player[] {
|
private getValidMirvTargetPlayers(): Player[] {
|
||||||
if (this.player === null) throw new Error("not initialized");
|
if (this.player === null) throw new Error("not initialized");
|
||||||
@@ -261,6 +282,7 @@ export class NationMIRVBehavior {
|
|||||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||||
this.game.addExecution(new MirvExecution(this.player, centerTile));
|
this.game.addExecution(new MirvExecution(this.player, centerTile));
|
||||||
|
this.recordMirvHit(enemy);
|
||||||
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
|
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
|
||||||
respondToMIRV(this.game, this.random, enemy);
|
respondToMIRV(this.game, this.random, enemy);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -659,8 +659,8 @@ export class NationNukeBehavior {
|
|||||||
this.recentlySentNukes.push([tick, tile, nukeType]);
|
this.recentlySentNukes.push([tick, tile, nukeType]);
|
||||||
if (nukeType === UnitType.AtomBomb) {
|
if (nukeType === UnitType.AtomBomb) {
|
||||||
this.atomBombsLaunched++;
|
this.atomBombsLaunched++;
|
||||||
// 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)
|
// Increase perceived cost by 50% 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;
|
this.atomBombPerceivedCost = (this.atomBombPerceivedCost * 150n) / 100n;
|
||||||
} else if (nukeType === UnitType.HydrogenBomb) {
|
} else if (nukeType === UnitType.HydrogenBomb) {
|
||||||
this.hydrogenBombsLaunched++;
|
this.hydrogenBombsLaunched++;
|
||||||
// Increase perceived cost by 25% each time to simulate saving up for a MIRV
|
// Increase perceived cost by 25% each time to simulate saving up for a MIRV
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Game,
|
Game,
|
||||||
GameMode,
|
GameMode,
|
||||||
HumansVsNations,
|
HumansVsNations,
|
||||||
|
isStructureType,
|
||||||
Player,
|
Player,
|
||||||
PlayerID,
|
PlayerID,
|
||||||
PlayerType,
|
PlayerType,
|
||||||
@@ -199,6 +200,13 @@ export class AiAttackBehavior {
|
|||||||
borderingFriends: Player[],
|
borderingFriends: Player[],
|
||||||
borderingEnemies: Player[],
|
borderingEnemies: Player[],
|
||||||
) {
|
) {
|
||||||
|
// In games with high starting gold, nations will quickly build a lot of cities
|
||||||
|
// This causes them to expand slowly (cities increase max troops), and bots will steal their structures
|
||||||
|
// In this case: Attack bots before ratio checks
|
||||||
|
if (this.hasNeighboringBotWithStructures()) {
|
||||||
|
if (this.attackBots()) return;
|
||||||
|
}
|
||||||
|
|
||||||
// Save up troops until we reach the reserve ratio
|
// Save up troops until we reach the reserve ratio
|
||||||
if (!this.hasReserveRatioTroops()) return;
|
if (!this.hasReserveRatioTroops()) return;
|
||||||
|
|
||||||
@@ -345,6 +353,18 @@ export class AiAttackBehavior {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasNeighboringBotWithStructures(): boolean {
|
||||||
|
return this.player
|
||||||
|
.neighbors()
|
||||||
|
.some(
|
||||||
|
(n) =>
|
||||||
|
n.isPlayer() &&
|
||||||
|
n.type() === PlayerType.Bot &&
|
||||||
|
!this.player.isFriendly(n) &&
|
||||||
|
n.units().some((u) => isStructureType(u.type())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private hasReserveRatioTroops(): boolean {
|
private hasReserveRatioTroops(): boolean {
|
||||||
const maxTroops = this.game.config().maxTroops(this.player);
|
const maxTroops = this.game.config().maxTroops(this.player);
|
||||||
const ratio = this.player.troops() / maxTroops;
|
const ratio = this.player.troops() / maxTroops;
|
||||||
@@ -380,6 +400,7 @@ export class AiAttackBehavior {
|
|||||||
|
|
||||||
// Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks)
|
// Sort neighboring bots by density (troops / tiles) and attempt to attack many of them (Parallel attacks)
|
||||||
// sendAttack will do nothing if we don't have enough reserve troops left
|
// sendAttack will do nothing if we don't have enough reserve troops left
|
||||||
|
// Bots that own structures are prioritized as targets (they might have stolen our structures and they will delete them!)
|
||||||
private attackBots(): boolean {
|
private attackBots(): boolean {
|
||||||
const bots = this.player
|
const bots = this.player
|
||||||
.neighbors()
|
.neighbors()
|
||||||
@@ -397,7 +418,16 @@ export class AiAttackBehavior {
|
|||||||
this.botAttackTroopsSent = 0;
|
this.botAttackTroopsSent = 0;
|
||||||
|
|
||||||
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
const density = (p: Player) => p.troops() / p.numTilesOwned();
|
||||||
const sortedBots = bots.slice().sort((a, b) => density(a) - density(b));
|
const ownsStructures = (p: Player) =>
|
||||||
|
p.units().some((u) => isStructureType(u.type()));
|
||||||
|
const sortedBots = bots.slice().sort((a, b) => {
|
||||||
|
const aHasStructures = ownsStructures(a);
|
||||||
|
const bHasStructures = ownsStructures(b);
|
||||||
|
if (aHasStructures !== bHasStructures) {
|
||||||
|
return aHasStructures ? -1 : 1;
|
||||||
|
}
|
||||||
|
return density(a) - density(b);
|
||||||
|
});
|
||||||
const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism());
|
const reducedBots = sortedBots.slice(0, this.getBotAttackMaxParallelism());
|
||||||
|
|
||||||
for (const bot of reducedBots) {
|
for (const bot of reducedBots) {
|
||||||
@@ -700,9 +730,14 @@ export class AiAttackBehavior {
|
|||||||
|
|
||||||
private sendLandAttack(target: Player | TerraNullius) {
|
private sendLandAttack(target: Player | TerraNullius) {
|
||||||
const maxTroops = this.game.config().maxTroops(this.player);
|
const maxTroops = this.game.config().maxTroops(this.player);
|
||||||
const reserveRatio = target.isPlayer()
|
const botWithStructures =
|
||||||
? this.reserveRatio
|
target.isPlayer() &&
|
||||||
: this.expandRatio;
|
target.type() === PlayerType.Bot &&
|
||||||
|
target.units().some((u) => isStructureType(u.type()));
|
||||||
|
// Use the expand ratio when attacking a bot that owns structures — we need to
|
||||||
|
// recapture those structures ASAP, even before reaching the normal reserve.
|
||||||
|
const useReserve = target.isPlayer() && !botWithStructures;
|
||||||
|
const reserveRatio = useReserve ? this.reserveRatio : this.expandRatio;
|
||||||
const targetTroops = maxTroops * reserveRatio;
|
const targetTroops = maxTroops * reserveRatio;
|
||||||
|
|
||||||
let troops;
|
let troops;
|
||||||
|
|||||||
Reference in New Issue
Block a user