Files
OpenFrontIO/src/core/execution/BotExecution.ts
T
FloPinguin 427e462fe5 Revamp nation/bot enemy selection 🗡️ (#2550)
## Description:

I closed my previous PR #2533 which was already reviewed by evan (but
not yet merged) because I noticed some issues.
Which led me to changing the enemy selection entirely. 

Nations / Bots previously had a fixed enemy which they kept for 100
ticks (10 seconds). This could make them react too late and feel slow.
Now they are a bit more responsive.

But the main benefit: Without a fixed enemy we can do multiple
sendAttack() on the same tick, which allowed me to give impossible
nations extremely efficient parallel bot attacks:


https://github.com/user-attachments/assets/38f65623-fbf0-4e98-a833-5fcba2ee6eee

Previously nations were so slow in taking out bots that you could even
encircle them on the Archiran map...
Now they are like 200% faster (but only on the impossible difficulty)

## Nuke enemy selection

Previously, the enemy for troop attacks and nukes was identical. Now, as
we no longer have a fixed enemy in BotBehaviour, I added
findBestNukeTarget() to select better nuke-targets. I will probably open
a PR soon which makes nations nuke the crown :)

## Betrayal logic

While revamping the attack logic I had to work on the betrayal logic,
which was quite confusing, with many negations. And the betrayals were
just random.
So I made it easier to understand with maybeBetrayAndAttack().
Now it does betray friends if we have 10 times more troops than them. I
will improve that method in a future PR, but already now it should be
better than just betraying randomly.

## Attack order

Previously, nations attacked in this order:

- TerraNullius (Untaken land and nuked territory)
- Bots
- Retaliate against incoming attacks

Now its in this order:

- TerraNullius (Untaken land)
- Retaliate against incoming attacks
- Bots
- TerraNullius (Nuked territory)

So the changes are these:

- After throwing a nuke onto a nation, they will no longer ignore
incoming attacks. Previously they attacked the nuked territory first.
Very common singleplayer problem.
- Nations now retaliate against incoming attacks before attacking bots.
Previously you could attack a nation but they did not care because there
were still bots left.

I also changed the attack order of bots a bit (retaliate before
attacking randoms), but that isn't even noticeable.

## Big bug fixed

Additionally, I fixed a big bug: selectEnemy() oftentimes returned null
(because of enemySanityCheck) and therefore no attack happened.
This was especially visible in games where nations are surrounded by
friends (Team games and nations vs humans).
This was also the reason why Enzo could play nations vs humans in
singleplayer and NO NATION of the much bigger nation team would try to
attack him.

## 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
2025-12-11 13:57:15 -08:00

99 lines
2.5 KiB
TypeScript

import { Execution, Game, Player } from "../game/Game";
import { PseudoRandom } from "../PseudoRandom";
import { simpleHash } from "../Util";
import { BotBehavior } from "./utils/BotBehavior";
export class BotExecution implements Execution {
private active = true;
private random: PseudoRandom;
private mg: Game;
private neighborsTerraNullius = true;
private behavior: BotBehavior | null = null;
private attackRate: number;
private attackTick: number;
private triggerRatio: number;
private reserveRatio: number;
private expandRatio: number;
constructor(private bot: Player) {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(50, 60) / 100;
this.reserveRatio = this.random.nextInt(30, 40) / 100;
this.expandRatio = this.random.nextInt(10, 20) / 100;
}
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game) {
this.mg = mg;
}
tick(ticks: number) {
if (ticks % this.attackRate !== this.attackTick) return;
if (!this.bot.isAlive()) {
this.active = false;
return;
}
if (this.behavior === null) {
this.behavior = new BotBehavior(
this.random,
this.mg,
this.bot,
this.triggerRatio,
this.reserveRatio,
this.expandRatio,
);
// Send an attack on the first tick
this.behavior.sendAttack(this.mg.terraNullius());
return;
}
this.behavior.handleAllianceRequests();
this.behavior.handleAllianceExtensionRequests();
this.maybeAttack();
}
private maybeAttack() {
if (this.behavior === null) {
throw new Error("not initialized");
}
const toAttack = this.behavior.getNeighborTraitorToAttack();
if (toAttack !== null) {
const odds = this.bot.isFriendly(toAttack) ? 6 : 3;
if (this.random.chance(odds)) {
// Check and break alliance before attacking if needed
const alliance = this.bot.allianceWith(toAttack);
if (alliance !== null) {
this.bot.breakAlliance(alliance);
}
this.behavior.sendAttack(toAttack);
return;
}
}
if (this.neighborsTerraNullius) {
if (this.bot.sharesBorderWith(this.mg.terraNullius())) {
this.behavior.sendAttack(this.mg.terraNullius());
return;
}
this.neighborsTerraNullius = false;
}
this.behavior.attackRandomTarget();
}
isActive(): boolean {
return this.active;
}
}