diff --git a/src/core/execution/DonateTroopExecution.ts b/src/core/execution/DonateTroopExecution.ts index fabe7e82e..cf06a041c 100644 --- a/src/core/execution/DonateTroopExecution.ts +++ b/src/core/execution/DonateTroopExecution.ts @@ -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 { diff --git a/src/core/execution/NationExecution.ts b/src/core/execution/NationExecution.ts index ac3ce4928..d5c895e7a 100644 --- a/src/core/execution/NationExecution.ts +++ b/src/core/execution/NationExecution.ts @@ -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); } diff --git a/src/core/execution/nation/NationAllianceBehavior.ts b/src/core/execution/nation/NationAllianceBehavior.ts index f98958aaa..e738174c8 100644 --- a/src/core/execution/nation/NationAllianceBehavior.ts +++ b/src/core/execution/nation/NationAllianceBehavior.ts @@ -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 && diff --git a/src/core/execution/nation/NationMIRVBehavior.ts b/src/core/execution/nation/NationMIRVBehavior.ts index f935665f9..77ffd8dff 100644 --- a/src/core/execution/nation/NationMIRVBehavior.ts +++ b/src/core/execution/nation/NationMIRVBehavior.ts @@ -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; } diff --git a/src/core/execution/nation/NationNukeBehavior.ts b/src/core/execution/nation/NationNukeBehavior.ts index 9ba0aa8d6..f6b6809b2 100644 --- a/src/core/execution/nation/NationNukeBehavior.ts +++ b/src/core/execution/nation/NationNukeBehavior.ts @@ -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; + 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; + 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; diff --git a/src/core/execution/nation/NationStructureBehavior.ts b/src/core/execution/nation/NationStructureBehavior.ts index fce9f76f1..86783f665 100644 --- a/src/core/execution/nation/NationStructureBehavior.ts +++ b/src/core/execution/nation/NationStructureBehavior.ts @@ -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 | 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 = 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; }; diff --git a/src/core/execution/nation/NationWarshipBehavior.ts b/src/core/execution/nation/NationWarshipBehavior.ts index 51b469954..16378ba5d 100644 --- a/src/core/execution/nation/NationWarshipBehavior.ts +++ b/src/core/execution/nation/NationWarshipBehavior.ts @@ -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 ( diff --git a/src/core/execution/utils/AiAttackBehavior.ts b/src/core/execution/utils/AiAttackBehavior.ts index 4d45b3fe8..a63475426 100644 --- a/src/core/execution/utils/AiAttackBehavior.ts +++ b/src/core/execution/utils/AiAttackBehavior.ts @@ -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)), diff --git a/tests/NationAllianceBehavior.test.ts b/tests/NationAllianceBehavior.test.ts index b078457cf..528d2f844 100644 --- a/tests/NationAllianceBehavior.test.ts +++ b/tests/NationAllianceBehavior.test.ts @@ -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),