mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-20 09:19:55 +00:00
Make easy and medium nations less aggressive 📊 (#2671)
## Description: 1. Players complained that they have problems allying with nations in the earlygame. So I added an `isEarlygame()` check to `AllianceBehavior`. This should make the easier difficulties much easier :) 2. The attack order of nations now depends on the difficulty. Easy and medium nations got dumbed down, they now take nuked territory before retaliating against attacks again. 3. The attack rate now depends on the difficulty. Easy nations are reacting slower than impossible nations (to make sure the number of sent alliance requests stays the same I removed the difficulty check in `maybeSendAllianceRequests()`). 4. On easy and medium difficulty nations will sometimes just skip an attack if the enemy is a human (`shouldAttack()`). But this did not apply for the nuking logic. Now it does, which makes the easier difficulties a bit easier. 5. I tuned the `getBotAttackMaxParallelism()` method a bit. The nations are doing a bit less parallel bot attacks now, which makes the easier difficulties a bit easier. 6. The settings in MIRVBehavior now depend on the difficulty. On easy difficulty, nations will only send MIRVs very rarely. 7. Unrelated MIRVBehavior Cleanup: There was a 2 second cooldown and cache logic. But it was completely useless because `considerMIRV()` is only called every 4-8 seconds by NationExecution. So I removed it. 8. Unrelated little cleanup: I made a couple of methods `private` ## 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:
@@ -1,38 +1,19 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
Gold,
|
||||
Player,
|
||||
PlayerType,
|
||||
Tick,
|
||||
UnitType,
|
||||
} from "../../game/Game";
|
||||
import { TileRef } from "../../game/GameMap";
|
||||
import { PseudoRandom } from "../../PseudoRandom";
|
||||
import { assertNever } from "../../Util";
|
||||
import { MirvExecution } from "../MIRVExecution";
|
||||
import { calculateTerritoryCenter } from "../Util";
|
||||
import { NationEmojiBehavior } from "./NationEmojiBehavior";
|
||||
|
||||
export class NationMIRVBehavior {
|
||||
private readonly lastMIRVSent: [Tick, TileRef][] = [];
|
||||
|
||||
/** Ticks until MIRV can be attempted again */
|
||||
private static readonly MIRV_COOLDOWN_TICKS = 20;
|
||||
|
||||
/** Odds of aborting a MIRV attempt */
|
||||
private static readonly MIRV_HESITATION_ODDS = 7;
|
||||
|
||||
/** Threshold for team victory denial */
|
||||
private static readonly VICTORY_DENIAL_TEAM_THRESHOLD = 0.8;
|
||||
|
||||
/** Threshold for individual victory denial */
|
||||
private static readonly VICTORY_DENIAL_INDIVIDUAL_THRESHOLD = 0.65;
|
||||
|
||||
/** Multiplier for steamroll city gap threshold */
|
||||
private static readonly STEAMROLL_CITY_GAP_MULTIPLIER = 1.3;
|
||||
|
||||
/** Minimum city count for leader to trigger steam roll detection */
|
||||
private static readonly STEAMROLL_MIN_LEADER_CITIES = 10;
|
||||
|
||||
constructor(
|
||||
private random: PseudoRandom,
|
||||
private game: Game,
|
||||
@@ -40,6 +21,85 @@ export class NationMIRVBehavior {
|
||||
private emojiBehavior: NationEmojiBehavior,
|
||||
) {}
|
||||
|
||||
private get hesitationOdds(): number {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 2; // More likely to hesitate
|
||||
case Difficulty.Medium:
|
||||
return 4;
|
||||
case Difficulty.Hard:
|
||||
return 8;
|
||||
case Difficulty.Impossible:
|
||||
return 16; // Rarely hesitates
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
private get victoryDenialTeamThreshold(): number {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 0.9; // Only react right before the game ends (95%)
|
||||
case Difficulty.Medium:
|
||||
return 0.8;
|
||||
case Difficulty.Hard:
|
||||
return 0.7;
|
||||
case Difficulty.Impossible:
|
||||
return 0.6; // Reacts early
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
private get victoryDenialIndividualThreshold(): number {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 0.75; // Only react right before the game ends (80%)
|
||||
case Difficulty.Medium:
|
||||
return 0.65;
|
||||
case Difficulty.Hard:
|
||||
return 0.55;
|
||||
case Difficulty.Impossible:
|
||||
return 0.4; // Reacts early
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
private get steamrollCityGapMultiplier(): number {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 1.5; // Needs larger gap to trigger
|
||||
case Difficulty.Medium:
|
||||
return 1.3;
|
||||
case Difficulty.Hard:
|
||||
return 1.2;
|
||||
case Difficulty.Impossible:
|
||||
return 1.15; // Reacts to smaller gaps
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
private get steamrollMinLeaderCities(): number {
|
||||
const { difficulty } = this.game.config().gameConfig();
|
||||
switch (difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 15; // Needs more cities to trigger
|
||||
case Difficulty.Medium:
|
||||
case Difficulty.Hard:
|
||||
return 10;
|
||||
case Difficulty.Impossible:
|
||||
return 8; // Reacts early
|
||||
default:
|
||||
assertNever(difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
considerMIRV(): boolean {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
if (this.player.units(UnitType.MissileSilo).length === 0) {
|
||||
@@ -49,13 +109,7 @@ export class NationMIRVBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.removeOldMIRVEvents();
|
||||
if (this.lastMIRVSent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.random.chance(NationMIRVBehavior.MIRV_HESITATION_ODDS)) {
|
||||
this.triggerMIRVCooldown();
|
||||
if (this.random.chance(this.hesitationOdds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -107,7 +161,7 @@ export class NationMIRVBehavior {
|
||||
.map((x) => x.numTilesOwned())
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const teamShare = teamTerritory / totalLand;
|
||||
if (teamShare >= NationMIRVBehavior.VICTORY_DENIAL_TEAM_THRESHOLD) {
|
||||
if (teamShare >= this.victoryDenialTeamThreshold) {
|
||||
// Only consider the largest team member as the target when team exceeds threshold
|
||||
let largestMember: Player | null = null;
|
||||
let largestTiles = -1;
|
||||
@@ -126,8 +180,7 @@ export class NationMIRVBehavior {
|
||||
}
|
||||
} else {
|
||||
const share = p.numTilesOwned() / totalLand;
|
||||
if (share >= NationMIRVBehavior.VICTORY_DENIAL_INDIVIDUAL_THRESHOLD)
|
||||
severity = share;
|
||||
if (share >= this.victoryDenialIndividualThreshold) severity = share;
|
||||
}
|
||||
if (severity > 0) {
|
||||
if (best === null || severity > best.severity) best = { p, severity };
|
||||
@@ -152,13 +205,11 @@ export class NationMIRVBehavior {
|
||||
|
||||
const topPlayer = allPlayers[0];
|
||||
|
||||
if (topPlayer.cityCount <= NationMIRVBehavior.STEAMROLL_MIN_LEADER_CITIES)
|
||||
return null;
|
||||
if (topPlayer.cityCount <= this.steamrollMinLeaderCities) return null;
|
||||
|
||||
const secondHighest = allPlayers[1].cityCount;
|
||||
|
||||
const threshold =
|
||||
secondHighest * NationMIRVBehavior.STEAMROLL_CITY_GAP_MULTIPLIER;
|
||||
const threshold = secondHighest * this.steamrollCityGapMultiplier;
|
||||
|
||||
if (topPlayer.cityCount >= threshold) {
|
||||
return validTargets.some((p) => p === topPlayer.p) ? topPlayer.p : null;
|
||||
@@ -168,23 +219,10 @@ export class NationMIRVBehavior {
|
||||
}
|
||||
|
||||
// MIRV Helper Methods
|
||||
private mirvTargetsCache: {
|
||||
tick: number;
|
||||
players: Player[];
|
||||
} | null = null;
|
||||
|
||||
private getValidMirvTargetPlayers(): Player[] {
|
||||
const MIRV_TARGETS_CACHE_TICKS = 2 * 10; // 2 seconds
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
|
||||
if (
|
||||
this.mirvTargetsCache &&
|
||||
this.game.ticks() - this.mirvTargetsCache.tick < MIRV_TARGETS_CACHE_TICKS
|
||||
) {
|
||||
return this.mirvTargetsCache.players;
|
||||
}
|
||||
|
||||
const players = this.game.players().filter((p) => {
|
||||
return this.game.players().filter((p) => {
|
||||
return (
|
||||
p !== this.player &&
|
||||
p.isPlayer() &&
|
||||
@@ -192,9 +230,6 @@ export class NationMIRVBehavior {
|
||||
!this.player!.isOnSameTeam(p)
|
||||
);
|
||||
});
|
||||
|
||||
this.mirvTargetsCache = { tick: this.game.ticks(), players };
|
||||
return players;
|
||||
}
|
||||
|
||||
private isInboundMIRVFrom(attacker: Player): boolean {
|
||||
@@ -220,35 +255,7 @@ export class NationMIRVBehavior {
|
||||
|
||||
const centerTile = this.calculateTerritoryCenter(enemy);
|
||||
if (centerTile && this.player.canBuild(UnitType.MIRV, centerTile)) {
|
||||
this.sendMIRV(centerTile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMIRV(tile: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.triggerMIRVCooldown(tile);
|
||||
this.game.addExecution(new MirvExecution(this.player, tile));
|
||||
}
|
||||
|
||||
private triggerMIRVCooldown(tile?: TileRef): void {
|
||||
if (this.player === null) throw new Error("not initialized");
|
||||
this.removeOldMIRVEvents();
|
||||
const tick = this.game.ticks();
|
||||
// Use provided tile or any tile from player's territory for cooldown tracking
|
||||
const cooldownTile =
|
||||
tile ?? Array.from(this.player.tiles())[0] ?? this.game.ref(0, 0);
|
||||
this.lastMIRVSent.push([tick, cooldownTile]);
|
||||
}
|
||||
|
||||
private removeOldMIRVEvents() {
|
||||
const maxAge = NationMIRVBehavior.MIRV_COOLDOWN_TICKS;
|
||||
const tick = this.game.ticks();
|
||||
while (
|
||||
this.lastMIRVSent.length > 0 &&
|
||||
this.lastMIRVSent[0][0] + maxAge <= tick
|
||||
) {
|
||||
this.lastMIRVSent.shift();
|
||||
this.game.addExecution(new MirvExecution(this.player, centerTile));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user