From b68de96c6e91d2f5f77b9b5e8a37e69b11c360d0 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:07:17 +0100 Subject: [PATCH] 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 --- src/core/Util.ts | 15 +++++---- src/core/execution/PlayerExecution.ts | 47 +++++++++++++++++---------- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/core/Util.ts b/src/core/Util.ts index 330359021..a79dda1a8 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -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) }; } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index c7e452e5d..e1d80d0b4 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -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): false | Player { + private surroundedBySamePlayer( + cluster: Set, + clusterBox: { min: Cell; max: Cell }, + ): false | Player { const enemies = new Set(); 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): boolean { - const enemyTiles = new Set(); + 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) { - 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);