From 8134f435a29d930f79dde2c390be69e32a992c16 Mon Sep 17 00:00:00 2001
From: 1brucben <1benjbruce@gmail.com>
Date: Mon, 21 Apr 2025 20:14:46 +0200
Subject: [PATCH] Commit all at once
---
src/client/index.html | 2 +-
src/core/configuration/DefaultConfig.ts | 170 +++++++-------
src/core/execution/AttackExecution.ts | 286 ++++++++++++++++-------
src/core/execution/FakeHumanExecution.ts | 26 ++-
4 files changed, 314 insertions(+), 170 deletions(-)
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 @@
/>
-
+
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;
}