From 4877e202f686b54904a7e68fd8f3adc2324cb784 Mon Sep 17 00:00:00 2001 From: Arkadiusz Sygulski Date: Fri, 2 Jan 2026 21:48:08 +0100 Subject: [PATCH] Update MIRV target selection algorithm (#2765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: `MIRVExecution.separate` is consuming more resources than it needs to. This PR introduces a series of minor performance changes which do not alter the behavior of the selection algorithm. Assessing the code initially, I was convinced there are multiple easy wins - starting with the proximity check. However, after many hours of mostly math, no alternative solution came close to the speed of current implementation. Therefore this PR consists of only a few minor tweaks: #### Removed `console.log` This is by far the worst offender, in my test removing the three lines of console log improved execution time from ~30ms down to ~10ms. The logs are not very useful. I do not see a clear pattern in the logs produced by the application therefore they were completely removed for now. If there is a need for the log in production build, I suggest adding single line with the number of destinations selected. ```diff - console.log(`dsts: ${dsts.length}`); - console.log(`got ${dsts.length} dsts!!`); - console.log("couldn't find place, giving up"); ``` #### Replaced multiple calls to `random.nextInt` with single call to `random.next` The flamechart shows calling pseudo random number generator is expensive. Therefore instead of calling it twice, the code now generates a single random number and derives further calculations from it. It remains 100% deterministic and there should not be any noticeable change to the enthrophy. This saves about ~1.5ms in my tests. ```diff - const x = this.random.nextInt( - this.mg.x(ref) - this.mirvRange, - this.mg.x(ref) + this.mirvRange, - ); - const y = this.random.nextInt( - this.mg.y(ref) - this.mirvRange, - this.mg.y(ref) + this.mirvRange, - ); + const r1 = this.random.next(); + const r2 = (r1 * 15485863) % 1; + const x = Math.round(r1 * this.range * 2 - this.range + this.baseX); + const y = Math.round(r2 * this.range * 2 - this.range + this.baseY); ``` #### Caching of destination coordinates Since the target tile coordinates are used a lot, instead of retrieving them every time with `this.mg.x` and `this.mg.y`, they get cached as `baseX` and `baseY`. To reduce usage further, I also exposed `x` and `y` to `isOverlapping` / `proximityCheck` directly instead of passing the tile. Since available methods operate on `TileRef`, this change requires the calculations - manhattan and euclidean distance - to be inlined. I do not think this is a big issue, considering this code is responsible for very specific task. This saves another ~1.5ms in my tests. ```diff - if (this.mg.euclideanDistSquared(tile, ref) > mirvRange2) { + if ((x - this.baseX) ** 2 + (y - this.baseY) ** 2 > this.rangeSquared) { ``` ## Benchmark: **Before** ``` === MIRV Performance Benchmark Results === MIRV target selection - sparse territory x 53.53 ops/sec ±0.48% (71 runs sampled) MIRV target selection - dense territory x 53.39 ops/sec ±0.57% (70 runs sampled) MIRV target selection - giant world map (350 targets) x 1,129 ops/sec ±0.98% (90 runs sampled) ``` **After** ``` === MIRV Performance Benchmark Results === MIRV target selection - sparse territory x 198 ops/sec ±0.39% (85 runs sampled) MIRV target selection - dense territory x 200 ops/sec ±0.28% (86 runs sampled) MIRV target selection - giant world map (350 targets) x 1,409 ops/sec ±0.89% (92 runs sampled) ``` ## 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 - [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 \* Tests in separate PR, implemented against master: https://github.com/openfrontio/OpenFrontIO/pull/2767 ## Please put your Discord username so you can be contacted if a bug or regression is found: moleole --- src/core/execution/MIRVExecution.ts | 91 +++++++++++++++++------------ 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/src/core/execution/MIRVExecution.ts b/src/core/execution/MIRVExecution.ts index b368a7b4a..e17b3ceeb 100644 --- a/src/core/execution/MIRVExecution.ts +++ b/src/core/execution/MIRVExecution.ts @@ -20,9 +20,14 @@ export class MirvExecution implements Execution { private nuke: Unit | null = null; - private mirvRange = 1500; + private range = 1500; + private rangeSquared = this.range * this.range; + private minimumSpread = 55; private warheadCount = 350; + private baseX: number; + private baseY: number; + private random: PseudoRandom; private pathFinder: ParabolaPathFinder; @@ -98,25 +103,15 @@ export class MirvExecution implements Execution { } private separate() { - if (this.nuke === null) throw new Error("uninitialized"); - const dsts: TileRef[] = [this.dst]; - let attempts = 1000; - while (attempts > 0 && dsts.length < this.warheadCount) { - attempts--; - const potential = this.randomLand(this.dst, dsts); - if (potential === null) { - continue; - } - dsts.push(potential); + if (this.nuke === null) { + throw new Error("uninitialized"); } - console.log(`dsts: ${dsts.length}`); - dsts.sort( - (a, b) => - this.mg.manhattanDist(b, this.dst) - this.mg.manhattanDist(a, this.dst), - ); - console.log(`got ${dsts.length} dsts!!`); - for (const [i, dst] of dsts.entries()) { + this.baseX = this.mg.x(this.dst); + this.baseY = this.mg.y(this.dst); + + const destinations = this.selectDestinations(); + for (const [i, dst] of destinations.entries()) { this.mg.addExecution( new NukeExecution( UnitType.MIRVWarhead, @@ -132,47 +127,67 @@ export class MirvExecution implements Execution { this.nuke.delete(false); } - randomLand(ref: TileRef, taken: TileRef[]): TileRef | null { - let tries = 0; - const mirvRange2 = this.mirvRange * this.mirvRange; - while (tries < 100) { - tries++; - const x = this.random.nextInt( - this.mg.x(ref) - this.mirvRange, - this.mg.x(ref) + this.mirvRange, - ); - const y = this.random.nextInt( - this.mg.y(ref) - this.mirvRange, - this.mg.y(ref) + this.mirvRange, - ); + private selectDestinations(): TileRef[] { + const targets: TileRef[] = [this.dst]; + + for (let attempt = 0; attempt < 1000; attempt++) { + const target = this.tryGenerateTarget(targets); + if (target) targets.push(target); + if (targets.length >= this.warheadCount) break; + } + + return targets.sort( + (a, b) => + this.mg.manhattanDist(b, this.dst) - this.mg.manhattanDist(a, this.dst), + ); + } + + private tryGenerateTarget(taken: TileRef[]): TileRef | undefined { + for (let attempt = 0; attempt < 100; attempt++) { + const r1 = this.random.next(); + const r2 = (r1 * 15485863) % 1; + + const x = Math.round(r1 * this.range * 2 - this.range + this.baseX); + const y = Math.round(r2 * this.range * 2 - this.range + this.baseY); + if (!this.mg.isValidCoord(x, y)) { continue; } + const tile = this.mg.ref(x, y); + if (!this.mg.isLand(tile)) { continue; } - if (this.mg.euclideanDistSquared(tile, ref) > mirvRange2) { + + if ((x - this.baseX) ** 2 + (y - this.baseY) ** 2 > this.rangeSquared) { continue; } + if (this.mg.owner(tile) !== this.targetPlayer) { continue; } - if (this.proximityCheck(tile, taken)) { + + if (this.isOverlapping(x, y, taken)) { continue; } + return tile; } - console.log("couldn't find place, giving up"); - return null; } - private proximityCheck(tile: TileRef, taken: TileRef[]): boolean { - for (const t of taken) { - if (this.mg.manhattanDist(tile, t) < 55) { + private isOverlapping(x: number, y: number, taken: TileRef[]): boolean { + for (const existingTile of taken) { + const existingTileX = this.mg.x(existingTile); + const existingTileY = this.mg.y(existingTile); + const manhattanDistance = + Math.abs(x - existingTileX) + Math.abs(y - existingTileY); + + if (manhattanDistance < this.minimumSpread) { return true; } } + return false; }