mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user