Rework annexation into border-engagement scaling; drop cluster removal

- Make attack pacing + defender losses depend on **border engagement fraction**:
  - Add `defenderTotalBorderTiles` to `Config.attackTilesPerTick()`
  - Add `borderEngagedFraction` to `Config.attackLogic()`
  - In `DefaultConfig.attackTilesPerTick()`, scale tiles-per-tick by engaged fraction and enable a **full-annexation mode** when the attack engages the entire defender border (fast conquest multiplier)
  - In `DefaultConfig.attackLogic()`, scale defender troop losses with engagement and apply a large loss multiplier during full annexation
- Remove attack speed randomness for stability:
  - `AttackExecution` now uses exact `attack.borderSize()` (no `+ random(0..5)`)
  - Compute and pass `borderEngagedFraction = attackBorder / defenderBorder` into `attackLogic()`
-
**Notes / behavior changes:**
- Annexation is now driven by **border engagement** rather than “surrounded cluster” detection.
- Attack resolution is less jittery (no random border boost) and becomes extremely fast only when the defender’s border is fully engaged.
This commit is contained in:
scamiv
2025-12-14 02:31:54 +01:00
parent e290e587ea
commit 20b840e658
4 changed files with 62 additions and 149 deletions
+3 -1
View File
@@ -100,10 +100,11 @@ export interface Config {
troopIncreaseRate(player: Player | PlayerView): number;
goldAdditionRate(player: Player | PlayerView): Gold;
attackTilesPerTick(
attckTroops: number,
attackTroops: number,
attacker: Player,
defender: Player | TerraNullius,
numAdjacentTilesWithEnemy: number,
defenderTotalBorderTiles?: number,
): number;
attackLogic(
gm: Game,
@@ -111,6 +112,7 @@ export interface Config {
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
borderEngagedFraction?: number,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
+31 -1
View File
@@ -649,6 +649,7 @@ export class DefaultConfig implements Config {
attacker: Player,
defender: Player | TerraNullius,
tileToConquer: TileRef,
borderEngagedFraction?: number,
): {
attackerTroopLoss: number;
defenderTroopLoss: number;
@@ -741,7 +742,17 @@ export class DefaultConfig implements Config {
largeDefenderAttackDebuff *
largeAttackBonus *
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
defenderTroopLoss: (() => {
const baseLoss = defender.troops() / defender.numTilesOwned();
const f = Math.max(0, Math.min(borderEngagedFraction ?? 0, 1));
// Full annexation: border fully engaged = massive troop loss
if (f >= 1) return baseLoss * 5.0;
// Option A: scale defender losses up as engagement increases (0.5x..2.0x)
// Keep baseline loss at least as high as the original system, but ramp up with engagement (1.0x..2.0x).
return baseLoss * (1.0 + 1.0 * f);
})(),
tilesPerTickUsed:
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
speed *
@@ -768,11 +779,30 @@ export class DefaultConfig implements Config {
attacker: Player,
defender: Player | TerraNullius,
numAdjacentTilesWithEnemy: number,
defenderTotalBorderTiles?: number,
): number {
if (defender.isPlayer()) {
const f = defenderTotalBorderTiles
? Math.min(numAdjacentTilesWithEnemy / defenderTotalBorderTiles, 1)
: 0;
// Full annexation: if the attack engages the entire defender border, conquest becomes very cheap.
if (f >= 1) {
return (
within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
numAdjacentTilesWithEnemy *
10
);
}
// Option A: engaged fraction curve. Low f => slow; high f => fast.
// Tune: don't slow down baseline attacks. f=0 -> 1.0x, f=0.5 -> 1.5x, f=1 -> 3.0x (clamped; annex handled above).
const engagedMultiplier = Math.max(1.0, Math.min(1.0 + 2.0 * f * f, 3.0));
return (
within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
numAdjacentTilesWithEnemy *
engagedMultiplier *
3
);
} else {
+12 -1
View File
@@ -239,7 +239,8 @@ export class AttackExecution implements Execution {
troopCount,
this._owner,
this.target,
this.attack.borderSize() + this.random.nextInt(0, 5),
this.attack.borderSize(),
this.target.isPlayer() ? this.target.borderTiles().size : undefined,
);
while (numTilesPerTick > 0) {
@@ -269,6 +270,15 @@ export class AttackExecution implements Execution {
continue;
}
this.addNeighbors(tileToConquer);
//border engagement fraction f = engagedBorder / defenderBorder (clamped to [0, 1]).
const defenderBorderTiles = this.target.isPlayer()
? this.target.borderTiles().size
: 0;
const borderEngagedFraction =
defenderBorderTiles > 0
? Math.min(this.attack.borderSize() / defenderBorderTiles, 1)
: 0;
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
.attackLogic(
@@ -277,6 +287,7 @@ export class AttackExecution implements Execution {
this._owner,
this.target,
tileToConquer,
borderEngagedFraction,
);
numTilesPerTick -= tilesPerTickUsed;
troopCount -= attackerTroopLoss;
+16 -146
View File
@@ -1,7 +1,7 @@
import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
import { calculateBoundingBox, simpleHash } from "../Util";
interface ClusterTraversalState {
visited: Uint32Array;
@@ -119,158 +119,28 @@ export class PlayerExecution implements Execution {
private removeClusters() {
const clusters = this.calculateClusters();
clusters.sort((a, b) => b.size - a.size);
const main = clusters.shift();
if (main === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox(this.mg, main);
const surroundedBy = this.surroundedBySamePlayer(main);
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(main);
}
for (const cluster of clusters) {
if (this.isSurrounded(cluster)) {
this.removeCluster(cluster);
}
}
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
const enemies = new Set<number>();
for (const tile of cluster) {
let hasUnownedNeighbor = false;
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
return false;
}
this.mg.forEachNeighbor(tile, (n) => {
if (!this.mg.hasOwner(n)) {
hasUnownedNeighbor = true;
return;
}
const ownerId = this.mg.ownerID(n);
if (ownerId !== this.player.smallID()) {
enemies.add(ownerId);
}
});
if (hasUnownedNeighbor) {
return false;
}
if (enemies.size !== 1) {
return false;
}
}
if (enemies.size !== 1) {
return false;
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
const clusterBox = calculateBoundingBox(this.mg, cluster);
if (inscribed(enemyBox, clusterBox)) {
return enemy;
}
return false;
}
private isSurrounded(cluster: Set<TileRef>): boolean {
const enemyTiles = new Set<TileRef>();
for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
return false;
}
this.mg.forEachNeighbor(tr, (n) => {
const owner = this.mg.owner(n);
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
enemyTiles.add(n);
}
});
}
if (enemyTiles.size === 0) {
return false;
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
const clusterBox = calculateBoundingBox(this.mg, cluster);
return inscribed(enemyBox, clusterBox);
}
private removeCluster(cluster: Set<TileRef>) {
if (
Array.from(cluster).some(
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
)
) {
// Other removeCluster operations could change tile owners,
// so double check.
if (clusters.length === 0) {
this.player.largestClusterBoundingBox = null;
return;
}
const capturing = this.getCapturingPlayer(cluster);
if (capturing === null) {
return;
// Find the largest cluster with a single linear scan (O(n)).
let largestIndex = 0;
let largestSize = clusters[0].size;
for (let i = 1; i < clusters.length; i++) {
const size = clusters[i].size;
if (size > largestSize) {
largestSize = size;
largestIndex = i;
}
}
const firstTile = cluster.values().next().value;
if (!firstTile) {
return;
}
const tiles = this.floodFillWithGen(
this.bumpGeneration(),
this.traversalState().visited,
[firstTile],
(tile, cb) => this.mg.forEachNeighbor(tile, cb),
(tile) => this.mg.ownerID(tile) === this.player.smallID(),
const largestCluster = clusters[largestIndex];
this.player.largestClusterBoundingBox = calculateBoundingBox(
this.mg,
largestCluster,
);
if (this.player.numTilesOwned() === tiles.size) {
this.mg.conquerPlayer(capturing, this.player);
}
for (const tile of tiles) {
capturing.conquer(tile);
}
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
const neighbors = new Map<Player, number>();
for (const t of cluster) {
this.mg.forEachNeighbor(t, (neighbor) => {
const owner = this.mg.owner(neighbor);
if (
owner.isPlayer() &&
owner !== this.player &&
!owner.isFriendly(this.player)
) {
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
}
});
}
// If there are no enemies, return null
if (neighbors.size === 0) {
return null;
}
// Get the largest attack from the neighbors
let largestNeighborAttack: Player | null = null;
let largestTroopCount = 0;
for (const [neighbor] of neighbors) {
for (const attack of neighbor.outgoingAttacks()) {
if (attack.target() === this.player) {
if (attack.troops() > largestTroopCount) {
largestTroopCount = attack.troops();
largestNeighborAttack = neighbor;
}
}
}
}
if (largestNeighborAttack !== null) {
return largestNeighborAttack;
}
// There are no ongoing attacks, so find the enemy with the largest border.
return getMode(neighbors);
}
private calculateClusters(): Set<TileRef>[] {