Perf alloc (#3241)

If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #(issue number)

## Description:

## PR Title
perf(core): reduce hot-path allocations & safe optimizations

This PR brings in a set of allocation-focused optimizations in core hot
paths

### Scope
- `src/core/execution/NukeExecution.ts`
- `src/core/execution/WarshipExecution.ts`
- `src/core/game/UnitGrid.ts`
- `src/core/game/PlayerImpl.ts`
- `src/core/configuration/DefaultConfig.ts`
- `src/core/execution/SAMLauncherExecution.ts`

### What Changed
- `NukeExecution.detonate`: reduced call overhead/allocations by caching
`mg`/`config`, avoiding repeated lookups, and using allocation-free
loops (no `forEach` closures) in the diminishing-effect pass.
- `WarshipExecution.findTargetUnit`: replaced allocate+sort flow with
single-pass best-target selection.
- `UnitGrid.nearbyUnits`: reduced call overhead and allocations via
single-type fast path and cached query coordinates.
- `PlayerImpl.units`: added fast paths for common small-arity type
queries (1-3 unit types).
- `DefaultConfig.unitInfo`: cached `UnitInfo` objects per `UnitType` to
avoid repeated object/closure creation.
- `SAMLauncherExecution` targeting: removed sort churn and streamlined
target selection with single-pass hydrogen prioritization.



### Rebase
- One conflict was resolved in `NukeExecution.detonate` by keeping
`main`'s diminishing-effect-per-impacted-tile behavior, while retaining
the allocation-reduction refactors.


## 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-19 19:01:12 -06:00
committed by GitHub
parent c235debb57
commit f6a08e16db
6 changed files with 282 additions and 137 deletions
+33 -24
View File
@@ -252,28 +252,31 @@ export class NukeExecution implements Execution {
throw new Error("Not initialized");
}
const magnitude = this.mg.config().nukeMagnitudes(this.nuke.type());
const mg = this.mg;
const config = mg.config();
const magnitude = config.nukeMagnitudes(this.nuke.type());
const toDestroy = this.tilesToDestroy();
// Retrieve all impacted players and the number of tiles
const tilesPerPlayers = new Map<Player, number>();
for (const tile of toDestroy) {
const owner = this.mg.owner(tile);
const owner = mg.owner(tile);
if (owner.isPlayer()) {
owner.relinquish(tile);
const numTiles = tilesPerPlayers.get(owner);
tilesPerPlayers.set(owner, numTiles === undefined ? 1 : numTiles + 1);
tilesPerPlayers.set(owner, (tilesPerPlayers.get(owner) ?? 0) + 1);
}
if (this.mg.isLand(tile)) {
this.mg.setFallout(tile, true);
if (mg.isLand(tile)) {
mg.setFallout(tile, true);
}
}
// Then compute the explosion effect on each player
for (const [player, numImpactedTiles] of tilesPerPlayers) {
const config = this.mg.config();
const tilesBeforeNuke = player.numTilesOwned() + numImpactedTiles;
const transportShips = player.units(UnitType.TransportShip);
const outgoingAttacks = player.outgoingAttacks();
const maxTroops = config.maxTroops(player);
// nukeDeathFactor could compute the complete fallout in a single call instead
for (let i = 0; i < numImpactedTiles; i++) {
@@ -287,39 +290,45 @@ export class NukeExecution implements Execution {
maxTroops,
),
);
player.outgoingAttacks().forEach((attack) => {
for (const attack of outgoingAttacks) {
const attackTroops = attack.troops();
const deaths = config.nukeDeathFactor(
this.nukeType,
attack.troops(),
attackTroops,
numTilesLeft,
maxTroops,
);
attack.setTroops(attack.troops() - deaths);
});
transportShips.forEach((unit) => {
attack.setTroops(attackTroops - deaths);
}
for (const unit of transportShips) {
const unitTroops = unit.troops();
const deaths = config.nukeDeathFactor(
this.nukeType,
unit.troops(),
unitTroops,
numTilesLeft,
maxTroops,
);
unit.setTroops(unit.troops() - deaths);
});
unit.setTroops(unitTroops - deaths);
}
}
}
const outer2 = magnitude.outer * magnitude.outer;
for (const unit of this.mg.units()) {
const dst = this.dst;
const destroyer = this.player;
for (const unit of mg.units()) {
const type = unit.type();
if (
unit.type() !== UnitType.AtomBomb &&
unit.type() !== UnitType.HydrogenBomb &&
unit.type() !== UnitType.MIRVWarhead &&
unit.type() !== UnitType.MIRV &&
unit.type() !== UnitType.SAMMissile
type === UnitType.AtomBomb ||
type === UnitType.HydrogenBomb ||
type === UnitType.MIRVWarhead ||
type === UnitType.MIRV ||
type === UnitType.SAMMissile
) {
if (this.mg.euclideanDistSquared(this.dst, unit.tile()) < outer2) {
unit.delete(true, this.player);
}
continue;
}
if (mg.euclideanDistSquared(dst, unit.tile()) < outer2) {
unit.delete(true, destroyer);
}
}
+63 -33
View File
@@ -40,7 +40,31 @@ class SAMTargetingSystem {
}
updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) {
const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id()));
if (this.precomputedNukes.size === 0) {
return;
}
// Avoid per-tick allocations for the common case where only a few nukes are tracked.
if (this.precomputedNukes.size <= 16) {
for (const nukeId of this.precomputedNukes.keys()) {
let found = false;
for (const u of nearbyUnits) {
if (u.unit.id() === nukeId) {
found = true;
break;
}
}
if (!found) {
this.precomputedNukes.delete(nukeId);
}
}
return;
}
const nearbyUnitSet = new Set<number>();
for (const u of nearbyUnits) {
nearbyUnitSet.add(u.unit.id());
}
for (const nukeId of this.precomputedNukes.keys()) {
if (!nearbyUnitSet.has(nukeId)) {
this.precomputedNukes.delete(nukeId);
@@ -48,27 +72,27 @@ class SAMTargetingSystem {
}
}
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const range = this.mg.config().samRange(this.sam.level());
const rangeSquared = range * range;
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
}
private tickToReach(currentTile: TileRef, tile: TileRef): number {
return Math.ceil(
this.mg.manhattanDist(currentTile, tile) / this.missileSpeed,
);
}
private computeInterceptionTile(unit: Unit): InterceptionTile | undefined {
private computeInterceptionTile(
unit: Unit,
samTile: TileRef,
rangeSquared: number,
): InterceptionTile | undefined {
const trajectory = unit.trajectory();
const samTile = this.sam.tile();
const currentIndex = unit.trajectoryIndex();
const explosionTick: number = trajectory.length - currentIndex;
for (let i = currentIndex; i < trajectory.length; i++) {
const trajectoryTile = trajectory[i];
if (trajectoryTile.targetable && this.isInRange(trajectoryTile.tile)) {
if (
trajectoryTile.targetable &&
this.mg.euclideanDistSquared(samTile, trajectoryTile.tile) <=
rangeSquared
) {
const nukeTickToReach = i - currentIndex;
const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile);
const tickBeforeShooting = nukeTickToReach - samTickToReach;
@@ -81,10 +105,14 @@ class SAMTargetingSystem {
}
public getSingleTarget(ticks: number): Target | null {
const samTile = this.sam.tile();
const range = this.mg.config().samRange(this.sam.level());
const rangeSquared = range * range;
// Look beyond the SAM range so it can preshot nukes
const detectionRange = this.mg.config().maxSamRange() * 2;
const nukes = this.mg.nearbyUnits(
this.sam.tile(),
samTile,
detectionRange,
[UnitType.AtomBomb, UnitType.HydrogenBomb],
({ unit }) => {
@@ -100,7 +128,7 @@ class SAMTargetingSystem {
// Clear unreachable nukes that went out of range
this.updateUnreachableNukes(nukes);
const targets: Array<Target> = [];
let best: Target | null = null;
for (const nuke of nukes) {
const nukeId = nuke.unit.id();
const cached = this.precomputedNukes.get(nukeId);
@@ -111,7 +139,14 @@ class SAMTargetingSystem {
}
if (cached.tick === ticks) {
// Time to shoot!
targets.push({ tile: cached.tile, unit: nuke.unit });
const target = { tile: cached.tile, unit: nuke.unit };
if (
best === null ||
(target.unit.type() === UnitType.HydrogenBomb &&
best.unit.type() !== UnitType.HydrogenBomb)
) {
best = target;
}
this.precomputedNukes.delete(nukeId);
continue;
}
@@ -122,12 +157,23 @@ class SAMTargetingSystem {
// Missed the planned tick (e.g was on cooldown), recompute a new interception tile if possible
this.precomputedNukes.delete(nukeId);
}
const interceptionTile = this.computeInterceptionTile(nuke.unit);
const interceptionTile = this.computeInterceptionTile(
nuke.unit,
samTile,
rangeSquared,
);
if (interceptionTile !== undefined) {
if (interceptionTile.tick <= 1) {
// Shoot instantly
targets.push({ unit: nuke.unit, tile: interceptionTile.tile });
const target = { unit: nuke.unit, tile: interceptionTile.tile };
if (
best === null ||
(target.unit.type() === UnitType.HydrogenBomb &&
best.unit.type() !== UnitType.HydrogenBomb)
) {
best = target;
}
} else {
// Nuke will be reachable but not yet. Store the result.
this.precomputedNukes.set(nukeId, {
@@ -141,23 +187,7 @@ class SAMTargetingSystem {
}
}
return (
targets.sort((a: Target, b: Target) => {
// Prioritize Hydrogen Bombs
if (
a.unit.type() === UnitType.HydrogenBomb &&
b.unit.type() !== UnitType.HydrogenBomb
)
return -1;
if (
a.unit.type() !== UnitType.HydrogenBomb &&
b.unit.type() === UnitType.HydrogenBomb
)
return 1;
return 0;
})[0] ?? null
);
return best;
}
}
+45 -46
View File
@@ -76,78 +76,77 @@ export class WarshipExecution implements Execution {
}
private findTargetUnit(): Unit | undefined {
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
const patrolRangeSquared = this.mg.config().warshipPatrolRange() ** 2;
const mg = this.mg;
const config = mg.config();
const owner = this.warship.owner();
const hasPort = owner.unitCount(UnitType.Port) > 0;
const patrolTile = this.warship.patrolTile()!;
const patrolRangeSquared = config.warshipPatrolRange() ** 2;
const ships = this.mg.nearbyUnits(
const ships = mg.nearbyUnits(
this.warship.tile()!,
this.mg.config().warshipTargettingRange(),
config.warshipTargettingRange(),
[UnitType.TransportShip, UnitType.Warship, UnitType.TradeShip],
);
const potentialTargets: { unit: Unit; distSquared: number }[] = [];
let bestUnit: Unit | undefined = undefined;
let bestTypePriority = 0;
let bestDistSquared = 0;
for (const { unit, distSquared } of ships) {
if (
unit.owner() === this.warship.owner() ||
unit.owner() === owner ||
unit === this.warship ||
!this.warship.owner().canAttackPlayer(unit.owner(), true) ||
!owner.canAttackPlayer(unit.owner(), true) ||
this.alreadySentShell.has(unit)
) {
continue;
}
if (unit.type() === UnitType.TradeShip) {
const type = unit.type();
if (type === UnitType.TradeShip) {
if (
!hasPort ||
unit.isSafeFromPirates() ||
unit.targetUnit()?.owner() === this.warship.owner() || // trade ship is coming to my port
unit.targetUnit()?.owner().isFriendly(this.warship.owner()) // trade ship is coming to my ally
unit.targetUnit()?.owner() === owner || // trade ship is coming to my port
unit.targetUnit()?.owner().isFriendly(owner) // trade ship is coming to my ally
) {
continue;
}
if (
this.mg.euclideanDistSquared(
this.warship.patrolTile()!,
unit.tile(),
) > patrolRangeSquared
mg.euclideanDistSquared(patrolTile, unit.tile()) > patrolRangeSquared
) {
// Prevent warship from chasing trade ship that is too far away from
// the patrol tile to prevent warships from wandering around the map.
continue;
}
}
potentialTargets.push({ unit: unit, distSquared });
const typePriority =
type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2;
if (bestUnit === undefined) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
continue;
}
// Match existing `sort()` semantics:
// - Lower priority is better (TransportShip < Warship < TradeShip).
// - For same type, smaller distance is better.
// - For exact ties, keep the first encountered (stable sort behavior).
if (
typePriority < bestTypePriority ||
(typePriority === bestTypePriority && distSquared < bestDistSquared)
) {
bestUnit = unit;
bestTypePriority = typePriority;
bestDistSquared = distSquared;
}
}
return potentialTargets.sort((a, b) => {
const { unit: unitA, distSquared: distA } = a;
const { unit: unitB, distSquared: distB } = b;
// Prioritize Transport Ships above all other units
if (
unitA.type() === UnitType.TransportShip &&
unitB.type() !== UnitType.TransportShip
)
return -1;
if (
unitA.type() !== UnitType.TransportShip &&
unitB.type() === UnitType.TransportShip
)
return 1;
// Then prioritize Warships.
if (
unitA.type() === UnitType.Warship &&
unitB.type() !== UnitType.Warship
)
return -1;
if (
unitA.type() !== UnitType.Warship &&
unitB.type() === UnitType.Warship
)
return 1;
// If both are the same type, sort by distance (lower `distSquared` means closer)
return distA - distB;
})[0]?.unit;
return bestUnit;
}
private shootTarget() {