mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 00:21:55 +00:00
Commit all at once
This commit is contained in:
@@ -203,7 +203,7 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="l-header__highlightText">v21.2</div>
|
||||
<div class="l-header__highlightText">EXPERIMENTAL</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<TileContainer> =
|
||||
new PriorityQueue<TileContainer>((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<TileRef>(); // fast presence checks
|
||||
private toConquerIndex = new Map<TileRef, number>(); // 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<TileRef>();
|
||||
|
||||
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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user