mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 20:16:44 +00:00
b03f9778db
Betrayal was being considered too early (inside shouldAttack), causing alliances to break before calling attackChance. - [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 regression is found: abodcraft1
347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
import {
|
|
AllianceRequest,
|
|
Difficulty,
|
|
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";
|
|
|
|
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
|
|
flattenedEmojiTable.indexOf(e);
|
|
const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId);
|
|
const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦♂️"] as const).map(emojiId);
|
|
const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
|
|
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
|
|
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
|
|
|
|
export class BotBehavior {
|
|
private enemy: Player | null = null;
|
|
private enemyUpdated: Tick | undefined;
|
|
|
|
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, force = false) {
|
|
if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return;
|
|
this.enemy = newEnemy;
|
|
this.enemyUpdated = this.game.ticks();
|
|
}
|
|
|
|
private shouldAttack(other: Player): boolean {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
if (this.player.isOnSameTeam(other)) {
|
|
return false;
|
|
}
|
|
const shouldAttack = this.attackChance(other);
|
|
if (shouldAttack && this.player.isAlliedWith(other)) {
|
|
this.betray(other);
|
|
return true;
|
|
}
|
|
return shouldAttack;
|
|
}
|
|
|
|
private betray(target: Player): void {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
const alliance = this.player.allianceWith(target);
|
|
if (!alliance) return;
|
|
this.player.breakAlliance(alliance);
|
|
}
|
|
|
|
private attackChance(other: Player): boolean {
|
|
if (this.player === null) throw new Error("not initialized");
|
|
|
|
if (this.player.isAlliedWith(other)) {
|
|
return this.shouldDiscourageAttack(other)
|
|
? this.random.chance(200)
|
|
: this.random.chance(50);
|
|
} else {
|
|
return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true;
|
|
}
|
|
}
|
|
|
|
private shouldDiscourageAttack(other: Player) {
|
|
if (other.isTraitor()) {
|
|
return false;
|
|
}
|
|
const { difficulty } = this.game.config().gameConfig();
|
|
if (
|
|
difficulty === Difficulty.Hard ||
|
|
difficulty === Difficulty.Impossible
|
|
) {
|
|
return false;
|
|
}
|
|
if (other.type() !== PlayerType.Human) {
|
|
return false;
|
|
}
|
|
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
|
|
return true;
|
|
}
|
|
|
|
private clearEnemy() {
|
|
this.enemy = null;
|
|
}
|
|
|
|
forgetOldEnemies() {
|
|
// Forget old enemies
|
|
if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) {
|
|
this.clearEnemy();
|
|
}
|
|
}
|
|
|
|
private hasReserveRatioTroops(): boolean {
|
|
const maxTroops = this.game.config().maxTroops(this.player);
|
|
const ratio = this.player.troops() / maxTroops;
|
|
return ratio >= this.reserveRatio;
|
|
}
|
|
|
|
private hasTriggerRatioTroops(): 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, true);
|
|
}
|
|
}
|
|
|
|
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() {
|
|
for (const ally of this.player.allies()) {
|
|
if (ally.targets().length === 0) continue;
|
|
if (this.player.relation(ally) < Relation.Friendly) {
|
|
this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW));
|
|
continue;
|
|
}
|
|
for (const target of ally.targets()) {
|
|
if (target === this.player) {
|
|
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME));
|
|
continue;
|
|
}
|
|
if (this.player.isAlliedWith(target)) {
|
|
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY));
|
|
continue;
|
|
}
|
|
// All checks passed, assist them
|
|
this.player.updateRelation(ally, -20);
|
|
this.setNewEnemy(target);
|
|
this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
selectEnemy(enemies: Player[]): Player | null {
|
|
if (this.enemy === null) {
|
|
// Save up troops until we reach the reserve ratio
|
|
if (!this.hasReserveRatioTroops()) return null;
|
|
|
|
// Maybe save up troops until we reach the trigger ratio
|
|
if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) 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) {
|
|
// Only after clearing bots
|
|
this.checkIncomingAttacks();
|
|
}
|
|
|
|
// Select the most hated player
|
|
if (this.enemy === null && this.random.chance(2)) {
|
|
// 50% chance
|
|
const mostHated = this.player.allRelationsSorted()[0];
|
|
if (
|
|
mostHated !== undefined &&
|
|
mostHated.relation === Relation.Hostile
|
|
) {
|
|
this.setNewEnemy(mostHated.player);
|
|
}
|
|
}
|
|
|
|
// Select the weakest player
|
|
if (this.enemy === null && enemies.length > 0) {
|
|
this.setNewEnemy(enemies[0]);
|
|
}
|
|
|
|
// Select a random player
|
|
if (this.enemy === null && enemies.length > 0) {
|
|
this.setNewEnemy(this.random.randElement(enemies));
|
|
}
|
|
}
|
|
|
|
// 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.hasTriggerRatioTroops()) 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
|
|
}
|