diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index f60159d644..b4ce99715b 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -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(); 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( diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index b72890da90..fc1743f26a 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -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(); 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); } } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index d7d55aafa6..2e1682abb7 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -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(); + 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 = []; + 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; } } diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 6a0845d649..189ad99247 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -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 }); - } - return potentialTargets.sort((a, b) => { - const { unit: unitA, distSquared: distA } = a; - const { unit: unitB, distSquared: distB } = b; + const typePriority = + type === UnitType.TransportShip ? 0 : type === UnitType.Warship ? 1 : 2; - // 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; + if (bestUnit === undefined) { + bestUnit = unit; + bestTypePriority = typePriority; + bestDistSquared = distSquared; + continue; + } - // Then prioritize Warships. - if ( - unitA.type() === UnitType.Warship && - unitB.type() !== UnitType.Warship - ) - return -1; + // 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 ( - unitA.type() !== UnitType.Warship && - unitB.type() === UnitType.Warship - ) - return 1; + typePriority < bestTypePriority || + (typePriority === bestTypePriority && distSquared < bestDistSquared) + ) { + bestUnit = unit; + bestTypePriority = typePriority; + bestDistSquared = distSquared; + } + } - // If both are the same type, sort by distance (lower `distSquared` means closer) - return distA - distB; - })[0]?.unit; + return bestUnit; } private shootTarget() { diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index e4bc3c7c9f..64404ed27d 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -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> = {}; diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index 34cc60920c..c763e25450 100644 --- a/src/core/game/UnitGrid.ts +++ b/src/core/game/UnitGrid.ts @@ -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); } } }