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 10:07:17 -08: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, maxX = -Infinity,
maxY = -Infinity; maxY = -Infinity;
borderTiles.forEach((tile: TileRef) => { for (const tile of borderTiles) {
const cell = gm.cell(tile); const x = gm.x(tile);
minX = Math.min(minX, cell.x); const y = gm.y(tile);
minY = Math.min(minY, cell.y); minX = Math.min(minX, x);
maxX = Math.max(maxX, cell.x); minY = Math.min(minY, y);
maxY = Math.max(maxY, cell.y); maxX = Math.max(maxX, x);
}); maxY = Math.max(maxY, y);
}
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) }; 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 { 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 { TileRef } from "../game/GameMap";
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
@@ -139,11 +139,12 @@ export class PlayerExecution implements Execution {
const largestCluster = clusters[largestIndex]; const largestCluster = clusters[largestIndex];
if (largestCluster === undefined) throw new Error("No clusters"); if (largestCluster === undefined) throw new Error("No clusters");
this.player.largestClusterBoundingBox = calculateBoundingBox( const largestClusterBox = calculateBoundingBox(this.mg, largestCluster);
this.mg, this.player.largestClusterBoundingBox = largestClusterBox;
const surroundedBy = this.surroundedBySamePlayer(
largestCluster, largestCluster,
largestClusterBox,
); );
const surroundedBy = this.surroundedBySamePlayer(largestCluster);
if (surroundedBy && !surroundedBy.isFriendly(this.player)) { if (surroundedBy && !surroundedBy.isFriendly(this.player)) {
this.removeCluster(largestCluster); 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>(); const enemies = new Set<number>();
for (const tile of cluster) { for (const tile of cluster) {
let hasUnownedNeighbor = false; let hasUnownedNeighbor = false;
@@ -187,7 +191,6 @@ export class PlayerExecution implements Execution {
} }
const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player; const enemy = this.mg.playerBySmallID(Array.from(enemies)[0]) as Player;
const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles()); const enemyBox = calculateBoundingBox(this.mg, enemy.borderTiles());
const clusterBox = calculateBoundingBox(this.mg, cluster);
if (inscribed(enemyBox, clusterBox)) { if (inscribed(enemyBox, clusterBox)) {
return enemy; return enemy;
} }
@@ -195,7 +198,11 @@ export class PlayerExecution implements Execution {
} }
private isSurrounded(cluster: Set<TileRef>): boolean { 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) { for (const tr of cluster) {
if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) { if (this.mg.isShore(tr) || this.mg.isOnEdgeOfMap(tr)) {
return false; return false;
@@ -203,27 +210,31 @@ export class PlayerExecution implements Execution {
this.mg.forEachNeighbor(tr, (n) => { this.mg.forEachNeighbor(tr, (n) => {
const owner = this.mg.owner(n); const owner = this.mg.owner(n);
if (owner.isPlayer() && this.mg.ownerID(n) !== this.player.smallID()) { 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; return false;
} }
const enemyBox = calculateBoundingBox(this.mg, enemyTiles);
const clusterBox = calculateBoundingBox(this.mg, cluster); const clusterBox = calculateBoundingBox(this.mg, cluster);
const enemyBox = { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
return inscribed(enemyBox, clusterBox); return inscribed(enemyBox, clusterBox);
} }
private removeCluster(cluster: Set<TileRef>) { private removeCluster(cluster: Set<TileRef>) {
if ( for (const t of cluster) {
Array.from(cluster).some( if (this.mg?.ownerID(t) !== this.player?.smallID()) {
(t) => this.mg?.ownerID(t) !== this.player?.smallID(), // Other removeCluster operations could change tile owners,
) // so double check.
) { return;
// Other removeCluster operations could change tile owners, }
// so double check.
return;
} }
const capturing = this.getCapturingPlayer(cluster); const capturing = this.getCapturingPlayer(cluster);