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-26 18:43:45 -06:00
committed by GitHub
parent c0febacb8e
commit 8099b9fad7
9 changed files with 385 additions and 45 deletions
+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)),