Massive nation improvement 🤖 (#3761)

## Description:

- Hard / Impossible nations in team games auto-stop trading with all
enemies
- If there are a LOT of nations on the map (Enzo stream with 400 nation
HvN private games) they no longer start with a city, they start with eco
(port / factory) because they cannot gain much gold from bot-killing
- Impossible nations built way too many missile silos sometimes, caused
by the SAM overwhelming logic. Fixed now.
- In public HvN games with 5M starting gold, nations placed their
structures way too fast, which slowed down their expansion. And humans
could easily cause a lot of damage with one atom bomb. Now their first
structure is a SAM (on hard / impossible) and they wait between their
earlygame structure placements.
- Nations now spread out their port placements more evenly
- Nations are now able to attack much stronger enemies in team games
(They can expect donations)
- Improve performance a bit by adding more early-returns (Dont run any
nuking logic if nukes are disabled, no alliance logic if alliances are
disabled, no boating logic if transport boats are disabled, ...)
- Fix some of the "cannot send troops" messages in the console
(DonateTroopExecution)
- Nations build their first missile silo sooner, they should also build
more SAMs
- Nations spend their gold better after reaching the save-up-target
(previously they stopped nuking)
- Optimized save-up-targets for team games
- The richest impossible nation is nuking very dense players now (lot of
structure levels on a small island)

### How does a 5M gold HvN start look like now?


https://github.com/user-attachments/assets/e9da89c3-c0d4-4144-a741-3101746b16da

## 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:
FloPinguin
2026-04-27 02:43:45 +02:00
committed by GitHub
parent c0febacb8e
commit 8099b9fad7
9 changed files with 385 additions and 45 deletions
@@ -45,6 +45,10 @@ export class DonateTroopsExecution implements Execution {
const maxDonation =
mg.config().maxTroops(this.recipient) - this.recipient.troops();
this.troops = Math.min(this.troops, maxDonation);
if (this.troops <= 0) {
this.active = false;
}
}
tick(ticks: number): void {
+26 -4
View File
@@ -2,11 +2,14 @@ import {
Difficulty,
Execution,
Game,
GameMode,
Nation,
Player,
PlayerID,
PlayerType,
Relation,
TerrainType,
UnitType,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
@@ -89,7 +92,8 @@ export class NationExecution implements Execution {
this.behaviorsInitialized &&
this.player !== null &&
this.player.isAlive() &&
this.mg.config().gameConfig().difficulty !== Difficulty.Easy
this.mg.config().gameConfig().difficulty !== Difficulty.Easy &&
!this.mg.config().isUnitDisabled(UnitType.Warship)
) {
this.warshipBehavior.trackShipsAndRetaliate();
}
@@ -293,8 +297,26 @@ export class NationExecution implements Execution {
const player = this.player;
if (player === null) return;
const others = this.mg.players().filter((p) => p.id() !== player.id());
const difficulty = this.mg.config().gameConfig().difficulty;
const isHigherDifficulty =
difficulty === Difficulty.Hard || difficulty === Difficulty.Impossible;
const teamGame = this.mg.config().gameConfig().gameMode === GameMode.Team;
others.forEach((other: Player) => {
// In team games on higher difficulties, refuse to trade with anyone
// not on this nation's team (mirrors the "stop trading with all" button).
if (
teamGame &&
isHigherDifficulty &&
other.type() !== PlayerType.Bot &&
!player.isOnSameTeam(other)
) {
if (!player.hasEmbargoAgainst(other)) {
player.addEmbargo(other, false);
}
return;
}
/* When player is hostile starts embargo. Do not stop until neutral again */
if (
player.relation(other) <= Relation.Hostile &&
@@ -305,14 +327,14 @@ export class NationExecution implements Execution {
} else if (
player.relation(other) >= Relation.Neutral &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Hard &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
difficulty !== Difficulty.Hard &&
difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
} else if (
player.relation(other) >= Relation.Friendly &&
player.hasEmbargoAgainst(other) &&
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
difficulty !== Difficulty.Impossible
) {
player.stopEmbargo(other);
}
@@ -27,6 +27,8 @@ export class NationAllianceBehavior {
) {}
handleAllianceRequests() {
if (this.game.config().disableAlliances()) return;
for (const req of this.player.incomingAllianceRequests()) {
// Alliance Request intents created during the spawn phase are executed on
// the first tick post-spawn phase. With the following condition we reject
@@ -44,6 +46,8 @@ export class NationAllianceBehavior {
}
handleAllianceExtensionRequests() {
if (this.game.config().disableAlliances()) return;
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 / nation already agreed to extend
@@ -59,6 +63,8 @@ export class NationAllianceBehavior {
}
maybeSendAllianceRequests(borderingEnemies: Player[]) {
if (this.game.config().disableAlliances()) return;
// Only easy nations are allowed to send alliance requests to bots
const isAcceptablePlayerType = (p: Player) =>
(p.type() === PlayerType.Bot &&
@@ -117,6 +117,9 @@ export class NationMIRVBehavior {
considerMIRV(): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.MIRV)) {
return false;
}
if (this.player.units(UnitType.MissileSilo).length === 0) {
return false;
}
+127 -20
View File
@@ -6,6 +6,7 @@ import {
Player,
PlayerType,
Relation,
Structures,
Tick,
Unit,
UnitType,
@@ -21,6 +22,18 @@ import { AiAttackBehavior } from "../utils/AiAttackBehavior";
import { EMOJI_NUKE, NationEmojiBehavior } from "./NationEmojiBehavior";
import { randTerritoryTileArray } from "./NationUtils";
/** Cap on silo levels reachable via maybeDestroyEnemySam's upgrade fallback. */
const MAX_NATION_SILO_UPGRADE_LEVEL = 5;
/**
* Level-weighted structure density (sum of structure levels per tile owned)
* above which the richest impossible nation will pre-emptively nuke a player.
*/
const HIGH_DENSITY_NUKE_THRESHOLD = 1 / 75;
/** Minimum sum of structure levels a player needs to qualify as a high-density nuke target. */
const MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE = 5;
export class NationNukeBehavior {
private readonly recentlySentNukes: [
Tick,
@@ -43,14 +56,23 @@ export class NationNukeBehavior {
) {}
maybeSendNuke() {
const silos = this.player.units(UnitType.MissileSilo);
const config = this.game.config();
if (
silos.length === 0 ||
config.isUnitDisabled(UnitType.MissileSilo) ||
(config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb))
) {
return;
}
const nukeTarget = this.findBestNukeTarget();
if (nukeTarget === null) {
return;
}
const silos = this.player.units(UnitType.MissileSilo);
if (
silos.length === 0 ||
nukeTarget.type() === PlayerType.Bot || // Don't nuke tribes (as opposed to nations and humans)
this.player.isOnSameTeam(nukeTarget) ||
this.attackBehavior.shouldAttack(nukeTarget) === false
@@ -77,14 +99,7 @@ export class NationNukeBehavior {
}
const range = this.game.config().nukeMagnitudes(nukeType).outer;
const structures = nukeTarget.units(
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
UnitType.SAMLauncher,
UnitType.Factory,
);
const structures = nukeTarget.units(...Structures.types);
const structureTiles = structures.map((u) => u.tile());
const difficulty = this.game.config().gameConfig().difficulty;
// Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
@@ -167,6 +182,20 @@ export class NationNukeBehavior {
return incomingAttackPlayer;
}
// On Impossible, the richest nation hunts very high structure density targets
// Restricting to the richest nation prevents every impossible nation
// from piling onto the same compact player.
if (
diff === Difficulty.Impossible &&
this.isRichestNation() &&
this.random.chance(2)
) {
const denseTarget = this.findHighDensityTarget();
if (denseTarget !== null) {
return denseTarget;
}
}
// On impossible difficulty, prioritize nuking the crown if they have more than 50% of the map
const { difficulty, gameMode } = this.game.config().gameConfig();
if (difficulty === Difficulty.Impossible && gameMode === GameMode.FFA) {
@@ -230,6 +259,39 @@ export class NationNukeBehavior {
return null;
}
private isRichestNation(): boolean {
const myGold = this.player.gold();
for (const other of this.game.players()) {
if (other === this.player) continue;
if (other.type() !== PlayerType.Nation) continue;
if (other.gold() > myGold) return false;
}
return true;
}
private findHighDensityTarget(): Player | null {
let bestTarget: Player | null = null;
let bestDensity = HIGH_DENSITY_NUKE_THRESHOLD;
for (const other of this.game.players()) {
if (other === this.player) continue;
if (other.type() === PlayerType.Bot) continue;
if (this.player.isFriendly(other)) continue;
const tilesOwned = other.numTilesOwned();
if (tilesOwned === 0) continue;
const structures = other.units(...Structures.types);
let levelSum = 0;
for (const s of structures) levelSum += s.level();
// Skip players with too few structures regardless of density
if (levelSum < MIN_LEVEL_SUM_FOR_HIGH_DENSITY_NUKE) continue;
const density = levelSum / tilesOwned;
if (density > bestDensity) {
bestDensity = density;
bestTarget = other;
}
}
return bestTarget;
}
private findFFACrownTarget(): Player | null {
const { difficulty, gameMode } = this.game.config().gameConfig();
if (gameMode !== GameMode.FFA) {
@@ -377,12 +439,19 @@ export class NationNukeBehavior {
return this.cost(type);
}
// Return the actual cost in team games (saving up for a MIRV is not relevant, the game will be finished before that)
// or if we already have enough gold to buy both a MIRV and a hydro
// Save up a limited amount in team games, synced with NationStructureBehavior
// Saving up for a MIRV is not relevant
if (
this.game.config().gameConfig().gameMode === GameMode.Team &&
this.player.gold() > this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
// Return the actual cost if we already have enough gold to buy both a MIRV and a hydro
if (
this.game.config().gameConfig().gameMode === GameMode.Team ||
this.player.gold() >
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
this.cost(UnitType.MIRV) + this.cost(UnitType.HydrogenBomb)
) {
return this.cost(type);
}
@@ -735,6 +804,13 @@ export class NationNukeBehavior {
// Try each enemy SAM as a target, easiest (lowest level) first
const sortedSams = enemySams.slice().sort((a, b) => a.level() - b.level());
let needsMoreSilos = false;
// Track the first failed attempt so we can upgrade a silo that would
// actually have helped that plan (rather than an unrelated silo).
let failedTarget: {
targetTile: TileRef;
coveringSamIds: Set<number>;
totalBombs: number;
} | null = null;
for (const targetSam of sortedSams) {
const targetTile = targetSam.tile();
@@ -832,6 +908,7 @@ export class NationNukeBehavior {
}
if (unblockedBombs.length < totalBombs) {
failedTarget ??= { targetTile, coveringSamIds, totalBombs };
needsMoreSilos = true;
continue;
}
@@ -860,6 +937,7 @@ export class NationNukeBehavior {
}
if (bestWindowCount < totalBombs) {
failedTarget ??= { targetTile, coveringSamIds, totalBombs };
needsMoreSilos = true;
continue;
}
@@ -921,8 +999,8 @@ export class NationNukeBehavior {
// Couldn't destroy any SAM — upgrade silos only if capacity was the bottleneck.
// If we only lack gold, don't waste it upgrading silos — just wait and save.
if (needsMoreSilos) {
this.maybeUpgradeBestProtectedSilo();
if (needsMoreSilos && failedTarget !== null) {
this.maybeUpgradeHelpfulSilo(failedTarget);
}
}
@@ -951,18 +1029,47 @@ export class NationNukeBehavior {
}
/**
* Upgrade the missile silo that is best protected by our own SAMs.
* Called when we need more silo capacity to overwhelm enemy SAMs.
* Upgrade a missile silo that would actually have helped the failed
* overwhelm attempt: trajectory to the failed target is not blocked by
* non-covering enemy SAMs, and the silo is below the upgrade cap. Among
* those, picks the one best protected by our own SAMs.
*/
private maybeUpgradeBestProtectedSilo(): void {
private maybeUpgradeHelpfulSilo(failedTarget: {
targetTile: TileRef;
coveringSamIds: Set<number>;
totalBombs: number;
}): void {
const silos = this.player.units(UnitType.MissileSilo);
if (silos.length === 0) return;
// First pass: find silos with an unblocked trajectory to the failed
// target. Only these contribute slots to the overwhelm plan.
const unblockedSilos: Unit[] = [];
for (const silo of silos) {
if (
!this.isTrajectoryInterceptableBySam(
silo.tile(),
failedTarget.targetTile,
failedTarget.coveringSamIds,
)
) {
unblockedSilos.push(silo);
}
}
if (unblockedSilos.length === 0) return;
// Bail out if the target is unreachable even at max silo level —
// crazy amounts of covering SAMs, upgrading is wasted gold.
const maxAchievableSlots =
unblockedSilos.length * MAX_NATION_SILO_UPGRADE_LEVEL;
if (maxAchievableSlots < failedTarget.totalBombs) return;
const ourSams = this.player.units(UnitType.SAMLauncher);
let bestSilo: Unit | null = null;
let bestProtection = -1;
for (const silo of silos) {
for (const silo of unblockedSilos) {
if (silo.level() >= MAX_NATION_SILO_UPGRADE_LEVEL) continue;
if (!this.player.canUpgradeUnit(silo)) continue;
let protection = 0;
@@ -1,6 +1,7 @@
import {
Difficulty,
Game,
GameMode,
Gold,
Player,
PlayerType,
@@ -57,7 +58,7 @@ function getStructureRatios(
},
[UnitType.SAMLauncher]: {
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
perceivedCostIncreasePerOwned: 0.5,
perceivedCostIncreasePerOwned: 0.3,
},
[UnitType.MissileSilo]: {
ratioPerCity: 0.2,
@@ -75,6 +76,9 @@ const FACTORY_COASTAL_RATIO_MULTIPLIER = 0.33;
/** Maximum number of missile silos a nation will build */
const MAX_MISSILE_SILOS = 3;
/** Ratio per city used for the first missile silo so nations start nuking earlier */
const FIRST_MISSILE_SILO_RATIO = 0.4;
/** If we have more than this many structures per tiles, prefer upgrading over building */
const UPGRADE_DENSITY_THRESHOLD = 1 / 1500;
@@ -84,6 +88,34 @@ const DEFENSE_POST_DENSITY_THRESHOLD = 1 / 5000;
/** Estimated number of tiles per city equivalent, used when cities are disabled */
const TILES_PER_CITY_EQUIVALENT = 2000;
/**
* When map-wide nation density (nations per land tile) is above this threshold,
* a nation's very first structure is a port (or factory if no water access)
*/
const HIGH_NATION_DENSITY_THRESHOLD = 1 / 7500;
/**
* Starting-gold threshold above which nations enter the
* "high-gold" early game: they build a SAM first and wait between structure
* placements. Without this, high-starting-gold games let a nation
* drop many structures within a short timespan, which ballooned its maxTroops
* before troop count caught up (delaying its attacks) and clustered the
* new structures inside a single nuke blast radius.
*/
const HIGH_STARTING_GOLD_THRESHOLD = 3_000_000n;
/** Tick gap a high-starting-gold nation must wait before placing its Nth structure */
const HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS: readonly number[] = [
0, // before #1 (SAM) — no pause
0, // before #2 — no pause
250, // before #3 — 25s
150, // before #4 — 15s
100, // before #5 — 10s
];
/** Length in ticks of each on/off phase after the team-mode save-up target is first reached */
const TEAM_POST_SAVE_UP_PHASE_TICKS = 150; // 15s
export class NationStructureBehavior {
private reachableStationsCache: Array<{
tile: TileRef;
@@ -91,6 +123,10 @@ export class NationStructureBehavior {
weight: number;
}> | null = null;
private _sharedWaterComponents: Set<number> | null = null;
private lastStructureTick: number | null = null;
private placementsCount = 0;
private _hasHighStartingGold: boolean | null = null;
private _postSaveUpStartTick: number | null = null;
constructor(
private random: PseudoRandom,
@@ -99,6 +135,54 @@ export class NationStructureBehavior {
) {}
handleStructures(): boolean {
if (this.isOnStructureCooldown()) {
return false;
}
if (this.isInPostSaveUpBlockedPhase()) {
return false;
}
const built = this.doHandleStructures();
if (built) {
this.lastStructureTick = this.game.ticks();
this.placementsCount++;
}
return built;
}
private isOnStructureCooldown(): boolean {
// Only high-starting-gold nations pause
if (this.lastStructureTick === null || !this.hasHighStartingGold()) {
return false;
}
const requiredGap =
HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS[this.placementsCount] ?? 0;
if (requiredGap === 0) {
return false;
}
return this.game.ticks() - this.lastStructureTick < requiredGap;
}
// Spreads placements after the save-up target is first reached:
// 15s ON / 15s OFF, alternating, to allow NationNukeBehavior to spend the gold.
private isInPostSaveUpBlockedPhase(): boolean {
if (this.game.config().isUnitDisabled(UnitType.MissileSilo)) {
return false;
}
const saveUpTarget = this.getSaveUpTarget();
if (this._postSaveUpStartTick === null) {
if (this.player.gold() < saveUpTarget) {
return false;
}
this._postSaveUpStartTick = this.game.ticks();
}
const elapsed = this.game.ticks() - this._postSaveUpStartTick;
return (
elapsed % (TEAM_POST_SAVE_UP_PHASE_TICKS * 2) >=
TEAM_POST_SAVE_UP_PHASE_TICKS
);
}
private doHandleStructures(): boolean {
this.reachableStationsCache = null;
const config = this.game.config();
const citiesDisabled = config.isUnitDisabled(UnitType.City);
@@ -111,6 +195,44 @@ export class NationStructureBehavior {
this._sharedWaterComponents = this.game.sharedWaterComponents(this.player);
const hasCoastalTiles = this._sharedWaterComponents !== null;
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
// High-starting-gold Hard/Impossible nations build a SAM first so their
// next structures get SAM coverage and aren't clustered under the same nuke target.
const { difficulty } = config.gameConfig();
if (
this.placementsCount === 0 &&
(difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible) &&
!config.isUnitDisabled(UnitType.AtomBomb) &&
missileSilosEnabled &&
!config.isUnitDisabled(UnitType.SAMLauncher) &&
this.hasHighStartingGold() &&
this.maybeSpawnStructure(UnitType.SAMLauncher)
) {
return true;
}
// On crowded maps the first structure is a port (or factory if landlocked)
// instead of a city, so nations can get income earlier.
// Mainly intended for private 200+ nation HvN games.
if (
!citiesDisabled &&
this.player.unitsOwned(UnitType.City) === 0 &&
this.isHighNationDensity()
) {
const preferredFirst =
hasCoastalTiles && !config.isUnitDisabled(UnitType.Port)
? UnitType.Port
: UnitType.Factory;
if (
!config.isUnitDisabled(preferredFirst) &&
this.maybeSpawnStructure(preferredFirst)
) {
return true;
}
}
// Build order for non-city structures (priority order)
const buildOrder: UnitType[] = [
UnitType.DefensePost,
@@ -124,7 +246,6 @@ export class NationStructureBehavior {
!config.isUnitDisabled(UnitType.AtomBomb) ||
!config.isUnitDisabled(UnitType.HydrogenBomb) ||
!config.isUnitDisabled(UnitType.MIRV);
const missileSilosEnabled = !config.isUnitDisabled(UnitType.MissileSilo);
for (const structureType of buildOrder) {
// Skip disabled structure types
@@ -167,6 +288,21 @@ export class NationStructureBehavior {
return false;
}
private hasHighStartingGold(): boolean {
this._hasHighStartingGold ??=
this.game.config().startingGold(this.player.info()) >=
HIGH_STARTING_GOLD_THRESHOLD;
return this._hasHighStartingGold;
}
private isHighNationDensity(): boolean {
const landTiles = this.game.numLandTiles();
if (landTiles <= 0) return false;
return (
this.game.nations().length / landTiles > HIGH_NATION_DENSITY_THRESHOLD
);
}
/**
* Determines if we should build more of this structure type based on
* the current city count and the configured ratio.
@@ -202,6 +338,11 @@ export class NationStructureBehavior {
return false;
}
// First missile silo uses a higher ratio so nations can start nuking earlier
if (type === UnitType.MissileSilo && owned === 0) {
ratio = FIRST_MISSILE_SILO_RATIO;
}
// Density cap on defense posts (can't be upgraded so a new one would be built - problematic if it's a game with high starting gold)
if (type === UnitType.DefensePost) {
const tilesOwned = this.player.numTilesOwned();
@@ -297,9 +438,15 @@ export class NationStructureBehavior {
private getSaveUpTarget(): Gold {
const config = this.game.config();
// No need to save up if missile silos are disabled
// Just save up for SAMs if missile silos are disabled
if (config.isUnitDisabled(UnitType.MissileSilo)) {
return 0n;
return this.cost(UnitType.SAMLauncher);
}
// Save up a limited amount in team games, synced with NationNukeBehavior
// Saving up for a MIRV is not relevant
if (this.game.config().gameConfig().gameMode === GameMode.Team) {
return this.cost(UnitType.HydrogenBomb);
}
const mirvEnabled = !config.isUnitDisabled(UnitType.MIRV);
@@ -318,8 +465,8 @@ export class NationStructureBehavior {
// Save up for 20 atom bombs
return this.cost(UnitType.AtomBomb) * 20n;
}
// No nukes enabled, no need to save up
return 0n;
// No nukes enabled, just save up for SAMs
return this.cost(UnitType.SAMLauncher);
}
/**
@@ -561,16 +708,15 @@ export class NationStructureBehavior {
private portValue(): (tile: TileRef) => number {
const game = this.game;
const otherUnits = this.player.units(UnitType.Port);
const { structureSpacing } = this.spacingConstants();
return (tile) => {
let w = 0;
// Prefer to be away from other structures of the same type
// Prefer to be as far as possible from other ports
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
otherTiles.delete(tile);
const [, closestOtherDist] = closestTile(game, otherTiles, tile);
w += Math.min(closestOtherDist, structureSpacing);
w += closestOtherDist;
return w;
};
@@ -31,6 +31,9 @@ export class NationWarshipBehavior {
maybeSpawnWarship(): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.Warship)) {
return false;
}
if (!this.random.chance(50)) {
return false;
}
@@ -89,6 +92,9 @@ export class NationWarshipBehavior {
// Send out a warship if our transport ship got captured
private trackTransportShipsAndRetaliate(): void {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return;
}
// Add any currently owned transport ships to our tracking set
this.player
.units(UnitType.TransportShip)
@@ -185,6 +191,10 @@ export class NationWarshipBehavior {
}
private shouldCounterWarshipInfestation(): boolean {
if (this.game.config().isUnitDisabled(UnitType.Warship)) {
return false;
}
// Only the smart nations can do this
const { difficulty } = this.game.config().gameConfig();
if (
+50 -11
View File
@@ -101,6 +101,10 @@ export class AiAttackBehavior {
private attackWithRandomBoat(borderingEnemies: Player[] = []) {
if (this.player === null) throw new Error("not initialized");
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return;
}
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
@@ -166,8 +170,12 @@ export class AiAttackBehavior {
if (owner.isPlayer() && borderingEnemies.includes(owner)) {
continue;
}
// Don't spam boats into players which are stronger than us
if (owner.isPlayer() && owner.troops() > this.player.troops()) {
// Don't spam boats into players which are stronger than us (FFA only)
if (
this.isFFA() &&
owner.isPlayer() &&
owner.troops() > this.player.troops()
) {
continue;
}
@@ -258,7 +266,8 @@ export class AiAttackBehavior {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest afk enemy
const afk = borderingEnemies.find(
(enemy) =>
enemy.isDisconnected() && enemy.troops() < this.player.troops() * 3,
enemy.isDisconnected() &&
(!this.isFFA() || enemy.troops() < this.player.troops() * 3),
);
if (afk) {
this.sendAttack(afk);
@@ -292,7 +301,7 @@ export class AiAttackBehavior {
if (relation.relation !== Relation.Hostile) continue;
const other = relation.player;
if (this.player.isFriendly(other)) continue;
if (other.troops() > this.player.troops() * 3) continue;
if (this.isFFA() && other.troops() > this.player.troops() * 3) continue;
this.sendAttack(other);
return true;
}
@@ -312,8 +321,8 @@ export class AiAttackBehavior {
if (borderingEnemies.length > 0) {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest
const weakest = borderingEnemies[0];
// Don't attack if they have more troops than us
if (weakest.troops() < this.player.troops()) {
// In FFA, don't attack if they have more troops than us
if (!this.isFFA() || weakest.troops() < this.player.troops()) {
this.sendAttack(weakest);
return true;
}
@@ -463,6 +472,8 @@ export class AiAttackBehavior {
private assistAllies(): boolean {
if (this.emojiBehavior === undefined) throw new Error("not initialized");
if (this.game.config().disableAlliances()) return false;
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
@@ -490,11 +501,14 @@ export class AiAttackBehavior {
// Find a traitor who isn't significantly stronger than us
private findTraitor(borderingEnemies: Player[]): Player | null {
if (this.game.config().disableAlliances()) return null;
// borderingEnemies is already sorted by troops (ascending), so first match is weakest traitor
return (
borderingEnemies.find(
(enemy) =>
enemy.isTraitor() && enemy.troops() < this.player.troops() * 1.2,
enemy.isTraitor() &&
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2),
) ?? null
);
}
@@ -505,6 +519,8 @@ export class AiAttackBehavior {
): boolean {
if (this.allianceBehavior === undefined) throw new Error("not initialized");
if (this.game.config().disableAlliances()) return false;
if (borderingFriends.length > 0) {
for (const friend of borderingFriends) {
if (
@@ -522,6 +538,10 @@ export class AiAttackBehavior {
}
private isBorderingNukedTerritory(): boolean {
if (this.game.config().isUnitDisabled(UnitType.MissileSilo)) {
return false;
}
for (const tile of this.player.borderTiles()) {
for (const neighbor of this.game.neighbors(tile)) {
if (
@@ -541,7 +561,9 @@ export class AiAttackBehavior {
// borderingEnemies is already sorted by troops (ascending), so first match is weakest victim
return (
borderingEnemies.find((enemy) => {
if (enemy.troops() > this.player.troops() * 1.2) return false;
if (this.isFFA() && enemy.troops() > this.player.troops() * 1.2) {
return false;
}
const totalIncomingTroops = enemy
.incomingAttacks()
@@ -559,7 +581,7 @@ export class AiAttackBehavior {
const enemyMaxTroops = this.game.config().maxTroops(enemy);
return (
enemy.troops() < enemyMaxTroops * 0.15 &&
enemy.troops() < this.player.troops() * 1.2
(!this.isFFA() || enemy.troops() < this.player.troops() * 1.2)
);
});
@@ -568,6 +590,10 @@ export class AiAttackBehavior {
}
private findNearestIslandEnemy(): Player | null {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return null;
}
// Check if we've already sent out the maximum number of transport ships
if (
this.player.unitCount(UnitType.TransportShip) >=
@@ -585,8 +611,8 @@ export class AiAttackBehavior {
const filteredPlayers = this.game.players().filter((p) => {
if (p === this.player) return false;
if (this.player.isFriendly(p)) return false;
// Don't spam boats into players with more troops
return p.troops() < this.player.troops();
// In FFA, don't spam boats into players with more troops
return !this.isFFA() || p.troops() < this.player.troops();
});
if (filteredPlayers.length === 0) return null;
@@ -642,6 +668,13 @@ export class AiAttackBehavior {
return reachablePlayers[0];
}
// In team games, nations should be willing to attack/boat into stronger
// enemies - they can rely on teammates to donate. In FFA, going after
// someone significantly stronger is usually a losing proposition.
private isFFA(): boolean {
return this.game.config().gameConfig().gameMode === GameMode.FFA;
}
private getPlayerCenter(player: Player) {
if (player.largestClusterBoundingBox) {
return boundingBoxCenter(player.largestClusterBoundingBox);
@@ -688,6 +721,8 @@ export class AiAttackBehavior {
}
getNeighborTraitorToAttack(): Player | null {
if (this.game.config().disableAlliances()) return null;
const traitors = this.player
.neighbors()
.filter(
@@ -786,6 +821,10 @@ export class AiAttackBehavior {
}
private sendBoatAttack(target: Player) {
if (this.game.config().isUnitDisabled(UnitType.TransportShip)) {
return;
}
const closest = closestTwoTiles(
this.game,
Array.from(this.player.borderTiles()).filter((t) => this.game.isShore(t)),
+4 -1
View File
@@ -164,7 +164,10 @@ describe("AllianceBehavior.handleAllianceExtensionRequests", () => {
let allianceBehavior: NationAllianceBehavior;
beforeEach(() => {
mockGame = { addExecution: vi.fn() };
mockGame = {
addExecution: vi.fn(),
config: vi.fn(() => ({ disableAlliances: vi.fn(() => false) })),
};
mockHuman = { id: vi.fn(() => "human_id") };
mockAlliance = {
onlyOneAgreedToExtend: vi.fn(() => true),