mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 03:53:49 +00:00
6952550014
## Description: Fixes https://github.com/openfrontio/OpenFrontIO/issues/1021 Fixes issue that has been there since the beginning. Player name and location and conquest FX (swords) not being in the right place. It can happen at any time during a game and can be game-breaking in that regard. This makes it hard to find players, especially when trying to eliminate their last few tiles on some island. So when clicking name in leaderboard > wrong tiles. And when seeing name > above wrong tiles. Bug report: https://discord.com/channels/1284581928254701718/1444669324571967680 Also, when removing those last tiles, the wait time between updates of player location can make it frustrating to find and eliminate them fast. You need 2-3 clicks on their name in leaderboard, before finally being moved to their current location. **Cause:** largestClusterBoundingBox not being changed when last attack happened in same tick removeClusters last ran. **Fix:** Also call removeClusters, and therefore update largestClusterBoundingBox , when LastTileChange was AT lastCalc tick. **Also:** Run removeClusters if player owns less than 100 tiles, don't wait for ticksPerClusterCalc in that case. This way, sniping off the last couple of island tiles of the player is easier. So it doesn't take 2-3 clicks bbut just 1 click on the player name in the Leaderboard before the camera moves to the next little island they are on. Also their last clusters are annexed faster, only helping with the faster cleanup. I think this is an optional to the fix in this PR, but still an important QoL fix for sniping those last tiles quickly. **BEFORE:** https://github.com/user-attachments/assets/0960a4d3-7f8b-4368-9531-8244356bff17 **AFTER:** (also notice how it now just takes 1 click in the leaderboard to immediately go to their next location, not 2-3 clicks) https://youtu.be/qXJPekjsrP4 ## 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
436 lines
11 KiB
TypeScript
436 lines
11 KiB
TypeScript
import { Config } from "../configuration/Config";
|
|
import {
|
|
Cell,
|
|
Execution,
|
|
Game,
|
|
Player,
|
|
Structures,
|
|
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 (!Structures.has(u.type())) {
|
|
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);
|
|
|
|
for (const alliance of this.player.alliances()) {
|
|
if (alliance.expiresAt() <= this.mg.ticks()) {
|
|
alliance.expire();
|
|
}
|
|
}
|
|
|
|
for (const embargo of this.player.getEmbargoes()) {
|
|
if (
|
|
embargo.isTemporary &&
|
|
this.mg.ticks() - embargo.createdAt >
|
|
this.mg.config().temporaryEmbargoDuration()
|
|
) {
|
|
this.player.stopEmbargo(embargo.target);
|
|
}
|
|
}
|
|
|
|
if (
|
|
ticks - this.lastCalc > this.ticksPerClusterCalc ||
|
|
this.player.numTilesOwned() < 100
|
|
) {
|
|
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>();
|
|
|
|
let minX = Infinity,
|
|
minY = Infinity,
|
|
maxX = -Infinity,
|
|
maxY = -Infinity;
|
|
|
|
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);
|
|
const px = this.mg.x(n);
|
|
const py = this.mg.y(n);
|
|
minX = Math.min(minX, px);
|
|
minY = Math.min(minY, py);
|
|
maxX = Math.max(maxX, px);
|
|
maxY = Math.max(maxY, py);
|
|
}
|
|
});
|
|
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 localEnemyBox = {
|
|
min: new Cell(minX, minY),
|
|
max: new Cell(maxX, maxY),
|
|
};
|
|
if (inscribed(localEnemyBox, 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();
|
|
}
|
|
}
|