Files
OpenFrontIO/src/core/execution/AttackExecution.ts
T
DevelopingTom d1b5c80ccd Cancel alliance requests if the recipient attacks (#1733)
## Description:

Problem: attacking a player right before accepting an alliance request
is very effective since the requester can't fight back or reclaim his
territory without canceling the alliance and being penalized with the
traitor debuff.

Change:
- Attacking a player after he requested an alliance automatically
rejects the request
- No changes to existing attacks in both directions, only new attacks
affect the request

## 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:

IngloriousTom
2025-08-08 00:10:39 +00:00

384 lines
10 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._owner === this.target) {
console.error(`Player ${this._owner} cannot attack itself`);
this.active = false;
return;
}
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);
this.rejectIncomingAllianceRequests(targetPlayer);
}
}
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 rejectIncomingAllianceRequests(target: Player) {
const request = this._owner
.incomingAllianceRequests()
.find((ar) => ar.requestor() === target);
if (request !== undefined) {
request.reject();
}
}
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;
}
}