Files
OpenFrontIO/src/core/execution/AttackExecution.ts
T
Kipstz Avenger 0943b1544c Fix Race conditions on alliances (#1605)
## Description:

Players received "traitor" debuff when alliances were formed after
attacks started, creating an unfair race condition.

the problem was mentioned here
https://discord.com/channels/1284581928254701718/1399115120486912100

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I have read and accepted the CLA agreement (only required once).

## Please put your Discord username so you can be contacted if a bug or
regression is found:

Kipstzz

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
2025-08-02 22:12:23 +00:00

374 lines
9.8 KiB
TypeScript

import { renderTroops } from "../../client/Utils";
import {
Attack,
Execution,
Game,
MessageType,
Player,
PlayerID,
PlayerType,
TerrainType,
TerraNullius,
} from "../game/Game";
import { TileRef } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { FlatBinaryHeap } from "./utils/FlatBinaryHeap"; // adjust path if needed
const malusForRetreat = 25;
export class AttackExecution implements Execution {
private breakAlliance = false;
private wasAlliedAtInit = false; // Store alliance state at initialization
private active: boolean = true;
private toConquer = new FlatBinaryHeap();
private random = new PseudoRandom(123);
private target: Player | TerraNullius;
private mg: Game;
private attack: Attack | null = null;
constructor(
private startTroops: number | null = null,
private _owner: Player,
private _targetID: PlayerID | null,
private sourceTile: TileRef | null = null,
private removeTroops: boolean = true,
) {}
public targetID(): PlayerID | null {
return this._targetID;
}
activeDuringSpawnPhase(): boolean {
return false;
}
init(mg: Game, ticks: number) {
if (!this.active) {
return;
}
this.mg = mg;
if (this._targetID !== null && !mg.hasPlayer(this._targetID)) {
console.warn(`target ${this._targetID} not found`);
this.active = false;
return;
}
this.target =
this._targetID === this.mg.terraNullius().id()
? mg.terraNullius()
: mg.player(this._targetID);
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 anyway.
targetPlayer.addEmbargo(this._owner.id(), true);
}
}
if (this._owner === this.target) {
console.error(`Player ${this._owner} cannot attack itself`);
this.active = false;
return;
}
if (this.target.isPlayer()) {
if (
this.mg.config().numSpawnPhaseTurns() +
this.mg.config().spawnImmunityDuration() >
this.mg.ticks()
) {
console.warn("cannot attack player during immunity phase");
this.active = false;
return;
}
if (this._owner.isOnSameTeam(this.target)) {
console.warn(
`${this._owner.displayName()} cannot attack ${this.target.displayName()} because they are on the same team`,
);
this.active = false;
return;
}
}
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,
new Set<TileRef>(),
);
if (this.sourceTile !== null) {
this.addNeighbors(this.sourceTile);
} else {
this.refreshToConquer();
}
// Record stats
this.mg.stats().attack(this._owner, this.target, this.startTroops);
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();
this.active = false;
return;
} else {
this.attack.setTroops(this.attack.troops() - incoming.troops());
incoming.delete();
}
}
}
for (const outgoing of this._owner.outgoingAttacks()) {
if (
outgoing !== this.attack &&
outgoing.target() === this.attack.target() &&
// Boat attacks (sourceTile is not null) are not combined with other attacks
this.attack.sourceTile() === null
) {
this.attack.setTroops(this.attack.troops() + outgoing.troops());
outgoing.delete();
}
}
if (this.target.isPlayer()) {
// Store the alliance state at initialization time to prevent race conditions
this.wasAlliedAtInit = this._owner.isAlliedWith(this.target);
if (this.wasAlliedAtInit) {
this.breakAlliance = true;
}
this.target.updateRelation(this._owner, -80);
}
}
private refreshToConquer() {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
this.toConquer.clear();
this.attack.clearBorder();
for (const tile of this._owner.borderTiles()) {
this.addNeighbors(tile);
}
}
private retreat(malusPercent = 0) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
const deaths = this.attack.troops() * (malusPercent / 100);
if (deaths) {
this.mg.displayMessage(
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
MessageType.ATTACK_CANCELLED,
this._owner.id(),
);
}
const survivors = this.attack.troops() - deaths;
this._owner.addTroops(survivors);
this.attack.delete();
this.active = false;
// Not all retreats are canceled attacks
if (this.attack.retreated()) {
// Record stats
this.mg.stats().attackCancel(this._owner, this.target, survivors);
}
}
tick(ticks: number) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
let troopCount = this.attack.troops(); // cache troop count
const targetIsPlayer = this.target.isPlayer(); // cache target type
const targetPlayer = targetIsPlayer ? (this.target as Player) : null; // cache target player
if (this.attack.retreated()) {
if (targetIsPlayer) {
this.retreat(malusForRetreat);
} else {
this.retreat();
}
this.active = false;
return;
}
if (this.attack.retreating()) {
return;
}
if (!this.attack.isActive()) {
this.active = false;
return;
}
const alliance = targetPlayer
? this._owner.allianceWith(targetPlayer)
: null;
if (this.breakAlliance && alliance !== null) {
this.breakAlliance = false;
this._owner.breakAlliance(alliance);
}
if (
targetPlayer &&
this._owner.isAlliedWith(targetPlayer) &&
!this.wasAlliedAtInit
) {
// In this case a new alliance was created AFTER the attack started.
// We should retreat to avoid the attacker becoming a traitor.
this.retreat();
return;
}
let numTilesPerTick = this.mg
.config()
.attackTilesPerTick(
troopCount,
this._owner,
this.target,
this.attack.borderSize() + this.random.nextInt(0, 5),
);
while (numTilesPerTick > 0) {
if (troopCount < 1) {
this.attack.delete();
this.active = false;
return;
}
if (this.toConquer.size() === 0) {
this.refreshToConquer();
this.retreat();
return;
}
const [tileToConquer] = this.toConquer.dequeue();
this.attack.removeBorderTile(tileToConquer);
let onBorder = false;
for (const n of this.mg.neighbors(tileToConquer)) {
if (this.mg.owner(n) === this._owner) {
onBorder = true;
break;
}
}
if (this.mg.owner(tileToConquer) !== this.target || !onBorder) {
continue;
}
this.addNeighbors(tileToConquer);
const { attackerTroopLoss, defenderTroopLoss, tilesPerTickUsed } = this.mg
.config()
.attackLogic(
this.mg,
troopCount,
this._owner,
this.target,
tileToConquer,
);
numTilesPerTick -= tilesPerTickUsed;
troopCount -= attackerTroopLoss;
this.attack.setTroops(troopCount);
if (targetPlayer) {
targetPlayer.removeTroops(defenderTroopLoss);
}
this._owner.conquer(tileToConquer);
this.handleDeadDefender();
}
}
private addNeighbors(tile: TileRef) {
if (this.attack === null) {
throw new Error("Attack not initialized");
}
const tickNow = this.mg.ticks(); // cache tick
for (const neighbor of this.mg.neighbors(tile)) {
if (
this.mg.isWater(neighbor) ||
this.mg.owner(neighbor) !== this.target
) {
continue;
}
this.attack.addBorderTile(neighbor);
let numOwnedByMe = 0;
for (const n of this.mg.neighbors(neighbor)) {
if (this.mg.owner(n) === this._owner) {
numOwnedByMe++;
}
}
let mag = 0;
switch (this.mg.terrainType(neighbor)) {
case TerrainType.Plains:
mag = 1;
break;
case TerrainType.Highland:
mag = 1.5;
break;
case TerrainType.Mountain:
mag = 2;
break;
}
const priority =
(this.random.nextInt(0, 7) + 10) * (1 - numOwnedByMe * 0.5 + mag / 2) +
tickNow;
this.toConquer.enqueue(neighbor, priority);
}
}
private handleDeadDefender() {
if (!(this.target.isPlayer() && this.target.numTilesOwned() < 100)) return;
this.mg.conquerPlayer(this._owner, this.target);
for (let i = 0; i < 10; i++) {
for (const tile of this.target.tiles()) {
const borders = this.mg
.neighbors(tile)
.some((t) => this.mg.owner(t) === this._owner);
if (borders) {
this._owner.conquer(tile);
} else {
for (const neighbor of this.mg.neighbors(tile)) {
const no = this.mg.owner(neighbor);
if (no.isPlayer() && no !== this.target) {
this.mg.player(no.id()).conquer(tile);
break;
}
}
}
}
}
}
owner(): Player {
return this._owner;
}
isActive(): boolean {
return this.active;
}
}