Update MIRV target selection algorithm (#2765)

## 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
This commit is contained in:
Arkadiusz Sygulski
2026-01-02 21:48:08 +01:00
committed by GitHub
parent 6887ae598f
commit 4877e202f6
+53 -38
View File
@@ -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;
}