mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 09:18:11 +00:00
20b840e658
- 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.
241 lines
6.2 KiB
TypeScript
241 lines
6.2 KiB
TypeScript
import { Config } from "../configuration/Config";
|
|
import { Execution, Game, Player, UnitType } from "../game/Game";
|
|
import { TileRef } from "../game/GameMap";
|
|
import { calculateBoundingBox, simpleHash } from "../Util";
|
|
|
|
interface ClusterTraversalState {
|
|
visited: Uint32Array;
|
|
gen: number;
|
|
}
|
|
|
|
// Per-game traversal state used by calculateClusters() to avoid per-player buffers.
|
|
const traversalStates = new WeakMap<Game, ClusterTraversalState>();
|
|
|
|
export class PlayerExecution implements Execution {
|
|
private readonly ticksPerClusterCalc = 20;
|
|
|
|
private config: Config;
|
|
private lastCalc = 0;
|
|
private mg: Game;
|
|
private active = true;
|
|
|
|
constructor(private player: Player) {}
|
|
|
|
activeDuringSpawnPhase(): boolean {
|
|
return false;
|
|
}
|
|
|
|
init(mg: Game, ticks: number) {
|
|
this.mg = mg;
|
|
this.config = mg.config();
|
|
this.lastCalc =
|
|
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
|
}
|
|
|
|
tick(ticks: number) {
|
|
this.player.decayRelations();
|
|
for (const u of this.player.units()) {
|
|
if (!u.info().territoryBound) {
|
|
continue;
|
|
}
|
|
|
|
const owner = this.mg!.owner(u.tile());
|
|
if (!owner?.isPlayer()) {
|
|
u.delete();
|
|
continue;
|
|
}
|
|
if (owner === this.player) {
|
|
continue;
|
|
}
|
|
|
|
const captor = this.mg!.player(owner.id());
|
|
if (u.type() === UnitType.DefensePost) {
|
|
u.decreaseLevel(captor);
|
|
if (u.isActive()) {
|
|
captor.captureUnit(u);
|
|
}
|
|
} else {
|
|
captor.captureUnit(u);
|
|
}
|
|
}
|
|
|
|
if (!this.player.isAlive()) {
|
|
// Player has no tiles, delete any remaining units and gold
|
|
const gold = this.player.gold();
|
|
this.player.removeGold(gold);
|
|
this.player.units().forEach((u) => {
|
|
if (
|
|
u.type() !== UnitType.AtomBomb &&
|
|
u.type() !== UnitType.HydrogenBomb &&
|
|
u.type() !== UnitType.MIRVWarhead &&
|
|
u.type() !== UnitType.MIRV
|
|
) {
|
|
u.delete();
|
|
}
|
|
});
|
|
this.active = false;
|
|
this.mg.stats().playerKilled(this.player, ticks);
|
|
return;
|
|
}
|
|
|
|
const troopInc = this.config.troopIncreaseRate(this.player);
|
|
this.player.addTroops(troopInc);
|
|
const goldFromWorkers = this.config.goldAdditionRate(this.player);
|
|
this.player.addGold(goldFromWorkers);
|
|
|
|
// Record stats
|
|
this.mg.stats().goldWork(this.player, goldFromWorkers);
|
|
|
|
const alliances = Array.from(this.player.alliances());
|
|
for (const alliance of alliances) {
|
|
if (alliance.expiresAt() <= this.mg.ticks()) {
|
|
alliance.expire();
|
|
}
|
|
}
|
|
|
|
const embargoes = this.player.getEmbargoes();
|
|
for (const embargo of embargoes) {
|
|
if (
|
|
embargo.isTemporary &&
|
|
this.mg.ticks() - embargo.createdAt >
|
|
this.mg.config().temporaryEmbargoDuration()
|
|
) {
|
|
this.player.stopEmbargo(embargo.target);
|
|
}
|
|
}
|
|
|
|
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
|
|
if (this.player.lastTileChange() > this.lastCalc) {
|
|
this.lastCalc = ticks;
|
|
const start = performance.now();
|
|
this.removeClusters();
|
|
const end = performance.now();
|
|
if (end - start > 1000) {
|
|
console.log(`player ${this.player.name()}, took ${end - start}ms`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private removeClusters() {
|
|
const clusters = this.calculateClusters();
|
|
|
|
if (clusters.length === 0) {
|
|
this.player.largestClusterBoundingBox = 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 largestCluster = clusters[largestIndex];
|
|
this.player.largestClusterBoundingBox = calculateBoundingBox(
|
|
this.mg,
|
|
largestCluster,
|
|
);
|
|
}
|
|
|
|
private calculateClusters(): Set<TileRef>[] {
|
|
const borderTiles = this.player.borderTiles();
|
|
if (borderTiles.size === 0) return [];
|
|
|
|
const state = this.traversalState();
|
|
const currentGen = this.bumpGeneration();
|
|
const visited = state.visited;
|
|
|
|
const clusters: Set<TileRef>[] = [];
|
|
|
|
for (const startTile of borderTiles) {
|
|
if (visited[startTile] === currentGen) continue;
|
|
|
|
const cluster = this.floodFillWithGen(
|
|
currentGen,
|
|
visited,
|
|
[startTile],
|
|
(tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb),
|
|
(tile) => borderTiles.has(tile),
|
|
);
|
|
clusters.push(cluster);
|
|
}
|
|
return clusters;
|
|
}
|
|
|
|
owner(): Player {
|
|
if (this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
return this.player;
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.active;
|
|
}
|
|
|
|
private traversalState(): ClusterTraversalState {
|
|
const totalTiles = this.mg.width() * this.mg.height();
|
|
let state = traversalStates.get(this.mg);
|
|
if (!state || state.visited.length < totalTiles) {
|
|
state = {
|
|
visited: new Uint32Array(totalTiles),
|
|
gen: 0,
|
|
};
|
|
traversalStates.set(this.mg, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
private bumpGeneration(): number {
|
|
const state = this.traversalState();
|
|
state.gen++;
|
|
if (state.gen === 0xffffffff) {
|
|
state.visited.fill(0);
|
|
state.gen = 1;
|
|
}
|
|
return state.gen;
|
|
}
|
|
|
|
private floodFillWithGen(
|
|
currentGen: number,
|
|
visited: Uint32Array,
|
|
startTiles: TileRef[],
|
|
neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void,
|
|
includeFn: (tile: TileRef) => boolean,
|
|
): Set<TileRef> {
|
|
const result = new Set<TileRef>();
|
|
const stack: TileRef[] = [];
|
|
|
|
for (const start of startTiles) {
|
|
if (visited[start] === currentGen) continue;
|
|
if (!includeFn(start)) continue;
|
|
visited[start] = currentGen;
|
|
result.add(start);
|
|
stack.push(start);
|
|
}
|
|
|
|
while (stack.length > 0) {
|
|
const tile = stack.pop()!;
|
|
neighborFn(tile, (neighbor) => {
|
|
if (visited[neighbor] === currentGen) {
|
|
return;
|
|
}
|
|
if (!includeFn(neighbor)) {
|
|
return;
|
|
}
|
|
visited[neighbor] = currentGen;
|
|
result.add(neighbor);
|
|
stack.push(neighbor);
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|