mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:40:44 +00:00
Nations now gang up on players and prioritize traitors/AFK players 🤖 (+ other improvements) (#2730)
## Description: - **Nations now specifically target AFK players, traitors, and players who are already being attacked (they gang up on them).** Depends on the difficulty. - Added ally assistance directly to the attack order (instead of calling it separately beforehand). - Added ally assistance to `findBestNukeTarget`. - Removed some checks from `findBestNukeTarget` (not necessary; better checks will follow in a dedicated nuking PR). - Relation updates on attack now depend on difficulty (makes Easy & Medium nations a bit easier). - On betrayal, every nation in the game previously received a –40 relation penalty toward the betrayer. That was too extreme, so now this only applies only to neighbors. - Nations send fewer alliance requests now; it felt like too many before. - In team games, nations may now reject alliance requests more often (depending on difficulty). - To ensure there are enough non-friendly players to stop the crown with nukes, nations may now reject alliance requests if the other player has too many alliances (on Hard and Impossible difficulty). - Rebalanced nation emoji usage a bit. - Nations may now send an emoji when they get MIRVed. ## 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 --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { renderTroops } from "../../client/Utils";
|
||||
import {
|
||||
Attack,
|
||||
Difficulty,
|
||||
Execution,
|
||||
Game,
|
||||
MessageType,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "../game/Game";
|
||||
import { TileRef } from "../game/GameMap";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { assertNever } from "../Util";
|
||||
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
|
||||
|
||||
const malusForRetreat = 25;
|
||||
@@ -152,7 +154,25 @@ export class AttackExecution implements Execution {
|
||||
}
|
||||
|
||||
if (this.target.isPlayer()) {
|
||||
this.target.updateRelation(this._owner, -80);
|
||||
const difficulty = this.mg.config().gameConfig().difficulty;
|
||||
let relationChange: number;
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
relationChange = -60;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
relationChange = -70;
|
||||
break;
|
||||
case Difficulty.Hard:
|
||||
relationChange = -80;
|
||||
break;
|
||||
case Difficulty.Impossible:
|
||||
relationChange = -100;
|
||||
break;
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
this.target.updateRelation(this._owner, relationChange);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -458,7 +458,6 @@ export class NationExecution implements Execution {
|
||||
this.allianceBehavior.maybeSendAllianceRequests(borderingEnemies);
|
||||
}
|
||||
|
||||
this.attackBehavior.assistAllies();
|
||||
this.attackBehavior.attackBestTarget(borderingFriends, borderingEnemies);
|
||||
this.maybeSendNuke(
|
||||
this.attackBehavior.findBestNukeTarget(borderingEnemies),
|
||||
|
||||
@@ -35,11 +35,16 @@ export class BreakAllianceExecution implements Execution {
|
||||
console.warn("cant break alliance, not allied");
|
||||
} else {
|
||||
this.requestor.breakAlliance(alliance);
|
||||
this.recipient.updateRelation(this.requestor, -200);
|
||||
for (const player of this.mg.players()) {
|
||||
if (player !== this.requestor && !player.isOnSameTeam(this.requestor)) {
|
||||
player.updateRelation(this.requestor, -40);
|
||||
}
|
||||
this.recipient.updateRelation(this.requestor, -100);
|
||||
|
||||
const neighbors = this.requestor
|
||||
.neighbors()
|
||||
.filter(
|
||||
(n): n is Player => n.isPlayer() && !n.isOnSameTeam(this.recipient!),
|
||||
);
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
neighbor.updateRelation(this.requestor, -40);
|
||||
}
|
||||
}
|
||||
this.active = false;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
PlayerType,
|
||||
Relation,
|
||||
@@ -59,7 +60,7 @@ export class NationAllianceBehavior {
|
||||
|
||||
for (const enemy of borderingEnemies) {
|
||||
if (
|
||||
this.random.chance(20) &&
|
||||
this.random.chance(30) &&
|
||||
isAcceptablePlayerType(enemy) &&
|
||||
this.player.canSendAllianceRequest(enemy) &&
|
||||
this.getAllianceDecision(enemy, false)
|
||||
@@ -86,18 +87,27 @@ export class NationAllianceBehavior {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Reject if otherPlayer has allied with 50% or more of all players (Hard and Impossible only)
|
||||
// To make sure there are enough non-friendly players in the game to stop the crown with nukes
|
||||
if (this.hasTooManyAlliances(otherPlayer)) {
|
||||
return false;
|
||||
}
|
||||
// Before caring about the relation, first check if the otherPlayer is a threat
|
||||
// Easy (dumb) nations are blinded by hatred, they don't care about threats, they care about the relation
|
||||
// Impossible (smart) nations on the other hand are analyzing the facts
|
||||
if (this.isAlliancePartnerThreat(otherPlayer)) {
|
||||
if (!isResponse && this.random.chance(3)) {
|
||||
if (!isResponse && this.random.chance(6)) {
|
||||
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_SCARED_OF_THREAT);
|
||||
}
|
||||
if (isResponse && this.random.chance(3)) {
|
||||
if (isResponse && this.random.chance(6)) {
|
||||
this.emojiBehavior.sendEmoji(otherPlayer, EMOJI_LOVE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Maybe reject if we are in a team game (allying makes less sense there)
|
||||
if (this.shouldRejectInTeamGame()) {
|
||||
return false;
|
||||
}
|
||||
// Reject if relation is bad
|
||||
if (this.player.relation(otherPlayer) < Relation.Neutral) {
|
||||
if (isResponse && this.random.chance(3)) {
|
||||
@@ -124,6 +134,21 @@ export class NationAllianceBehavior {
|
||||
return this.isAlliancePartnerSimilarlyStrong(otherPlayer);
|
||||
}
|
||||
|
||||
private hasTooManyAlliances(otherPlayer: Player): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
if (
|
||||
difficulty !== Difficulty.Hard &&
|
||||
difficulty !== Difficulty.Impossible
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalPlayers = this.game.players().length;
|
||||
const otherPlayerAlliances = otherPlayer.alliances().length;
|
||||
|
||||
return otherPlayerAlliances >= totalPlayers * 0.5;
|
||||
}
|
||||
|
||||
private isConfused(): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
@@ -207,6 +232,26 @@ export class NationAllianceBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRejectInTeamGame(): boolean {
|
||||
if (this.game.config().gameConfig().gameMode !== GameMode.Team) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return false; // 0% chance to reject on easy
|
||||
case Difficulty.Medium:
|
||||
return this.random.nextInt(0, 100) < 20; // 20% chance to reject on medium
|
||||
case Difficulty.Hard:
|
||||
return this.random.nextInt(0, 100) < 40; // 40% chance to reject on hard
|
||||
case Difficulty.Impossible:
|
||||
return this.random.nextInt(0, 100) < 60; // 60% chance to reject on impossible
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
private checkAlreadyEnoughAlliances(otherPlayer: Player): boolean {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
|
||||
@@ -15,9 +15,7 @@ import { EmojiExecution } from "../EmojiExecution";
|
||||
|
||||
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
|
||||
flattenedEmojiTable.indexOf(e);
|
||||
export const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(
|
||||
emojiId,
|
||||
);
|
||||
export const EMOJI_ASSIST_ACCEPT = (["👍", "🤝", "🎯"] as const).map(emojiId);
|
||||
export const EMOJI_ASSIST_RELATION_TOO_LOW = (["🥱", "🤦♂️"] as const).map(
|
||||
emojiId,
|
||||
);
|
||||
@@ -34,7 +32,7 @@ export const EMOJI_LOVE = (["❤️", "😊", "🥰"] as const).map(emojiId);
|
||||
export const EMOJI_CONFUSED = (["❓", "🤡"] as const).map(emojiId);
|
||||
export const EMOJI_BRAG = (["👑", "🥇", "💪"] as const).map(emojiId);
|
||||
export const EMOJI_CHARM_ALLIES = (["🤝", "😇", "💪"] as const).map(emojiId);
|
||||
export const EMOJI_CLOWN = (["🤡"] as const).map(emojiId);
|
||||
export const EMOJI_CLOWN = (["🤡", "🤦♂️"] as const).map(emojiId);
|
||||
export const EMOJI_RAT = (["🐀"] as const).map(emojiId);
|
||||
export const EMOJI_OVERWHELMED = (
|
||||
["💀", "🆘", "😱", "🥺", "😭", "😞", "🫡", "👋"] as const
|
||||
@@ -67,7 +65,7 @@ export class NationEmojiBehavior {
|
||||
}
|
||||
|
||||
private checkOverwhelmedByAttacks(): void {
|
||||
if (!this.random.chance(4)) return;
|
||||
if (!this.random.chance(16)) return;
|
||||
|
||||
const incomingAttacks = this.player.incomingAttacks();
|
||||
if (incomingAttacks.length === 0) return;
|
||||
@@ -85,7 +83,7 @@ export class NationEmojiBehavior {
|
||||
}
|
||||
|
||||
private checkVerySmallAttack(): void {
|
||||
if (!this.random.chance(4)) return;
|
||||
if (!this.random.chance(8)) return;
|
||||
|
||||
const incomingAttacks = this.player.incomingAttacks();
|
||||
if (incomingAttacks.length === 0) return;
|
||||
@@ -175,7 +173,7 @@ export class NationEmojiBehavior {
|
||||
|
||||
// Brag with our crown
|
||||
private brag(): void {
|
||||
if (!this.random.chance(100)) return;
|
||||
if (!this.random.chance(300)) return;
|
||||
|
||||
const sorted = this.game
|
||||
.players()
|
||||
@@ -218,6 +216,7 @@ export class NationEmojiBehavior {
|
||||
}
|
||||
|
||||
private findRat(): void {
|
||||
if (this.game.ticks() < 6000) return; // Ignore first 10 minutes (everybody is small in the early game)
|
||||
if (!this.random.chance(10000)) return;
|
||||
|
||||
const totalLand = this.game.numLandTiles();
|
||||
@@ -340,3 +339,19 @@ export function respondToEmoji(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function respondToMIRV(
|
||||
game: Game,
|
||||
random: PseudoRandom,
|
||||
mirvTarget: Player,
|
||||
) {
|
||||
if (!random.chance(8)) return;
|
||||
|
||||
game.addExecution(
|
||||
new EmojiExecution(
|
||||
mirvTarget,
|
||||
AllPlayers,
|
||||
random.randElement(EMOJI_OVERWHELMED),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { MirvExecution } from "../MIRVExecution";
|
||||
import { calculateTerritoryCenter } from "../Util";
|
||||
import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
|
||||
import {
|
||||
EMOJI_NUKE,
|
||||
NationEmojiBehavior,
|
||||
respondToMIRV,
|
||||
} from "./NationEmojiBehavior";
|
||||
|
||||
export class NationMIRVBehavior {
|
||||
constructor(
|
||||
@@ -258,6 +262,7 @@ export class NationMIRVBehavior {
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.game.addExecution(new MirvExecution(this.player, centerTile));
|
||||
this.emojiBehavior.sendEmoji(AllPlayers, EMOJI_NUKE);
|
||||
respondToMIRV(this.game, this.random, enemy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,33 +38,7 @@ export class AiAttackBehavior {
|
||||
private emojiBehavior?: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
assistAllies() {
|
||||
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
||||
|
||||
for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
|
||||
continue;
|
||||
}
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
|
||||
continue;
|
||||
}
|
||||
if (this.player.isFriendly(target)) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
|
||||
continue;
|
||||
}
|
||||
// All checks passed, assist them
|
||||
this.player.updateRelation(ally, -20);
|
||||
this.sendAttack(target);
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attackBestTarget is called with borderingFriends and borderingEnemies sorted by troops (ascending)
|
||||
attackBestTarget(borderingFriends: Player[], borderingEnemies: Player[]) {
|
||||
// Save up troops until we reach the reserve ratio
|
||||
if (!this.hasReserveRatioTroops()) return;
|
||||
@@ -101,6 +75,29 @@ export class AiAttackBehavior {
|
||||
|
||||
const bots = (): boolean => this.attackBots();
|
||||
|
||||
const assist = (): boolean => this.assistAllies();
|
||||
|
||||
const traitor = (): boolean => {
|
||||
const weakestTraitor = this.findWeakestTraitor(borderingEnemies);
|
||||
if (weakestTraitor) {
|
||||
this.sendAttack(weakestTraitor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const afk = (): boolean => {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
const weakestAfk = borderingEnemies.find((enemy) =>
|
||||
enemy.isDisconnected(),
|
||||
);
|
||||
if (weakestAfk) {
|
||||
this.sendAttack(weakestAfk);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const betray = (): boolean => this.maybeBetrayAndAttack(borderingFriends);
|
||||
|
||||
const nuked = (): boolean => {
|
||||
@@ -111,6 +108,15 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
};
|
||||
|
||||
const victim = (): boolean => {
|
||||
const weakestVictim = this.findWeakestVictim(borderingEnemies);
|
||||
if (weakestVictim) {
|
||||
this.sendAttack(weakestVictim);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hated = (): boolean => {
|
||||
const mostHated = this.player.allRelationsSorted()[0];
|
||||
if (
|
||||
@@ -126,6 +132,7 @@ export class AiAttackBehavior {
|
||||
|
||||
const weakest = (): boolean => {
|
||||
if (borderingEnemies.length > 0) {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
this.sendAttack(borderingEnemies[0]);
|
||||
return true;
|
||||
}
|
||||
@@ -147,19 +154,53 @@ export class AiAttackBehavior {
|
||||
// Easy nations get the dumbest order, impossible nations get the smartest order
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return [nuked, bots, retaliate, betray, hated, weakest];
|
||||
return [nuked, bots, retaliate, assist, betray, hated, weakest];
|
||||
case Difficulty.Medium:
|
||||
return [bots, nuked, retaliate, betray, hated, weakest, island];
|
||||
return [
|
||||
bots,
|
||||
nuked,
|
||||
retaliate,
|
||||
assist,
|
||||
betray,
|
||||
hated,
|
||||
afk,
|
||||
traitor,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
case Difficulty.Hard:
|
||||
return [bots, retaliate, betray, nuked, hated, weakest, island];
|
||||
return [
|
||||
bots,
|
||||
retaliate,
|
||||
assist,
|
||||
betray,
|
||||
nuked,
|
||||
traitor,
|
||||
afk,
|
||||
hated,
|
||||
victim,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
case Difficulty.Impossible:
|
||||
return [retaliate, bots, betray, nuked, hated, weakest, island];
|
||||
return [
|
||||
retaliate,
|
||||
bots,
|
||||
assist,
|
||||
traitor,
|
||||
afk,
|
||||
betray,
|
||||
nuked,
|
||||
victim,
|
||||
hated,
|
||||
weakest,
|
||||
island,
|
||||
];
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Nuke the crown if it's far enough ahead of everybody else (based on difficulty)
|
||||
findBestNukeTarget(borderingEnemies: Player[]): Player | null {
|
||||
// Retaliate against incoming attacks (Most important!)
|
||||
const incomingAttackPlayer = this.findIncomingAttackPlayer();
|
||||
@@ -167,6 +208,19 @@ export class AiAttackBehavior {
|
||||
return incomingAttackPlayer;
|
||||
}
|
||||
|
||||
// Assist allies, check their targets (this is basically the same as in assistAllies, but without sending emojis)
|
||||
for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) continue;
|
||||
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) continue;
|
||||
if (this.player.isFriendly(target)) continue;
|
||||
// Found a valid ally target to nuke
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the most hated player with hostile relation
|
||||
const mostHated = this.player.allRelationsSorted()[0];
|
||||
if (
|
||||
@@ -177,19 +231,6 @@ export class AiAttackBehavior {
|
||||
return mostHated.player;
|
||||
}
|
||||
|
||||
// Find the weakest player
|
||||
if (borderingEnemies.length > 0) {
|
||||
return borderingEnemies[0];
|
||||
}
|
||||
|
||||
// If we don't have bordering enemies, find someone on an island next to us
|
||||
if (borderingEnemies.length === 0) {
|
||||
const nearestIslandEnemy = this.findNearestIslandEnemy();
|
||||
if (nearestIslandEnemy) {
|
||||
return nearestIslandEnemy;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -275,6 +316,45 @@ export class AiAttackBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
private assistAllies(): boolean {
|
||||
if (this.emojiBehavior === undefined) throw new Error("not initialized");
|
||||
|
||||
for (const ally of this.player.allies()) {
|
||||
if (ally.targets().length === 0) continue;
|
||||
if (this.player.relation(ally) < Relation.Friendly) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_RELATION_TOO_LOW);
|
||||
continue;
|
||||
}
|
||||
for (const target of ally.targets()) {
|
||||
if (target === this.player) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ME);
|
||||
continue;
|
||||
}
|
||||
if (this.player.isFriendly(target)) {
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_TARGET_ALLY);
|
||||
continue;
|
||||
}
|
||||
// All checks passed, assist them
|
||||
this.player.updateRelation(ally, -20);
|
||||
this.sendAttack(target);
|
||||
this.emojiBehavior.sendEmoji(ally, EMOJI_ASSIST_ACCEPT);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find a traitor who isn't much stronger than us (max 20% more troops)
|
||||
private findWeakestTraitor(borderingEnemies: Player[]): Player | null {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
return (
|
||||
borderingEnemies.find(
|
||||
(enemy) =>
|
||||
enemy.isTraitor() && enemy.troops() * 1.2 < this.player.troops(),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private maybeBetrayAndAttack(borderingFriends: Player[]): boolean {
|
||||
if (this.allianceBehavior === undefined) throw new Error("not initialized");
|
||||
|
||||
@@ -304,6 +384,22 @@ export class AiAttackBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find someone who is weaker than us and is under big attack from others (50%+ of their troops incoming)
|
||||
private findWeakestVictim(borderingEnemies: Player[]): Player | null {
|
||||
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
|
||||
return (
|
||||
borderingEnemies.find((enemy) => {
|
||||
if (enemy.troops() >= this.player.troops()) return false;
|
||||
|
||||
const totalIncomingTroops = enemy
|
||||
.incomingAttacks()
|
||||
.reduce((sum, attack) => sum + attack.troops(), 0);
|
||||
|
||||
return totalIncomingTroops > enemy.troops() * 0.5;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private findNearestIslandEnemy(): Player | null {
|
||||
const myBorder = this.player.borderTiles();
|
||||
if (myBorder.size === 0) return null;
|
||||
|
||||
Reference in New Issue
Block a user