From 7c7ad1281cda61d6c15e8c6beff90a587bbb5945 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Fri, 25 Apr 2025 23:57:56 +0200 Subject: [PATCH] evan's tile algorithm --- src/core/execution/AttackExecution.ts | 273 +++++++------------------- 1 file changed, 71 insertions(+), 202 deletions(-) diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 6bf06ef2b..8d62debb5 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -1,3 +1,4 @@ +import { PriorityQueue } from "@datastructures-js/priority-queue"; import { renderNumber, renderTroops } from "../../client/Utils"; import { Attack, @@ -15,30 +16,22 @@ import { PseudoRandom } from "../PseudoRandom"; const malusForRetreat = 25; -// This class handles the lifecycle of an attack between one player and a target. -// It determines what tiles to conquer, resolves combat logic, and manages retreat/end conditions. export class AttackExecution implements Execution { private breakAlliance = false; private active: boolean = true; - - // These are the tiles we are considering conquering - private toConquerList: TileRef[] = []; // ordered list for random selection - private toConquerSet = new Set(); // fast presence checks - private toConquerIndex = new Map(); // O(1) removal from list - private validTileList: TileRef[] = []; // subset of list that is currently on the front line - + private toConquer: PriorityQueue = + new PriorityQueue((a: TileContainer, b: TileContainer) => { + return a.priority - b.priority; + }); private random = new PseudoRandom(123); - // Map of each tile to its combat weight and adjacency info - private tileWeights: Map< - TileRef, - { weight: number; ownedCount: number; valid: boolean } - > = new Map(); - private _owner: Player; private target: Player | TerraNullius; + private mg: Game; + private border = new Set(); + private attack: Attack = null; constructor( @@ -57,9 +50,10 @@ export class AttackExecution implements Execution { return false; } - // Initializes the attack object and prepares the first batch of tiles to conquer init(mg: Game, ticks: number) { - if (!this.active) return; + if (!this.active) { + return; + } this.mg = mg; if (!mg.hasPlayer(this._ownerID)) { @@ -67,7 +61,6 @@ export class AttackExecution implements Execution { this.active = false; return; } - if (this._targetID != null && !mg.hasPlayer(this._targetID)) { console.warn(`target ${this._targetID} not found`); this.active = false; @@ -76,17 +69,17 @@ export class AttackExecution implements Execution { this._owner = mg.player(this._ownerID); this.target = - this._targetID === this.mg.terraNullius().id() + this._targetID == this.mg.terraNullius().id() ? mg.terraNullius() : mg.player(this._targetID); - // Embargo if non-bots are fighting - if (this.target.isPlayer()) { + if (this.target && this.target.isPlayer()) { const targetPlayer = this.target as Player; if ( targetPlayer.type() != PlayerType.Bot && this._owner.type() != PlayerType.Bot ) { + // Don't let bots embargo since they can't trade anyways. targetPlayer.addEmbargo(this._owner.id()); } } @@ -97,7 +90,6 @@ export class AttackExecution implements Execution { return; } - // Prevent attacks during spawn protection if ( this.target.isPlayer() && this.mg.config().numSpawnPhaseTurns() + @@ -109,27 +101,24 @@ export class AttackExecution implements Execution { return; } - // Determine troop count if (this.startTroops == null) { this.startTroops = this.mg .config() .attackAmount(this._owner, this.target); } - if (this.removeTroops) { this.startTroops = Math.min(this._owner.troops(), this.startTroops); this._owner.removeTroops(this.startTroops); } - this.attack = this._owner.createAttack( this.target, this.startTroops, this.sourceTile, ); - // Cancel out opposing incoming attacks for (const incoming of this._owner.incomingAttacks()) { if (incoming.attacker() == this.target) { + // Target has opposing attack, cancel them out if (incoming.troops() > this.attack.troops()) { incoming.setTroops(incoming.troops() - this.attack.troops()); this.attack.delete(); @@ -141,14 +130,13 @@ export class AttackExecution implements Execution { } } } - - // Combine with duplicate outgoing attack for (const outgoing of this._owner.outgoingAttacks()) { if ( outgoing != this.attack && outgoing.target() == this.attack.target() && outgoing.sourceTile() == this.attack.sourceTile() ) { + // Existing attack on same target, add troops outgoing.setTroops(outgoing.troops() + this.attack.troops()); this.active = false; this.attack.delete(); @@ -156,7 +144,6 @@ export class AttackExecution implements Execution { } } - // Start conquest from source tile or full border if (this.sourceTile != null) { this.addNeighbors(this.sourceTile); } else { @@ -165,27 +152,21 @@ export class AttackExecution implements Execution { if (this.target.isPlayer()) { if (this._owner.isAlliedWith(this.target)) { + // No updates should happen in init. this.breakAlliance = true; } this.target.updateRelation(this._owner, -80); } } - // Rebuilds the list of tiles to conquer from scratch private refreshToConquer() { - this.toConquerList = []; - this.toConquerSet.clear(); - this.toConquerIndex.clear(); + this.toConquer.clear(); this.border.clear(); - this.tileWeights.forEach((entry) => (entry.valid = false)); - this.validTileList = []; - for (const tile of this._owner.borderTiles()) { this.addNeighbors(tile); } } - // Retreats from battle, possibly killing some troops private retreat(malusPercent = 0) { const deaths = this.attack.troops() * (malusPercent / 100); if (deaths) { @@ -200,16 +181,19 @@ export class AttackExecution implements Execution { this.active = false; } - // Runs attack logic every game tick: conquers tiles, calculates losses, refreshes conquest front tick(ticks: number) { if (this.attack.retreated()) { - this.retreat(this.attack.target().isPlayer() ? malusForRetreat : 0); + if (this.attack.target().isPlayer()) { + this.retreat(malusForRetreat); + } else { + this.retreat(); + } this.active = false; return; } if (this.attack.retreating()) { - return; // Keep waiting for retreat flag to become "retreated" + return; } if (!this.attack.isActive()) { @@ -217,14 +201,13 @@ export class AttackExecution implements Execution { return; } - // Break alliance if needed const alliance = this._owner.allianceWith(this.target as Player); - if (this.breakAlliance && alliance) { + if (this.breakAlliance && alliance != null) { this.breakAlliance = false; this._owner.breakAlliance(alliance); } - if (this.target.isPlayer() && this._owner.isAlliedWith(this.target)) { + // In this case a new alliance was created AFTER the attack started. this.retreat(); return; } @@ -245,97 +228,23 @@ export class AttackExecution implements Execution { return; } - if (this.toConquerList.length === 0) { + if (this.toConquer.size() == 0) { this.refreshToConquer(); this.retreat(); return; } - const validTiles = this.validTileList; - if (validTiles.length === 0) { - this.retreat(); - return; - } - - // Prioritize tiles touching 3+ owned neighbors - const priorityTiles: { tile: TileRef; weight: number }[] = []; - const fallbackTiles: { tile: TileRef; weight: number }[] = []; - - for (const tile of validTiles) { - const meta = this.tileWeights.get(tile); - if (!meta) continue; - const { weight, ownedCount } = meta; - if (ownedCount >= 3) { - priorityTiles.push({ tile, weight }); - } else { - fallbackTiles.push({ tile, weight }); - } - } - - const candidates = - priorityTiles.length > 0 ? priorityTiles : fallbackTiles; - if (candidates.length === 0) { - this.retreat(); - return; - } - - // Weighted random selection - const cumulativeWeights: number[] = []; - let runningTotal = 0; - for (const { weight } of candidates) { - runningTotal += weight; - cumulativeWeights.push(runningTotal); - } - - if (runningTotal === 0) { - this.retreat(); - return; - } - - const r = (this.random.nextInt(0, 10000) / 10000) * runningTotal; - let low = 0; - let high = cumulativeWeights.length - 1; - while (low < high) { - const mid = Math.floor((low + high) / 2); - if (r < cumulativeWeights[mid]) { - high = mid; - } else { - low = mid + 1; - } - } - - const tileToConquer = candidates[low].tile; - - // Remove tile from list/set/index after selection - const index = this.toConquerIndex.get(tileToConquer); - if (index !== undefined) { - const last = this.toConquerList.length - 1; - const lastTile = this.toConquerList[last]; - this.toConquerList[index] = lastTile; - this.toConquerIndex.set(lastTile, index); - this.toConquerList.pop(); - this.toConquerSet.delete(tileToConquer); - this.toConquerIndex.delete(tileToConquer); - } - - const meta = this.tileWeights.get(tileToConquer); - if (meta) { - meta.valid = false; - this.validTileList = this.validTileList.filter( - (t) => t !== tileToConquer, - ); - } - + const tileToConquer = this.toConquer.dequeue().tile; this.border.delete(tileToConquer); - // Make sure tile still borders friendly land - const onBorder = this.mg - .neighbors(tileToConquer) - .some((t) => this.mg.owner(t) == this._owner); - if (this.mg.owner(tileToConquer) != this.target || !onBorder) continue; - + const onBorder = + this.mg + .neighbors(tileToConquer) + .filter((t) => this.mg.owner(t) == this._owner).length > 0; + if (this.mg.owner(tileToConquer) != this.target || !onBorder) { + continue; + } this.addNeighbors(tileToConquer); - const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg .config() .attackLogic( @@ -345,74 +254,56 @@ export class AttackExecution implements Execution { this.target, tileToConquer, ); - numTilesPerTick -= tilesPerTickUsed; this.attack.setTroops(this.attack.troops() - attackerTroopLoss); - if (this.target.isPlayer()) this.target.removeTroops(defenderTroopLoss); - this._owner.conquer(tileToConquer); - - // Update border and validity of neighbor tiles - for (const neighbor of this.mg.neighbors(tileToConquer)) { - if (this.toConquerSet.has(neighbor)) { - const onBorder = this.mg - .neighbors(neighbor) - .some((t) => this.mg.owner(t) === this._owner); - const meta = this.tileWeights.get(neighbor); - if (meta) { - meta.valid = onBorder; - if (onBorder && !this.validTileList.includes(neighbor)) { - this.validTileList.push(neighbor); - } else if (!onBorder) { - this.validTileList = this.validTileList.filter( - (t) => t !== neighbor, - ); - } - } - this.updateTileWeight(neighbor); - } + if (this.target.isPlayer()) { + this.target.removeTroops(defenderTroopLoss); } - + this._owner.conquer(tileToConquer); this.handleDeadDefender(); } } - // Adds enemy neighbors of a tile to the conquest frontier private addNeighbors(tile: TileRef) { for (const neighbor of this.mg.neighbors(tile)) { - if (this.mg.isWater(neighbor) || this.mg.owner(neighbor) != this.target) + if (this.mg.isWater(neighbor) || this.mg.owner(neighbor) != this.target) { continue; - + } this.border.add(neighbor); - - if (!this.toConquerSet.has(neighbor)) { - this.toConquerSet.add(neighbor); - this.toConquerIndex.set(neighbor, this.toConquerList.length); - this.toConquerList.push(neighbor); - this.updateTileWeight(neighbor); - } - - const onBorder = this.mg + const numOwnedByMe = this.mg .neighbors(neighbor) - .some((t) => this.mg.owner(t) === this._owner); - const meta = this.tileWeights.get(neighbor); - if (meta) { - meta.valid = onBorder; - if (onBorder && !this.validTileList.includes(neighbor)) { - this.validTileList.push(neighbor); - } else if (!onBorder) { - this.validTileList = this.validTileList.filter((t) => t !== neighbor); - } + .filter((t) => this.mg.owner(t) == this._owner).length; + let mag = 0; + switch (this.mg.terrainType(tile)) { + case TerrainType.Plains: + mag = 1; + break; + case TerrainType.Highland: + mag = 1.5; + break; + case TerrainType.Mountain: + mag = 2; + break; } + this.toConquer.enqueue( + new TileContainer( + neighbor, + (this.random.nextInt(0, 7) + 10) * + (1 - numOwnedByMe * 0.5 + mag / 2) + + this.mg.ticks(), + ), + ); } } - // If defender collapses (few tiles left), conquer everything and transfer gold private handleDeadDefender() { if (!(this.target.isPlayer() && this.target.numTilesOwned() < 100)) return; const gold = this.target.gold(); this.mg.displayMessage( - `Conquered ${this.target.displayName()} received ${renderNumber(gold)} gold`, + `Conquered ${this.target.displayName()} received ${renderNumber( + gold, + )} gold`, MessageType.SUCCESS, this._owner.id(), ); @@ -439,40 +330,18 @@ export class AttackExecution implements Execution { } } - // Recomputes how desirable a tile is to conquer, based on terrain and neighbor ownership - private updateTileWeight(tile: TileRef) { - const neighbors = this.mg.neighbors(tile); - const ownedCount = neighbors.filter( - (t) => this.mg.owner(t) === this._owner, - ).length; - - let weight = 1.0; - switch (this.mg.terrainType(tile)) { - case TerrainType.Plains: - weight = 3.0; - break; - case TerrainType.Highland: - weight = 0.5; - break; - case TerrainType.Mountain: - weight = 0.25; - break; - } - - if (ownedCount === 2) weight *= 8; - - const existing = this.tileWeights.get(tile); - const valid = existing?.valid ?? false; - this.tileWeights.set(tile, { weight, ownedCount, valid }); - } - - // Returns the player who owns this attack owner(): Player { return this._owner; } - // Indicates whether the attack is still in progress isActive(): boolean { return this.active; } } + +class TileContainer { + constructor( + public readonly tile: TileRef, + public readonly priority: number, + ) {} +}