Perf clusters (#3127)

## Description:

This PR reduces server/client tick CPU spent on territory cluster
maintenance by:
- Cutting redundant work in `PlayerExecution.removeClusters()`
(largest-cluster bounding box reuse, fewer allocations in
`isSurrounded()` and `removeCluster()`).
- Making `calculateBoundingBox()` allocation-free per tile by switching
from `gm.cell(tile)` to `gm.x(tile)`/`gm.y(tile)` (it now only allocates
the two result `Cell`s, instead of 1 per tile).

## Commits
- `51de0a1b` core: reduce PlayerExecution cluster overhead
- `6d9d85c5` core: avoid Cell allocations in PlayerExecution
isSurrounded
- `346f6a8c` core(util): speed up calculateBoundingBox by avoiding Cell
allocations


## Notes
- This PR is intended to be behavior-preserving; changes are limited to
hot-path micro-optimizations.
- Follow-up opportunity: `calculateClusters`/flood-fill is now the top
hotspot; further wins likely come from reducing traversal work or
caching.


## Please complete the following:

- [ ] I have added screenshots for all UI updates
- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [ ] I have added relevant tests to the test directory
- [ ] 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:

DISCORD_USERNAME
This commit is contained in:
scamiv
2026-02-05 19:07:17 +01:00
committed by GitHub
parent d40923fc18
commit b68de96c6e
2 changed files with 37 additions and 25 deletions
+8 -7
View File
@@ -80,13 +80,14 @@ export function calculateBoundingBox(
maxX = -Infinity,
maxY = -Infinity;
borderTiles.forEach((tile: TileRef) => {
const cell = gm.cell(tile);
minX = Math.min(minX, cell.x);
minY = Math.min(minY, cell.y);
maxX = Math.max(maxX, cell.x);
maxY = Math.max(maxY, cell.y);
});
for (const tile of borderTiles) {
const x = gm.x(tile);
const y = gm.y(tile);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
}
+29 -18
View File
@@ -1,5 +1,5 @@
import { Config } from "../configuration/Config";
import { Execution, Game, Player, UnitType } from "../game/Game";
import { Cell, Execution, Game, Player, UnitType } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -139,11 +139,12 @@ export class PlayerExecution implements Execution {
const largestCluster = clusters[largestIndex];
if (largestCluster === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox(
this.mg,
const largestClusterBox = calculateBoundingBox(this.mg, largestCluster);
this.player.largestClusterBoundingBox = largestClusterBox;
const surroundedBy = this.surroundedBySamePlayer(
largestCluster,
largestClusterBox,
);
const surroundedBy = this.surroundedBySamePlayer(largestCluster);
if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(largestCluster);
}
@@ -158,7 +159,10 @@ export class PlayerExecution implements Execution {
}
}
private surroundedBySamePlayer(cluster: Set<TileRef>): false | Player {
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;
@@ -187,7 +191,6 @@ export class PlayerExecution implements Execution {
}
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
const clusterBox = calculateBoundingBox(this.mg, cluster);
if (inscribed(enemyBox, clusterBox)) {
return enemy;
}
@@ -195,7 +198,11 @@ export class PlayerExecution implements Execution {
}
private isSurrounded(cluster: Set<TileRef>): boolean {
const enemyTiles = new Set<TileRef>();
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;
@@ -203,27 +210,31 @@ export class PlayerExecution implements Execution {
this.mg.forEachNeighbor(tr, (n) => {
const owner = this.mg.owner(n);
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) {
enemyTiles.add(n);
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 (enemyTiles.size === 0) {
if (!hasEnemy) {
return false;
}
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
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>) {
if (
Array.from(cluster).some(
(t) => this.mg?.ownerID(t) !== this.player?.smallID(),
)
) {
// Other removeCluster operations could change tile owners,
// so double check.
return;
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);