Files
OpenFrontIO/src/core/execution/PlayerExecution.ts
T
VariableVince 6952550014 Fix: player name and location on wrong spot on the map (#3455)
## 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
2026-03-17 14:08:52 -07:00

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();
}
}