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-20 02:01:12 +01:00
committed by GitHub
parent c235debb57
commit f6a08e16db
6 changed files with 282 additions and 137 deletions
+42 -16
View File
@@ -139,6 +139,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
export class DefaultConfig implements Config {
private pastelTheme: PastelTheme = new PastelTheme();
private pastelThemeDark: PastelThemeDark = new PastelThemeDark();
private unitInfoCache = new Map<UnitType, UnitInfo>();
constructor(
private _serverConfig: ServerConfig,
private _gameConfig: GameConfig,
@@ -323,30 +324,40 @@ export class DefaultConfig implements Config {
}
unitInfo(type: UnitType): UnitInfo {
const cached = this.unitInfoCache.get(type);
if (cached !== undefined) {
return cached;
}
let info: UnitInfo;
switch (type) {
case UnitType.TransportShip:
return {
info = {
cost: () => 0n,
};
break;
case UnitType.Warship:
return {
info = {
cost: this.costWrapper(
(numUnits: number) => Math.min(1_000_000, (numUnits + 1) * 250_000),
UnitType.Warship,
),
maxHealth: 1000,
};
break;
case UnitType.Shell:
return {
info = {
cost: () => 0n,
damage: 250,
};
break;
case UnitType.SAMMissile:
return {
info = {
cost: () => 0n,
};
break;
case UnitType.Port:
return {
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
@@ -356,16 +367,19 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
break;
case UnitType.AtomBomb:
return {
info = {
cost: this.costWrapper(() => 750_000, UnitType.AtomBomb),
};
break;
case UnitType.HydrogenBomb:
return {
info = {
cost: this.costWrapper(() => 5_000_000, UnitType.HydrogenBomb),
};
break;
case UnitType.MIRV:
return {
info = {
cost: (game: Game, player: Player) => {
if (player.type() === PlayerType.Human && this.infiniteGold()) {
return 0n;
@@ -373,30 +387,35 @@ export class DefaultConfig implements Config {
return 25_000_000n + game.stats().numMirvsLaunched() * 15_000_000n;
},
};
break;
case UnitType.MIRVWarhead:
return {
info = {
cost: () => 0n,
};
break;
case UnitType.TradeShip:
return {
info = {
cost: () => 0n,
};
break;
case UnitType.MissileSilo:
return {
info = {
cost: this.costWrapper(() => 1_000_000, UnitType.MissileSilo),
constructionDuration: this.instantBuild() ? 0 : 10 * 10,
upgradable: true,
};
break;
case UnitType.DefensePost:
return {
info = {
cost: this.costWrapper(
(numUnits: number) => Math.min(250_000, (numUnits + 1) * 50_000),
UnitType.DefensePost,
),
constructionDuration: this.instantBuild() ? 0 : 5 * 10,
};
break;
case UnitType.SAMLauncher:
return {
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(3_000_000, (numUnits + 1) * 1_500_000),
@@ -405,8 +424,9 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 30 * 10,
upgradable: true,
};
break;
case UnitType.City:
return {
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
@@ -415,8 +435,9 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
break;
case UnitType.Factory:
return {
info = {
cost: this.costWrapper(
(numUnits: number) =>
Math.min(1_000_000, Math.pow(2, numUnits) * 125_000),
@@ -426,13 +447,18 @@ export class DefaultConfig implements Config {
constructionDuration: this.instantBuild() ? 0 : 2 * 10,
upgradable: true,
};
break;
case UnitType.Train:
return {
info = {
cost: () => 0n,
};
break;
default:
assertNever(type);
}
this.unitInfoCache.set(type, info);
return info;
}
private costWrapper(
+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() {
+49 -2
View File
@@ -214,11 +214,58 @@ export class PlayerImpl implements Player {
}
units(...types: UnitType[]): Unit[] {
if (types.length === 0) {
const len = types.length;
if (len === 0) {
return this._units;
}
// Fast paths for common small arity calls to avoid Set allocation.
if (len === 1) {
const t0 = types[0]!;
const out: Unit[] = [];
for (const u of this._units) {
if (u.type() === t0) out.push(u);
}
return out;
}
if (len === 2) {
const t0 = types[0]!;
const t1 = types[1]!;
if (t0 === t1) {
const out: Unit[] = [];
for (const u of this._units) {
if (u.type() === t0) out.push(u);
}
return out;
}
const out: Unit[] = [];
for (const u of this._units) {
const t = u.type();
if (t === t0 || t === t1) out.push(u);
}
return out;
}
if (len === 3) {
const t0 = types[0]!;
const t1 = types[1]!;
const t2 = types[2]!;
// Keep semantics identical for duplicates in types by using direct comparisons.
const out: Unit[] = [];
for (const u of this._units) {
const t = u.type();
if (t === t0 || t === t1 || t === t2) out.push(u);
}
return out;
}
const ts = new Set(types);
return this._units.filter((u) => ts.has(u.type()));
const out: Unit[] = [];
for (const u of this._units) {
if (ts.has(u.type())) out.push(u);
}
return out;
}
private numUnitsConstructed: Partial<Record<UnitType, number>> = {};
+50 -16
View File
@@ -140,29 +140,63 @@ export class UnitGrid {
includeUnderConstruction: boolean = false,
): Array<{ unit: Unit | UnitView; distSquared: number }> {
const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = [];
const gm = this.gm;
const x = gm.x(tile);
const y = gm.y(tile);
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
tile,
searchRange,
);
const rangeSquared = searchRange * searchRange;
const typeSet = Array.isArray(types) ? new Set(types) : new Set([types]);
// `Array.isArray` does not reliably narrow `readonly T[]` in TS, so use a
// cheap runtime check that narrows correctly for our string-backed UnitType.
if (typeof types !== "string") {
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
const cell = this.grid[cy][cx];
for (const type of types) {
const unitSet = cell.get(type);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (!unit.isActive()) continue;
// Exclude units under construction by default (e.g., defense posts being built)
// But include them for spacing checks
if (!includeUnderConstruction && unit.isUnderConstruction())
continue;
const unitTile = unit.tile();
const dx = gm.x(unitTile) - x;
const dy = gm.y(unitTile) - y;
const distSquared = dx * dx + dy * dy;
if (distSquared > rangeSquared) continue;
const value = { unit, distSquared };
if (predicate !== undefined && !predicate(value)) continue;
nearby.push(value);
}
}
}
}
return nearby;
}
const type = types;
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
for (const type of typeSet) {
const unitSet = this.grid[cy][cx].get(type);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (!unit.isActive()) continue;
// Exclude units under construction by default (e.g., defense posts being built)
// But include them for spacing checks
if (!includeUnderConstruction && unit.isUnderConstruction())
continue;
const distSquared = this.squaredDistanceFromTile(unit, tile);
if (distSquared > rangeSquared) continue;
const value = { unit, distSquared };
if (predicate !== undefined && !predicate(value)) continue;
nearby.push(value);
}
const unitSet = this.grid[cy][cx].get(type);
if (unitSet === undefined) continue;
for (const unit of unitSet) {
if (!unit.isActive()) continue;
// Exclude units under construction by default (e.g., defense posts being built)
// But include them for spacing checks
if (!includeUnderConstruction && unit.isUnderConstruction()) continue;
const unitTile = unit.tile();
const dx = gm.x(unitTile) - x;
const dy = gm.y(unitTile) - y;
const distSquared = dx * dx + dy * dy;
if (distSquared > rangeSquared) continue;
const value = { unit, distSquared };
if (predicate !== undefined && !predicate(value)) continue;
nearby.push(value);
}
}
}