diff --git a/src/client/index.html b/src/client/index.html index d0ddc600c..12372b1c2 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -203,7 +203,7 @@ /> -
v21.2
+
EXPERIMENTAL
diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index afaee99a6..a517e04cd 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -132,6 +132,10 @@ export class DefaultConfig implements Config { private _userSettings: UserSettings, ) {} + numPlayerTeams(): number { + return this.gameConfig().numPlayerTeams; + } + samHittingChance(): number { return 0.8; } @@ -176,7 +180,7 @@ export class DefaultConfig implements Config { } cityPopulationIncrease(): number { - return 250_000; + return 500_000; } falloutDefenseModifier(falloutRatio: number): number { @@ -192,14 +196,11 @@ export class DefaultConfig implements Config { } defensePostRange(): number { - return 30; + return 40; } defensePostDefenseBonus(): number { return 5; } - numPlayerTeams(): number { - return this._gameConfig.numPlayerTeams ?? 0; - } spawnNPCs(): boolean { return !this._gameConfig.disableNPCs; } @@ -222,12 +223,7 @@ export class DefaultConfig implements Config { return 10000 + 150 * Math.pow(dist, 1.1); } tradeShipSpawnRate(numberOfPorts: number): number { - if (numberOfPorts <= 3) return 18; - if (numberOfPorts <= 5) return 25; - if (numberOfPorts <= 8) return 35; - if (numberOfPorts <= 10) return 40; - if (numberOfPorts <= 12) return 45; - return 50; + return Math.round(10 * Math.pow(numberOfPorts, 0.6)); } unitInfo(type: UnitType): UnitInfo { @@ -347,7 +343,7 @@ export class DefaultConfig implements Config { p.type() == PlayerType.Human && this.infiniteGold() ? 0 : Math.min( - 1_000_000, + 2_000_000, Math.pow( 2, p.unitsIncludingConstruction(UnitType.City).length, @@ -419,26 +415,25 @@ export class DefaultConfig implements Config { defenderTroopLoss: number; tilesPerTickUsed: number; } { - let mag = 0; - let speed = 0; + const terrainModifiers = { + [TerrainType.Plains]: { mag: 0.85, speed: 0.75 }, + [TerrainType.Highland]: { mag: 1, speed: 1 }, + [TerrainType.Mountain]: { mag: 1.2, speed: 1.5 }, + } as const; + const type = gm.terrainType(tileToConquer); - switch (type) { - case TerrainType.Plains: - mag = 85; - speed = 16.5; - break; - case TerrainType.Highland: - mag = 100; - speed = 20; - break; - case TerrainType.Mountain: - mag = 120; - speed = 25; - break; - default: - throw new Error(`terrain type ${type} not supported`); + const mod = terrainModifiers[type]; + if (!mod) { + throw new Error(`terrain type ${type} not supported`); } - if (defender.isPlayer()) { + let mag = mod.mag; + let speed = mod.speed; + + const attackerType = attacker.type(); + const defenderIsPlayer = defender.isPlayer(); + const defenderType = defenderIsPlayer ? defender.type() : null; + + if (defenderIsPlayer) { for (const dp of gm.nearbyUnits( tileToConquer, gm.config().defensePostRange(), @@ -454,66 +449,58 @@ export class DefaultConfig implements Config { if (gm.hasFallout(tileToConquer)) { const falloutRatio = gm.numTilesWithFallout() / gm.numLandTiles(); - mag *= this.falloutDefenseModifier(falloutRatio); - speed *= this.falloutDefenseModifier(falloutRatio); + //mag *= this.falloutDefenseModifier(falloutRatio); + //speed *= this.falloutDefenseModifier(falloutRatio); } - if (attacker.isPlayer() && defender.isPlayer()) { - if ( - attacker.type() == PlayerType.Human && - defender.type() == PlayerType.Bot - ) { + if (attacker.isPlayer() && defenderIsPlayer) { + if (attackerType == PlayerType.Human && defenderType == PlayerType.Bot) { mag *= 0.8; } if ( - attacker.type() == PlayerType.FakeHuman && - defender.type() == PlayerType.Bot + attackerType == PlayerType.FakeHuman && + defenderType == PlayerType.Bot ) { mag *= 0.8; } } - - let largeLossModifier = 1; - if (attacker.numTilesOwned() > 100_000) { - largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned()); - } - let largeSpeedMalus = 1; - if (attacker.numTilesOwned() > 75_000) { - // sqrt is only exponent 1/2 which doesn't slow enough huge players - largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6; + if (attackerType == PlayerType.Bot) { + speed *= 4; // slow bot attacks } + if (defenderIsPlayer) { + const defenderTroops = defender.troops(); + const defenderTiles = defender.numTilesOwned(); + const defenderdensity = defenderTroops / defenderTiles; + const adjustedRatio = within(defenderTroops / attackTroops, 0.3, 10); - if (defender.isPlayer()) { - const ratio = within( - Math.pow(defender.troops() / attackTroops, 0.4), - 0.1, - 10, - ); - const speedRatio = within( - defender.troops() / (5 * attackTroops), - 0.1, - 10, - ); - + if (attacker.type() == PlayerType.Human) { + console.log( + "speed:", + 4 * + within(defenderdensity, 3, 90) ** 0.6 * + adjustedRatio ** 0.7 * + speed, + ); + console.log("density", defenderdensity); + } return { attackerTroopLoss: - ratio * - mag * - largeLossModifier * - (defender.isTraitor() ? this.traitorDefenseDebuff() : 1), - defenderTroopLoss: defender.population() / defender.numTilesOwned(), - tilesPerTickUsed: Math.floor(speedRatio * speed * largeSpeedMalus), + mag * 20 + + defenderdensity * + mag * + (defender.isTraitor() ? this.traitorDefenseDebuff() : 1), + defenderTroopLoss: defenderdensity, + tilesPerTickUsed: within( + 3.2 * defenderdensity ** 0.5 * adjustedRatio ** 0.7 * speed, + 8, + 1000, + ), }; } else { return { - attackerTroopLoss: - attacker.type() == PlayerType.Bot ? mag / 10 : mag / 5, + attackerTroopLoss: attackerType == PlayerType.Bot ? mag * 20 : mag * 20, defenderTroopLoss: 0, - tilesPerTickUsed: within( - (2000 * Math.max(10, speed)) / attackTroops, - 5, - 100, - ), + tilesPerTickUsed: 30 * speed, }; } } @@ -525,13 +512,9 @@ export class DefaultConfig implements Config { numAdjacentTilesWithEnemy: number, ): number { if (defender.isPlayer()) { - return ( - within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) * - numAdjacentTilesWithEnemy * - 3 - ); + return 10 * numAdjacentTilesWithEnemy; } else { - return numAdjacentTilesWithEnemy * 2; + return 12 * numAdjacentTilesWithEnemy; } } @@ -582,7 +565,7 @@ export class DefaultConfig implements Config { const maxPop = player.type() == PlayerType.Human && this.infiniteTroops() ? 1_000_000_000 - : 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) + + : 1 * (player.numTilesOwned() * 30 + 100000) + player.units(UnitType.City).length * this.cityPopulationIncrease(); if (player.type() == PlayerType.Bot) { @@ -638,8 +621,31 @@ export class DefaultConfig implements Config { } goldAdditionRate(player: Player): number { - const ratio = Math.pow(player.workers() / player.population(), 1.3); - return Math.floor(Math.sqrt(player.workers()) * ratio * 5); + const numCities = player.units(UnitType.City).length; + const baseCityPopulation = numCities * this.cityPopulationIncrease(); + + const totalWorkers = player.workers() ?? 0; + const totalPopulation = player.population() ?? 0; + const maxPopulation = this.maxPopulation(player) ?? 0; + const numTiles = player.numTilesOwned() ?? 0; + + if (totalWorkers <= 0 || totalPopulation <= 0 || maxPopulation <= 0) { + return 0; + } + + const populationRatio = totalPopulation / maxPopulation; + const adjustedCityPopulation = baseCityPopulation * populationRatio; + + const cityWorkers = + (adjustedCityPopulation * totalWorkers) / totalPopulation; + const ruralWorkers = totalWorkers - cityWorkers; + + const cityGold = cityWorkers / 1000; + const tileGold = (Math.sqrt(ruralWorkers) * Math.sqrt(numTiles)) / 300; + + const totalGold = cityGold + tileGold; + + return Number.isFinite(totalGold) ? totalGold : 0; } troopAdjustmentRate(player: Player): number { diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 5fbec6111..6bf06ef2b 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -1,4 +1,3 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; import { renderNumber, renderTroops } from "../../client/Utils"; import { Attack, @@ -16,29 +15,30 @@ 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; - private toConquer: PriorityQueue = - new PriorityQueue((a: TileContainer, b: TileContainer) => { - if (a.priority == b.priority) { - if (a.tick == b.tick) { - return 0; - // return this.random.nextInt(-1, 1) - } - return a.tick - b.tick; - } - return a.priority - b.priority; - }); + + // 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 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,10 +57,9 @@ 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)) { @@ -68,6 +67,7 @@ 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 +76,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); - if (this.target && this.target.isPlayer()) { + // Embargo if non-bots are fighting + if (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,6 +97,7 @@ export class AttackExecution implements Execution { return; } + // Prevent attacks during spawn protection if ( this.target.isPlayer() && this.mg.config().numSpawnPhaseTurns() + @@ -108,24 +109,27 @@ 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(); @@ -137,13 +141,14 @@ 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(); @@ -151,6 +156,7 @@ export class AttackExecution implements Execution { } } + // Start conquest from source tile or full border if (this.sourceTile != null) { this.addNeighbors(this.sourceTile); } else { @@ -159,21 +165,27 @@ 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.toConquer.clear(); + this.toConquerList = []; + this.toConquerSet.clear(); + this.toConquerIndex.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) { @@ -188,19 +200,16 @@ 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()) { - if (this.attack.target().isPlayer()) { - this.retreat(malusForRetreat); - } else { - this.retreat(); - } + this.retreat(this.attack.target().isPlayer() ? malusForRetreat : 0); this.active = false; return; } if (this.attack.retreating()) { - return; + return; // Keep waiting for retreat flag to become "retreated" } if (!this.attack.isActive()) { @@ -208,13 +217,14 @@ export class AttackExecution implements Execution { return; } + // Break alliance if needed const alliance = this._owner.allianceWith(this.target as Player); - if (this.breakAlliance && alliance != null) { + if (this.breakAlliance && alliance) { 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; } @@ -227,8 +237,6 @@ export class AttackExecution implements Execution { this.target, this.border.size + this.random.nextInt(0, 5), ); - // consolex.log(`num tiles per tick: ${numTilesPerTick}`) - // consolex.log(`num execs: ${this.mg.executions().length}`) while (numTilesPerTick > 0) { if (this.attack.troops() < 1) { @@ -237,23 +245,97 @@ export class AttackExecution implements Execution { return; } - if (this.toConquer.size() == 0) { + if (this.toConquerList.length === 0) { this.refreshToConquer(); this.retreat(); return; } - const tileToConquer = this.toConquer.dequeue().tile; + 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, + ); + } + this.border.delete(tileToConquer); - 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; - } + // 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; + this.addNeighbors(tileToConquer); + const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg .config() .attackLogic( @@ -263,59 +345,74 @@ 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); - } + 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); + } + } + 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); - let numOwnedByMe = this.mg + + 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 .neighbors(neighbor) - .filter((t) => this.mg.owner(t) == this._owner).length; - const dist = 0; - if (numOwnedByMe > 2) { - numOwnedByMe = 10; + .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); + } } - 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, - dist / 100 + this.random.nextInt(0, 2) - numOwnedByMe + mag, - 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(), ); @@ -342,19 +439,40 @@ 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, - public readonly tick: number, - ) {} -} diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index 3b336fee7..69b8ec20b 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -1,3 +1,4 @@ +import { DefaultConfig } from "../configuration/DefaultConfig"; import { consolex } from "../Consolex"; import { Cell, @@ -194,18 +195,37 @@ export class FakeHumanExecution implements Execution { } } + private chanceScaled(n: number): boolean { + const gameConfig = this.mg.config() as DefaultConfig; + const maxPop = gameConfig.maxPopulation(this.player); + const threshold = (this.player.targetTroopRatio() * maxPop) / 2; + const troops = this.player.troops(); + + let scaledN = n; + + if (troops < 0.25 * threshold) { + return false; // no chance + } else if (troops < 0.5 * threshold) { + // scale smoothly from 0 to 1 as ratio goes from 0.25 to 0.5 + const ratio = (troops - 0.25 * threshold) / (0.25 * threshold); // in [0, 1] + scaledN = Math.max(1, Math.round(n / ratio)); + } + + return this.random.chance(scaledN); + } + private shouldAttack(other: Player): boolean { if (this.player.isOnSameTeam(other)) { return false; } if (this.player.isFriendly(other)) { if (this.shouldDiscourageAttack(other)) { - return this.random.chance(200); + return this.chanceScaled(200); } - return this.random.chance(50); + return this.chanceScaled(50); } else { if (this.shouldDiscourageAttack(other)) { - return this.random.chance(4); + return this.chanceScaled(4); } return true; }