mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:00:42 +00:00
d321c08d92
## Description: - **Nations now specifically target AFK players, traitors, and players who are already being attacked (they gang up on them).** Depends on the difficulty. - Added ally assistance directly to the attack order (instead of calling it separately beforehand). - Added ally assistance to `findBestNukeTarget`. - Removed some checks from `findBestNukeTarget` (not necessary; better checks will follow in a dedicated nuking PR). - Relation updates on attack now depend on difficulty (makes Easy & Medium nations a bit easier). - On betrayal, every nation in the game previously received a –40 relation penalty toward the betrayer. That was too extreme, so now this only applies only to neighbors. - Nations send fewer alliance requests now; it felt like too many before. - In team games, nations may now reject alliance requests more often (depending on difficulty). - To ensure there are enough non-friendly players to stop the crown with nukes, nations may now reject alliance requests if the other player has too many alliances (on Hard and Impossible difficulty). - Rebalanced nation emoji usage a bit. - Nations may now send an emoji when they get MIRVed. ## 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: FloPinguin --------- Co-authored-by: iamlewis <lewismmmm@gmail.com>
397 lines
11 KiB
TypeScript
397 lines
11 KiB
TypeScript
import { renderTroops } from "../../client/Utils";
|
|
import {
|
|
Attack,
|
|
Difficulty,
|
|
Execution,
|
|
Game,
|
|
MessageType,
|
|
Player,
|
|
PlayerID,
|
|
PlayerType,
|
|
TerrainType,
|
|
TerraNullius,
|
|
} from "../game/Game";
|
|
import { TileRef } from "../game/GameMap";
|
|
import { PseudoRandom } from "../PseudoRandom";
|
|
import { assertNever } from "../Util";
|
|
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()) {
|
|
const difficulty = this.mg.config().gameConfig().difficulty;
|
|
let relationChange: number;
|
|
switch (difficulty) {
|
|
case Difficulty.Easy:
|
|
relationChange = -60;
|
|
break;
|
|
case Difficulty.Medium:
|
|
relationChange = -70;
|
|
break;
|
|
case Difficulty.Hard:
|
|
relationChange = -80;
|
|
break;
|
|
case Difficulty.Impossible:
|
|
relationChange = -100;
|
|
break;
|
|
default:
|
|
assertNever(difficulty);
|
|
}
|
|
this.target.updateRelation(this._owner, relationChange);
|
|
}
|
|
}
|
|
|
|
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(),
|
|
);
|
|
}
|
|
if (this.removeTroops === false && this.sourceTile === null) {
|
|
// startTroops are always added to attack troops at init but not always removed from owner troops
|
|
// subtract startTroops from attack troops so we don't give back startTroops to owner that were never removed
|
|
// boat attacks (sourceTile !== null) are the exception: troops were removed at departure and must be returned after attack still
|
|
this.attack.setTroops(this.attack.troops() - (this.startTroops ?? 0));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|