Files
OpenFrontIO/src/core/execution/AttackExecution.ts
T
evanpelle 469a14d62a Allow attacking allies or teammates if player is disconnected (#2144)
## Description:

This will allow players to conquer land from afk teammates in team
games.

No troop loss if attacking afk teammate.

Also remove the team check in attack execution because we already do an
isFriendly check.

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

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

evan
2025-10-06 14:25:46 -07:00

370 lines
9.6 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 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;
}
// ALLIANCE CHECK — block attacks on friendly (ally or same team)
if (this.target.isPlayer()) {
const targetPlayer = this.target as Player;
if (this._owner.isFriendly(targetPlayer)) {
console.warn(
`${this._owner.displayName()} cannot attack ${targetPlayer.displayName()} because they are friendly (allied or same team)`,
);
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, 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;
}
}
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()) {
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;
}
if (targetPlayer && this._owner.isFriendly(targetPlayer)) {
// In this case a new alliance was created AFTER the attack started.
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;
}
}