diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 9f7301cd3..f8ff3f5e8 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index dbde911bc..61a963a13 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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 { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 5997c1320..2b31fdc57 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -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; diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 16549c607..73afa0c84 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -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): false | Player { - const enemies = new Set(); - 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): boolean { - const enemyTiles = new Set(); - 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) { - 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): Player | null { - const neighbors = new Map(); - 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[] {