mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 05:11:21 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>[] {
|
||||
|
||||
Reference in New Issue
Block a user