Files
OpenFrontIO/src/core/execution/utils/BotBehavior.ts
T
Abdallah Bahrawi 0a6ab07d2e fix: traitor bug when attacking immediately after initiating an alliance (#2044)
## Description:

This PR fixes a critical race condition bug where players could
unintentionally receive the traitor debuff when alliance requests were
accepted mid-attack.


Critical Bug Fixes #1866

**Root Cause:** 
Players could bypass UI alliance checks ( isFriendly() ) by accepting
alliances and immediately attacking after that, causing the server to
treat the attack as betrayal
Solution: Added server-side alliance validation in
AttackExecution.init()
This ensures attacks on allies are blocked at the server level.

- Once Bots and Nations decide to attack, they breaks the alliance. I
added maybeConsiderBetrayal(), which currently always returns true. I’ll
add proper logic for alliance-breaking soon on another PR; this didn’t
exist in the code before.

## 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:

abodcraft1

---------

Co-authored-by: evanpelle <evanpelle@gmail.com>
2025-09-13 09:21:21 -07:00

268 lines
7.9 KiB
TypeScript

import {
AllianceRequest,
Game,
Player,
PlayerType,
Relation,
TerraNullius,
Tick,
} from "../../game/Game";
import { PseudoRandom } from "../../PseudoRandom";
import { flattenedEmojiTable } from "../../Util";
import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecution";
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
constructor(
private random: PseudoRandom,
private game: Game,
private player: Player,
private triggerRatio: number,
private reserveRatio: number,
private expandRatio: number,
) {}
handleAllianceRequests() {
for (const req of this.player.incomingAllianceRequests()) {
if (shouldAcceptAllianceRequest(this.player, req)) {
req.accept();
} else {
req.reject();
}
}
}
handleAllianceExtensionRequests() {
for (const alliance of this.player.alliances()) {
// Alliance expiration tracked by Events Panel, only human ally can click Request to Renew
// Skip if no expiration yet/ ally didn't request extension yet/ bot already agreed to extend
if (!alliance.onlyOneAgreedToExtend()) continue;
// Nation is either Friendly or Neutral as an ally. Bot has no attitude
// If Friendly or Bot, always agree to extend. If Neutral, have random chance decide
const human = alliance.other(this.player);
if (
this.player.type() === PlayerType.FakeHuman &&
this.player.relation(human) === Relation.Neutral
) {
if (!this.random.chance(1.5)) continue;
}
this.game.addExecution(
new AllianceExtensionExecution(this.player, human.id()),
);
}
}
private emoji(player: Player, emoji: number) {
if (player.type() !== PlayerType.Human) return;
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
private setNewEnemy(newEnemy: Player | null) {
this.enemy = newEnemy;
this.enemyUpdated = this.game.ticks();
}
private clearEnemy() {
this.enemy = null;
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
this.clearEnemy();
}
}
private hasSufficientTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
return ratio >= this.triggerRatio;
}
private checkIncomingAttacks() {
// Switch enemies if we're under attack
const incomingAttacks = this.player.incomingAttacks();
let largestAttack = 0;
let largestAttacker: Player | undefined;
for (const attack of incomingAttacks) {
if (attack.troops() <= largestAttack) continue;
largestAttack = attack.troops();
largestAttacker = attack.attacker();
}
if (largestAttacker !== undefined) {
this.setNewEnemy(largestAttacker);
}
}
getNeighborTraitorToAttack(): Player | null {
const traitors = this.player
.neighbors()
.filter((n): n is Player => n.isPlayer() && n.isTraitor());
return traitors.length > 0 ? this.random.randElement(traitors) : null;
}
assistAllies() {
outer: for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
// this.emoji(ally, "🤦");
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
// this.emoji(ally, "💀");
continue;
}
if (this.player.isAlliedWith(target)) {
// this.emoji(ally, "👎");
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.setNewEnemy(target);
this.emoji(ally, this.assistAcceptEmoji);
break outer;
}
}
}
selectEnemy(): Player | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
if (!this.hasSufficientTroops()) return null;
// Prefer neighboring bots
const bots = this.player
.neighbors()
.filter(
(n): n is Player => n.isPlayer() && n.type() === PlayerType.Bot,
);
if (bots.length > 0) {
const density = (p: Player) => p.troops() / p.numTilesOwned();
let lowestDensityBot: Player | undefined;
let lowestDensity = Infinity;
for (const bot of bots) {
const currentDensity = density(bot);
if (currentDensity < lowestDensity) {
lowestDensity = currentDensity;
lowestDensityBot = bot;
}
}
if (lowestDensityBot !== undefined) {
this.setNewEnemy(lowestDensityBot);
}
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Select the most hated player
if (this.enemy === null) {
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
mostHated.relation === Relation.Hostile
) {
this.setNewEnemy(mostHated.player);
}
}
}
// Sanity check, don't attack our allies or teammates
return this.enemySanityCheck();
}
selectRandomEnemy(): Player | TerraNullius | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
if (!this.hasSufficientTroops()) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
for (const neighbor of this.random.shuffleArray(neighbors)) {
if (!neighbor.isPlayer()) continue;
if (this.player.isFriendly(neighbor)) continue;
if (neighbor.type() === PlayerType.FakeHuman) {
if (this.random.chance(2)) {
continue;
}
}
this.setNewEnemy(neighbor);
}
// Retaliate against incoming attacks
if (this.enemy === null) {
this.checkIncomingAttacks();
}
// Select a traitor as an enemy
if (this.enemy === null) {
const toAttack = this.getNeighborTraitorToAttack();
if (toAttack !== null) {
if (!this.player.isFriendly(toAttack) && this.random.chance(3)) {
this.setNewEnemy(toAttack);
}
}
}
}
// Sanity check, don't attack our allies or teammates
return this.enemySanityCheck();
}
private enemySanityCheck(): Player | null {
if (this.enemy && this.player.isFriendly(this.enemy)) {
this.clearEnemy();
}
return this.enemy;
}
sendAttack(target: Player | TerraNullius) {
// Skip attacking friendly targets (allies or teammates) - decision to break alliances should be made by caller
if (target.isPlayer() && this.player.isFriendly(target)) return;
const maxTroops = this.game.config().maxTroops(this.player);
const reserveRatio = target.isPlayer()
? this.reserveRatio
: this.expandRatio;
const targetTroops = maxTroops * reserveRatio;
const troops = this.player.troops() - targetTroops;
if (troops < 1) return;
this.game.addExecution(
new AttackExecution(
troops,
this.player,
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
}
}
function shouldAcceptAllianceRequest(player: Player, request: AllianceRequest) {
if (player.relation(request.requestor()) < Relation.Neutral) {
return false; // Reject if hasMalice
}
if (request.requestor().isTraitor()) {
return false; // Reject if isTraitor
}
if (request.requestor().numTilesOwned() > player.numTilesOwned() * 3) {
return true; // Accept if requestorIsMuchLarger
}
if (request.requestor().alliances().length >= 3) {
return false; // Reject if tooManyAlliances
}
return true; // Accept otherwise
}