Nations: Fix city farming + reactive defense posts + fix nuked territory capture 🛡️ (#3814)

## Description:

Would be very good to get these fixes last minute into v31.

- **Farming nations for cities is fixed**: Here you can see city farming
in action, vari farms Finland:
https://www.youtube.com/watch?v=J4J0ajlnSHs&t=137s - Lots of 125k gold
cities... Now nations will build defense posts instead of cities:

- **Nations now build defense posts reactively** instead of through the
normal structure build order. When a nation is under significant land
attack (incoming/own troops >= 35%), it places a defense post near the
attack front -- skipped on Easy, capped at 1 on Medium, and scaled by
the incoming troop ratio on Hard/Impossible. Posts spread along the
front. The old `defensePostValue()` placement path is removed.

- **Nations now capture nuked territory.**
`hasLandBorderWithTerraNullius()` was incorrectly filtering out tiles
with fallout, causing the `nuked` attack strategy to never dispatch a
attack. Big bug

.

- [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-05-03 17:33:01 +02:00
committed by GitHub
parent 213ddd36c9
commit 58ec8b280f
3 changed files with 777 additions and 100 deletions
@@ -1,11 +1,11 @@
import {
Attack,
Difficulty,
Game,
GameMode,
Gold,
Player,
PlayerType,
Relation,
Structures,
Unit,
UnitType,
@@ -52,10 +52,6 @@ function getStructureRatios(
ratioPerCity: 0.75,
perceivedCostIncreasePerOwned: 1,
},
[UnitType.DefensePost]: {
ratioPerCity: 0.25,
perceivedCostIncreasePerOwned: 1,
},
[UnitType.SAMLauncher]: {
ratioPerCity: SAM_RATIO_BY_DIFFICULTY[difficulty],
perceivedCostIncreasePerOwned: 0.3,
@@ -82,9 +78,6 @@ 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;
/** Maximum density of defense posts (per tile owned) before no more can be built */
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;
@@ -116,6 +109,19 @@ const HIGH_GOLD_STRUCTURE_COOLDOWN_TICKS: readonly number[] = [
/** 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
/**
* Incoming land-attack troop count as a fraction of own troops below which
* the nation does not build defensive structures.
*/
const UNDER_ATTACK_THREAT_RATIO = 0.35;
/**
* Hard / Impossible: one additional defense post is allowed per this fraction
* of the incoming-to-own-troop ratio (e.g. 0.4 → 1 post at 040%, 2 at
* 4080%, 3 at 80120%, …).
*/
const DEFENSE_POST_RATIO_PER_POST = 0.4;
export class NationStructureBehavior {
private reachableStationsCache: Array<{
tile: TileRef;
@@ -135,6 +141,23 @@ export class NationStructureBehavior {
) {}
handleStructures(): boolean {
// Defense posts are handled outside the normal pacing/counter system:
// they don't increment placementsCount or lastStructureTick, and they
// are never built as the very first structure.
if (
this.placementsCount > 0 &&
!this.game.config().isUnitDisabled(UnitType.DefensePost)
) {
if (this.tryBuildDefensePost()) {
return true;
}
// If the attack threshold is met, block other structures even when
// placement failed (no tile found / can't afford).
if (this.defensePostNeeded()) {
return false;
}
}
if (this.isOnStructureCooldown()) {
return false;
}
@@ -149,6 +172,214 @@ export class NationStructureBehavior {
return built;
}
/**
* Tries to place one defense post near an active land-attack front.
* Not called on Easy. Medium: 50% chance per call, 1 post total. Hard/Impossible:
* ceil(ratio / 0.4) posts total. Boat attacks (sourceTile != null) are ignored.
* Does not touch placementsCount or lastStructureTick.
*/
private tryBuildDefensePost(): boolean {
const { difficulty } = this.game.config().gameConfig();
if (difficulty === Difficulty.Easy) return false;
if (difficulty === Difficulty.Medium && !this.random.chance(2))
return false;
const player = this.player;
const landAttacks = player
.incomingAttacks()
.filter((a) => a.sourceTile() === null);
if (landAttacks.length === 0) return false;
const ourTroops = player.troops();
if (ourTroops <= 0) return false;
const incomingTroops = landAttacks.reduce((sum, a) => sum + a.troops(), 0);
const ratio = incomingTroops / ourTroops;
if (ratio < UNDER_ATTACK_THREAT_RATIO) return false;
let allowed: number;
if (difficulty === Difficulty.Medium) {
allowed = 1;
} else {
allowed = Math.ceil(ratio / DEFENSE_POST_RATIO_PER_POST);
}
const frontTiles = this.getAttackFrontTiles(landAttacks);
if (this.countDefensePostsNearFront(frontTiles, allowed) >= allowed)
return false;
const cost = this.cost(UnitType.DefensePost);
if (player.gold() < cost) return false;
const tiles = this.sampleTilesNearFront(
frontTiles,
25,
UnitType.DefensePost,
);
for (const tile of tiles) {
if (!player.canBuild(UnitType.DefensePost, tile)) continue;
this.game.addExecution(
new ConstructionExecution(player, UnitType.DefensePost, tile),
);
return true;
}
return false;
}
private defensePostNeeded(): boolean {
const { difficulty } = this.game.config().gameConfig();
if (difficulty === Difficulty.Easy) return false;
const landAttacks = this.player
.incomingAttacks()
.filter((a) => a.sourceTile() === null);
if (landAttacks.length === 0) return false;
const ourTroops = this.player.troops();
if (ourTroops <= 0) return false;
const incomingTroops = landAttacks.reduce((sum, a) => sum + a.troops(), 0);
return incomingTroops / ourTroops >= UNDER_ATTACK_THREAT_RATIO;
}
/**
* Returns our border tiles that are adjacent to a tile owned by any of the
* attacking players.
*/
private getAttackFrontTiles(landAttacks: Attack[]): TileRef[] {
const game = this.game;
const player = this.player;
const attackerSet = new Set(landAttacks.map((a) => a.attacker()));
if (attackerSet.size === 0) return [];
const frontTiles: TileRef[] = [];
outer: for (const borderTile of player.borderTiles()) {
for (const neighbor of game.neighbors(borderTile)) {
const owner = game.owner(neighbor);
if (attackerSet.has(owner as Player)) {
frontTiles.push(borderTile);
continue outer;
}
}
}
return frontTiles;
}
/**
* Counts defense posts within 1.5 × borderSpacing of any front tile.
* `cap` short-circuits the scan once that many are found.
*/
private countDefensePostsNearFront(
frontTiles: TileRef[],
cap?: number,
): number {
if (frontTiles.length === 0) return 0;
const game = this.game;
const { borderSpacing } = this.spacingConstants();
const rangeSquared = (borderSpacing * 1.5) ** 2;
let count = 0;
for (const dp of this.player.units(UnitType.DefensePost)) {
for (const frontTile of frontTiles) {
if (game.euclideanDistSquared(dp.tile(), frontTile) <= rangeSquared) {
count++;
if (cap !== undefined && count >= cap) return count;
break;
}
}
}
return count;
}
/**
* Samples territory tiles for defense-post placement, using the full attack
* front as anchors. Only tiles where canBuild passes are collected.
* Anchors near existing defense posts are filtered out first so successive
* posts spread along the front rather than clustering together.
*
* Phase 1: tiles at depth [0.75×, 1.5×] borderSpacing from any border.
* Fallback 1: relax depth constraint (territory smaller than borderSpacing).
* Fallback 2: pure random territory sampling (canBuild checked by caller).
*/
private sampleTilesNearFront(
frontTiles: TileRef[],
count: number,
unitType: UnitType,
): TileRef[] {
const game = this.game;
const player = this.player;
if (frontTiles.length === 0) {
return [];
}
const { borderSpacing } = this.spacingConstants();
const searchRadius = Math.ceil(borderSpacing * 1.5);
const minBorderDist = Math.ceil(borderSpacing * 0.75);
const maxBorderDist = Math.ceil(borderSpacing * 1.5);
const borderTiles = player.borderTiles();
// Spread: prefer front tiles far from existing defense posts so successive
// posts don't cluster at the same spot along the attack line.
const spreadRangeSquared = (borderSpacing * 1.5) ** 2;
const existingDPTiles = player
.units(UnitType.DefensePost)
.map((u) => u.tile());
let anchors: TileRef[];
if (existingDPTiles.length > 0) {
anchors = frontTiles.filter(
(ft) =>
!existingDPTiles.some(
(dp) => game.euclideanDistSquared(ft, dp) < spreadRangeSquared,
),
);
if (anchors.length === 0) anchors = frontTiles;
} else {
anchors = frontTiles;
}
const result: TileRef[] = [];
for (
let attempt = 0;
attempt < count * 6 && result.length < count;
attempt++
) {
const anchor = this.random.randElement(anchors);
const ax = game.x(anchor);
const ay = game.y(anchor);
const x = this.random.nextInt(ax - searchRadius, ax + searchRadius + 1);
const y = this.random.nextInt(ay - searchRadius, ay + searchRadius + 1);
if (!game.isValidCoord(x, y)) continue;
const t = game.ref(x, y);
if (game.owner(t) !== player) continue;
const [, borderDist] = closestTile(game, borderTiles, t);
if (borderDist < minBorderDist || borderDist > maxBorderDist) continue;
if (!player.canBuild(unitType, t)) continue;
result.push(t);
}
if (result.length > 0) return result;
// Fallback: relax border-depth constraint (territory too small for depth ring)
const fallback: TileRef[] = [];
for (
let attempt = 0;
attempt < count * 4 && fallback.length < count;
attempt++
) {
const anchor = this.random.randElement(anchors);
const ax = game.x(anchor);
const ay = game.y(anchor);
const x = this.random.nextInt(ax - searchRadius, ax + searchRadius + 1);
const y = this.random.nextInt(ay - searchRadius, ay + searchRadius + 1);
if (!game.isValidCoord(x, y)) continue;
const t = game.ref(x, y);
if (game.owner(t) !== player) continue;
fallback.push(t);
}
return fallback;
}
private isOnStructureCooldown(): boolean {
// Only high-starting-gold nations pause
if (this.lastStructureTick === null || !this.hasHighStartingGold()) {
@@ -235,7 +466,6 @@ export class NationStructureBehavior {
// Build order for non-city structures (priority order)
const buildOrder: UnitType[] = [
UnitType.DefensePost,
UnitType.Port,
UnitType.Factory,
UnitType.SAMLauncher,
@@ -343,17 +573,6 @@ export class NationStructureBehavior {
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();
if (
tilesOwned > 0 &&
owned / tilesOwned >= DEFENSE_POST_DENSITY_THRESHOLD
) {
return false;
}
}
const targetCount = Math.floor(cityCount * ratio);
return owned < targetCount;
@@ -659,8 +878,6 @@ export class NationStructureBehavior {
return this.factoryValue();
case UnitType.Port:
return this.portValue();
case UnitType.DefensePost:
return this.defensePostValue();
case UnitType.SAMLauncher:
return this.samLauncherValue();
default:
@@ -1008,79 +1225,6 @@ export class NationStructureBehavior {
};
}
/**
* Value function for defense posts.
* Returns null if there are no hostile non-bot neighbors.
* Prefers elevation, proximity to border with hostile neighbors, and spacing.
*/
private defensePostValue(): ((tile: TileRef) => number) | null {
const game = this.game;
const player = this.player;
const borderTiles = player.borderTiles();
const otherUnits = player.units(UnitType.DefensePost);
const { borderSpacing, structureSpacing } = this.spacingConstants();
// Check if we have any non-friendly non-bot neighbors with more troops
const hasHostileNeighbor =
player
.nearby()
.filter(
(n): n is Player =>
n.isPlayer() &&
player.isFriendly(n) === false &&
n.type() !== PlayerType.Bot &&
n.troops() > player.troops(),
).length > 0;
// Don't build defense posts if there is no danger
if (!hasHostileNeighbor) {
return null;
}
return (tile) => {
let w = 0;
// Prefer higher elevations
w += game.magnitude(tile);
const [closest, closestBorderDist] = closestTile(game, borderTiles, tile);
if (closest !== null) {
// Prefer to be borderSpacing tiles from the border
w += Math.max(
0,
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
);
// Prefer adjacent players who are hostile and have more troops
const neighbors: Set<Player> = new Set();
for (const neighborTile of game.neighbors(closest)) {
if (!game.isLand(neighborTile)) continue;
const id = game.ownerID(neighborTile);
if (id === player.smallID()) continue;
const neighbor = game.playerBySmallID(id);
if (!neighbor.isPlayer()) continue;
if (neighbor.type() === PlayerType.Bot) continue;
if (neighbor.troops() <= player.troops()) continue;
neighbors.add(neighbor);
}
for (const neighbor of neighbors) {
w += borderSpacing * (Relation.Friendly - player.relation(neighbor));
}
}
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(otherUnits.map((u) => u.tile()));
otherTiles.delete(tile);
const closestOther = closestTwoTiles(game, otherTiles, [tile]);
if (closestOther !== null) {
const d = game.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
return w;
};
}
/**
* Value function for SAM launchers.
* Prefers elevation, distance from border, spacing, and proximity to protectable structures.
+1 -5
View File
@@ -755,11 +755,7 @@ export class AiAttackBehavior {
private hasLandBorderWithTerraNullius(): boolean {
for (const border of this.player.borderTiles()) {
for (const neighbor of this.game.neighbors(border)) {
if (
this.game.isLand(neighbor) &&
!this.game.hasOwner(neighbor) &&
!this.game.hasFallout(neighbor)
) {
if (this.game.isLand(neighbor) && !this.game.hasOwner(neighbor)) {
return true;
}
}