mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 15:10:43 +00:00
07e13b3479
## Description: - Remove alliances on death: after death, alliances would stay active including countdown timers and (when dead player kept spectating) icons. Now remove them when player becomes inActive. - Moved code to private method within PlayerExecution + added comments in NationExecution and BotExecution for more clarity as to where removals are performed from at death - Remove renewal request from Events Display when Alliance doesn't exist anymore (after death or otherwise). - Also cleanup this.alliancesCheckedAt when alliance doesn't exist anymore. Before, old/broken alliance id's would accumulate in it during a game. - Removed now-redundant isAlive check in EventsDisplay. Both the alliances array as the isAlive are updated in the same tick from PlayerUpdates so now alliance is removed from alliances array on player death, the other.isAlive() check is no longer needed. Of course we could keep it in just to be very safe, so just let me know when you're doubtful about this. - Attack.test.ts: fix failing test. Player B dies because of the attack, meaning the alliance now gets removed. Prevent this by gving both a different, adjecent, starting tile. And to be more clear about what is needed for the test to pass, add isAlive check for both of them after the attacks. ## 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: tryout33
412 lines
11 KiB
TypeScript
412 lines
11 KiB
TypeScript
import { Config } from "../configuration/Config";
|
|
import { Cell, Execution, Game, Player, UnitType } from "../game/Game";
|
|
import { TileRef } from "../game/GameMap";
|
|
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
|
|
|
interface ClusterTraversalState {
|
|
visited: Uint32Array;
|
|
gen: number;
|
|
}
|
|
|
|
// Per-game traversal state used by calculateClusters() to avoid per-player buffers.
|
|
const traversalStates = new WeakMap<Game, ClusterTraversalState>();
|
|
|
|
export class PlayerExecution implements Execution {
|
|
private readonly ticksPerClusterCalc = 20;
|
|
|
|
private config: Config;
|
|
private lastCalc = 0;
|
|
private mg: Game;
|
|
private active = true;
|
|
|
|
constructor(private player: Player) {}
|
|
|
|
activeDuringSpawnPhase(): boolean {
|
|
return false;
|
|
}
|
|
|
|
init(mg: Game, ticks: number) {
|
|
this.mg = mg;
|
|
this.config = mg.config();
|
|
this.lastCalc =
|
|
ticks + (simpleHash(this.player.name()) % this.ticksPerClusterCalc);
|
|
}
|
|
|
|
tick(ticks: number) {
|
|
this.player.decayRelations();
|
|
for (const u of this.player.units()) {
|
|
if (!u.info().territoryBound) {
|
|
continue;
|
|
}
|
|
|
|
const owner = this.mg!.owner(u.tile());
|
|
if (!owner?.isPlayer()) {
|
|
u.delete();
|
|
continue;
|
|
}
|
|
if (owner === this.player) {
|
|
continue;
|
|
}
|
|
|
|
const captor = this.mg!.player(owner.id());
|
|
if (u.type() === UnitType.DefensePost) {
|
|
u.decreaseLevel(captor);
|
|
if (u.isActive()) {
|
|
captor.captureUnit(u);
|
|
}
|
|
} else {
|
|
captor.captureUnit(u);
|
|
}
|
|
}
|
|
|
|
if (!this.player.isAlive()) {
|
|
this.removeOnDeath();
|
|
this.active = false;
|
|
this.mg.stats().playerKilled(this.player, ticks);
|
|
return;
|
|
}
|
|
|
|
const troopInc = this.config.troopIncreaseRate(this.player);
|
|
this.player.addTroops(troopInc);
|
|
const goldFromWorkers = this.config.goldAdditionRate(this.player);
|
|
this.player.addGold(goldFromWorkers);
|
|
|
|
// Record stats
|
|
this.mg.stats().goldWork(this.player, goldFromWorkers);
|
|
|
|
const alliances = Array.from(this.player.alliances());
|
|
for (const alliance of alliances) {
|
|
if (alliance.expiresAt() <= this.mg.ticks()) {
|
|
alliance.expire();
|
|
}
|
|
}
|
|
|
|
const embargoes = this.player.getEmbargoes();
|
|
for (const embargo of embargoes) {
|
|
if (
|
|
embargo.isTemporary &&
|
|
this.mg.ticks() - embargo.createdAt >
|
|
this.mg.config().temporaryEmbargoDuration()
|
|
) {
|
|
this.player.stopEmbargo(embargo.target);
|
|
}
|
|
}
|
|
|
|
if (ticks - this.lastCalc > this.ticksPerClusterCalc) {
|
|
if (this.player.lastTileChange() > this.lastCalc) {
|
|
this.lastCalc = ticks;
|
|
const start = performance.now();
|
|
this.removeClusters();
|
|
const end = performance.now();
|
|
if (end - start > 1000) {
|
|
console.log(`player ${this.player.name()}, took ${end - start}ms`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private removeClusters() {
|
|
const clusters = this.calculateClusters();
|
|
|
|
if (clusters.length === 0) {
|
|
this.player.largestClusterBoundingBox = null;
|
|
return;
|
|
}
|
|
|
|
// Find the largest cluster with a single linear scan (O(n)).
|
|
let largestIndex = 0;
|
|
let largestSize = clusters[0].size;
|
|
for (let i = 1; i < clusters.length; i++) {
|
|
const size = clusters[i].size;
|
|
if (size > largestSize) {
|
|
largestSize = size;
|
|
largestIndex = i;
|
|
}
|
|
}
|
|
|
|
const largestCluster = clusters[largestIndex];
|
|
if (largestCluster === undefined) throw new Error("No clusters");
|
|
|
|
const largestClusterBox = calculateBoundingBox(this.mg, largestCluster);
|
|
this.player.largestClusterBoundingBox = largestClusterBox;
|
|
const surroundedBy = this.surroundedBySamePlayer(
|
|
largestCluster,
|
|
largestClusterBox,
|
|
);
|
|
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
|
|
this.removeCluster(largestCluster);
|
|
}
|
|
|
|
// Process remaining clusters
|
|
for (let i = 0; i < clusters.length; i++) {
|
|
if (i === largestIndex) continue;
|
|
const cluster = clusters[i];
|
|
if (this.isSurrounded(cluster)) {
|
|
this.removeCluster(cluster);
|
|
}
|
|
}
|
|
}
|
|
|
|
private surroundedBySamePlayer(
|
|
cluster: Set<TileRef>,
|
|
clusterBox: { min: Cell; max: Cell },
|
|
): false | Player {
|
|
const enemies = new Set<number>();
|
|
for (const tile of cluster) {
|
|
let hasUnownedNeighbor = false;
|
|
if (this.mg.isOceanShore(tile) || this.mg.isOnEdgeOfMap(tile)) {
|
|
return false;
|
|
}
|
|
this.mg.forEachNeighbor(tile, (n) => {
|
|
if (!this.mg.hasOwner(n)) {
|
|
hasUnownedNeighbor = true;
|
|
return;
|
|
}
|
|
const ownerId = this.mg.ownerID(n);
|
|
if (ownerId !== this.player.smallID()) {
|
|
enemies.add(ownerId);
|
|
}
|
|
});
|
|
if (hasUnownedNeighbor) {
|
|
return false;
|
|
}
|
|
if (enemies.size !== 1) {
|
|
return false;
|
|
}
|
|
}
|
|
if (enemies.size !== 1) {
|
|
return false;
|
|
}
|
|
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
|
|
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
|
|
if (inscribed(enemyBox, clusterBox)) {
|
|
return enemy;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private isSurrounded(cluster: Set<TileRef>): boolean {
|
|
let hasEnemy = false;
|
|
let minX = Infinity,
|
|
minY = Infinity,
|
|
maxX = -Infinity,
|
|
maxY = -Infinity;
|
|
for (const tr of cluster) {
|
|
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
|
|
return false;
|
|
}
|
|
this.mg.forEachNeighbor(tr, (n) => {
|
|
const owner = this.mg.owner(n);
|
|
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
|
|
hasEnemy = true;
|
|
const x = this.mg.x(n);
|
|
const y = this.mg.y(n);
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
});
|
|
}
|
|
if (!hasEnemy) {
|
|
return false;
|
|
}
|
|
const clusterBox = calculateBoundingBox(this.mg, cluster);
|
|
const enemyBox = { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
|
|
return inscribed(enemyBox, clusterBox);
|
|
}
|
|
|
|
private removeCluster(cluster: Set<TileRef>) {
|
|
for (const t of cluster) {
|
|
if (this.mg?.ownerID(t) !== this.player?.smallID()) {
|
|
// Other removeCluster operations could change tile owners,
|
|
// so double check.
|
|
return;
|
|
}
|
|
}
|
|
|
|
const capturing = this.getCapturingPlayer(cluster);
|
|
if (capturing === null) {
|
|
return;
|
|
}
|
|
|
|
const firstTile = cluster.values().next().value;
|
|
if (!firstTile) {
|
|
return;
|
|
}
|
|
|
|
const tiles = this.floodFillWithGen(
|
|
this.bumpGeneration(),
|
|
this.traversalState().visited,
|
|
[firstTile],
|
|
(tile, cb) => this.mg.forEachNeighbor(tile, cb),
|
|
(tile) => this.mg.ownerID(tile) === this.player.smallID(),
|
|
);
|
|
|
|
if (this.player.numTilesOwned() === tiles.size) {
|
|
this.mg.conquerPlayer(capturing, this.player);
|
|
}
|
|
|
|
for (const tile of tiles) {
|
|
capturing.conquer(tile);
|
|
}
|
|
}
|
|
|
|
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
|
|
const neighbors = new Map<Player, number>();
|
|
for (const t of cluster) {
|
|
this.mg.forEachNeighbor(t, (neighbor) => {
|
|
const owner = this.mg.owner(neighbor);
|
|
if (
|
|
owner.isPlayer() &&
|
|
owner !== this.player &&
|
|
!owner.isFriendly(this.player)
|
|
) {
|
|
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
|
|
}
|
|
});
|
|
}
|
|
|
|
// If there are no enemies, return null
|
|
if (neighbors.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Get the largest attack from the neighbors
|
|
let largestNeighborAttack: Player | null = null;
|
|
let largestTroopCount = 0;
|
|
for (const [neighbor] of neighbors) {
|
|
for (const attack of neighbor.outgoingAttacks()) {
|
|
if (attack.target() === this.player) {
|
|
if (attack.troops() > largestTroopCount) {
|
|
largestTroopCount = attack.troops();
|
|
largestNeighborAttack = neighbor;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (largestNeighborAttack !== null) {
|
|
return largestNeighborAttack;
|
|
}
|
|
|
|
// There are no ongoing attacks, so find the enemy with the largest border.
|
|
return getMode(neighbors);
|
|
}
|
|
|
|
private calculateClusters(): Set<TileRef>[] {
|
|
const borderTiles = this.player.borderTiles();
|
|
if (borderTiles.size === 0) return [];
|
|
|
|
const state = this.traversalState();
|
|
const currentGen = this.bumpGeneration();
|
|
const visited = state.visited;
|
|
|
|
const clusters: Set<TileRef>[] = [];
|
|
|
|
for (const startTile of borderTiles) {
|
|
if (visited[startTile] === currentGen) continue;
|
|
|
|
const cluster = this.floodFillWithGen(
|
|
currentGen,
|
|
visited,
|
|
[startTile],
|
|
(tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb),
|
|
(tile) => borderTiles.has(tile),
|
|
);
|
|
clusters.push(cluster);
|
|
}
|
|
return clusters;
|
|
}
|
|
|
|
owner(): Player {
|
|
if (this.player === null) {
|
|
throw new Error("Not initialized");
|
|
}
|
|
return this.player;
|
|
}
|
|
|
|
isActive(): boolean {
|
|
return this.active;
|
|
}
|
|
|
|
private traversalState(): ClusterTraversalState {
|
|
const totalTiles = this.mg.width() * this.mg.height();
|
|
let state = traversalStates.get(this.mg);
|
|
if (!state || state.visited.length < totalTiles) {
|
|
state = {
|
|
visited: new Uint32Array(totalTiles),
|
|
gen: 0,
|
|
};
|
|
traversalStates.set(this.mg, state);
|
|
}
|
|
return state;
|
|
}
|
|
|
|
private bumpGeneration(): number {
|
|
const state = this.traversalState();
|
|
state.gen++;
|
|
if (state.gen === 0xffffffff) {
|
|
state.visited.fill(0);
|
|
state.gen = 1;
|
|
}
|
|
return state.gen;
|
|
}
|
|
|
|
private floodFillWithGen(
|
|
currentGen: number,
|
|
visited: Uint32Array,
|
|
startTiles: TileRef[],
|
|
neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void,
|
|
includeFn: (tile: TileRef) => boolean,
|
|
): Set<TileRef> {
|
|
const result = new Set<TileRef>();
|
|
const stack: TileRef[] = [];
|
|
|
|
for (const start of startTiles) {
|
|
if (visited[start] === currentGen) continue;
|
|
if (!includeFn(start)) continue;
|
|
visited[start] = currentGen;
|
|
result.add(start);
|
|
stack.push(start);
|
|
}
|
|
|
|
while (stack.length > 0) {
|
|
const tile = stack.pop()!;
|
|
neighborFn(tile, (neighbor) => {
|
|
if (visited[neighbor] === currentGen) {
|
|
return;
|
|
}
|
|
if (!includeFn(neighbor)) {
|
|
return;
|
|
}
|
|
visited[neighbor] = currentGen;
|
|
result.add(neighbor);
|
|
stack.push(neighbor);
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private removeOnDeath(): void {
|
|
// Player (bot, human, nation) has no tiles
|
|
// Delete any remaining gold, non-nuke units and alliances
|
|
const gold = this.player.gold();
|
|
this.player.removeGold(gold);
|
|
|
|
this.player.units().forEach((u) => {
|
|
if (
|
|
u.type() !== UnitType.AtomBomb &&
|
|
u.type() !== UnitType.HydrogenBomb &&
|
|
u.type() !== UnitType.MIRVWarhead &&
|
|
u.type() !== UnitType.MIRV
|
|
) {
|
|
u.delete();
|
|
}
|
|
});
|
|
|
|
this.player.removeAllAlliances();
|
|
}
|
|
}
|