From f3f6030c02ab0e0cee6c5a755d3a3843fd3f63f3 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 13 Nov 2025 18:42:40 +0100 Subject: [PATCH 01/11] SAM smart targeting (#1618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current SAM behavior is to shoot a missile as soon as a nuke is in range. Players can exploit it by overshooting behind the SAM, so the SAM missile will take way longer to reach the nuke, usually too late to prevent its explosion. This PR introduces a "smart" targeting system that allows SAM to calculate an optimal interception tile along the nuke's trajectory. They can also preshot before the nuke becomes vulnerable, as long as the interception tile will be within the vulnerable window. This change makes SAM range enforcement much more strict. Changes: - Nukes now precompute their full trajectory on creation and update their current position index every tick. - SAMs use this trajectory data and their own missile speed to calculate the ideal interception tile. - SAM missiles now aim directly at that interception point rather than chasing the nuke. Small changes on the fly: - `BezierCurve` now uses a provided increment so the curve LUT is the optimal size - Increased nuke opacity when untargetable: 0.4 → 0.5 - Slightly extended nuke vulnerability range to SAMs: 120 → 150 === Preshot an incoming nuke still in the unfocusable state. Notice how the nuke is destroyed as soon as becomes focusable: https://github.com/user-attachments/assets/9fbf1ae4-33b4-4fa0-9b53-cb53f3adc17b Shooting right at the range limit: https://github.com/user-attachments/assets/d68793ac-b249-45fe-88bf-e20f70758449 Shooting behind the SAM: https://github.com/user-attachments/assets/800cd7ff-d9d9-40f3-aba8-fa3ab526b3b2 - [x] I have added screenshots for all UI updates - [x] 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 - [x] I have read and accepted the CLA agreement (only required once). regression is found: IngloriousTom --- src/client/graphics/layers/UnitLayer.ts | 2 +- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 8 +- src/core/execution/NukeExecution.ts | 85 +++++--- src/core/execution/SAMLauncherExecution.ts | 184 +++++++++++++----- src/core/execution/SAMMissileExecution.ts | 6 +- src/core/execution/WarshipExecution.ts | 1 + src/core/execution/utils/CityAntiAirUtils.ts | 8 +- src/core/game/Game.ts | 9 + src/core/game/UnitImpl.ts | 18 ++ src/core/pathfinding/PathFinding.ts | 19 +- src/core/utilities/Line.ts | 125 +++++++----- tests/core/execution/WarshipExecution.test.ts | 8 +- .../executions/SAMLauncherExecution.test.ts | 48 +++-- .../SAMSmartTargetingAdditional.test.ts | 104 ++++++++++ .../SAMSmartTargetingEdgeCases.test.ts | 161 +++++++++++++++ 16 files changed, 640 insertions(+), 147 deletions(-) create mode 100644 tests/core/executions/SAMSmartTargetingAdditional.test.ts create mode 100644 tests/core/executions/SAMSmartTargetingEdgeCases.test.ts diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 32db43b41..56f0f9ab8 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -783,7 +783,7 @@ export class UnitLayer implements Layer { const targetable = unit.targetable(); if (!targetable) { this.context.save(); - this.context.globalAlpha = 0.4; + this.context.globalAlpha = 0.5; } const angle = angleByUnit?.get(unit) ?? this.getUnitAngle(unit); diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 4cb12e0d9..cf7574cff 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -232,6 +232,7 @@ export interface Config { nukeAllianceBreakThreshold(): number; defaultNukeSpeed(): number; defaultNukeTargetableRange(): number; + defaultSamMissileSpeed(): number; defaultSamRange(): number; nukeDeathFactor(humans: number, tilesOwned: number): number; structureMinDist(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c83073861..3503f21da 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1193,11 +1193,15 @@ export class DefaultConfig implements Config { } defaultNukeTargetableRange(): number { - return 120; + return 150; } defaultSamRange(): number { - return 80; + return 70; + } + + defaultSamMissileSpeed(): number { + return 12; } // Humans can be population, soldiers attacking, soldiers in boat etc. diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index af7c47979..376df763b 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -5,6 +5,7 @@ import { MessageType, Player, TerraNullius, + TrajectoryTile, Unit, UnitType, } from "../game/Game"; @@ -25,8 +26,6 @@ export class NukeExecution implements Execution { private nuke: Unit | null = null; private tilesToDestroyCache: Set | undefined; private eligibleCities: Unit[] = []; - - private random: PseudoRandom; private pathFinder: ParabolaPathFinder; constructor( @@ -40,7 +39,6 @@ export class NukeExecution implements Execution { init(mg: Game, ticks: number): void { this.mg = mg; - this.random = new PseudoRandom(ticks); if (this.speed === -1) { this.speed = this.mg.config().defaultNukeSpeed(); } @@ -112,10 +110,12 @@ export class NukeExecution implements Execution { this.pathFinder.computeControlPoints( spawn, this.dst, + this.speed, this.nukeType !== UnitType.MIRVWarhead, ); this.nuke = this.player.buildUnit(this.nukeType, spawn, { targetTile: this.dst, + trajectory: this.getTrajectory(this.dst), }); this.maybeBreakAlliances(this.tilesToDestroy()); if (this.mg.hasOwner(this.dst)) { @@ -191,27 +191,30 @@ export class NukeExecution implements Execution { } else { this.updateNukeTargetable(); this.nuke.move(nextTile); - - if (this.nuke === null || this.nuke.targetedBySAM()) return; - - const currentNuke = this.nuke; - const readyInterceptors = this.eligibleCities.filter( - (city) => - (city.ticksLeftInCooldown() ?? 0) <= 0 && - this.mg.euclideanDistSquared(currentNuke.tile(), city.tile()) <= - this.mg.config().citySamLaunchRange() * - this.mg.config().citySamLaunchRange(), - ); - - if (readyInterceptors.length > 0) { - readyInterceptors.sort( - (a, b) => - this.mg.euclideanDistSquared(currentNuke.tile(), a.tile()) - - this.mg.euclideanDistSquared(currentNuke.tile(), b.tile()), + // Update index so SAM can interpolate future position + this.nuke.setTrajectoryIndex(this.pathFinder.currentIndex()); + + // City-based interception: attempt if in range and off cooldown + if (this.nuke !== null && !this.nuke.targetedBySAM()) { + const currentNuke = this.nuke; + const readyInterceptors = this.eligibleCities.filter( + (city) => + (city.ticksLeftInCooldown() ?? 0) <= 0 && + this.mg.euclideanDistSquared(currentNuke.tile(), city.tile()) <= + this.mg.config().citySamLaunchRange() * + this.mg.config().citySamLaunchRange(), ); - const closestInterceptor = readyInterceptors[0]; - attemptInterception(currentNuke, this.mg, closestInterceptor); + if (readyInterceptors.length > 0) { + readyInterceptors.sort( + (a, b) => + this.mg.euclideanDistSquared(currentNuke.tile(), a.tile()) - + this.mg.euclideanDistSquared(currentNuke.tile(), b.tile()), + ); + + const closestInterceptor = readyInterceptors[0]; + attemptInterception(currentNuke, this.mg, closestInterceptor); + } } } } @@ -220,21 +223,43 @@ export class NukeExecution implements Execution { return this.nuke; } + private getTrajectory(target: TileRef): TrajectoryTile[] { + const trajectoryTiles: TrajectoryTile[] = []; + const targetRangeSquared = + this.mg.config().defaultNukeTargetableRange() ** 2; + const allTiles: TileRef[] = this.pathFinder.allTiles(); + for (const tile of allTiles) { + trajectoryTiles.push({ + tile, + targetable: this.isTargetable(target, tile, targetRangeSquared), + }); + } + + return trajectoryTiles; + } + + private isTargetable( + targetTile: TileRef, + nukeTile: TileRef, + targetRangeSquared: number, + ): boolean { + return ( + this.mg.euclideanDistSquared(nukeTile, targetTile) < targetRangeSquared || + (this.src !== undefined && + this.src !== null && + this.mg.euclideanDistSquared(this.src, nukeTile) < targetRangeSquared) + ); + } + private updateNukeTargetable() { if (this.nuke === null || this.nuke.targetTile() === undefined) { return; } const targetRangeSquared = - this.mg.config().defaultNukeTargetableRange() * - this.mg.config().defaultNukeTargetableRange(); + this.mg.config().defaultNukeTargetableRange() ** 2; const targetTile = this.nuke.targetTile(); this.nuke.setTargetable( - this.mg.euclideanDistSquared(this.nuke.tile(), targetTile!) < - targetRangeSquared || - (this.src !== undefined && - this.src !== null && - this.mg.euclideanDistSquared(this.src, this.nuke.tile()) < - targetRangeSquared), + this.isTargetable(targetTile!, this.nuke.tile(), targetRangeSquared), ); } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index fe4a7e2fa..62ce352a0 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -11,6 +11,118 @@ import { TileRef } from "../game/GameMap"; import { PseudoRandom } from "../PseudoRandom"; import { SAMMissileExecution } from "./SAMMissileExecution"; +type Target = { + unit: Unit; + tile: TileRef; +}; + +/** + * Smart SAM targeting system preshoting nukes so its range is strictly enforced + */ +class SAMTargetingSystem { + // Store unreachable nukes so the SAM won't compute an interception point for them every frame + private nukesToIgnore: Set = new Set(); + + constructor( + private mg: Game, + private player: Player, + private sam: Unit, + ) {} + + updateUnreachableNukes(nearbyUnits: { unit: Unit; distSquared: number }[]) { + const nearbyUnitSet = new Set(nearbyUnits.map((u) => u.unit.id())); + for (const nukeId of this.nukesToIgnore) { + if (!nearbyUnitSet.has(nukeId)) { + this.nukesToIgnore.delete(nukeId); + } + } + } + + private storeUnreachableNukes(nukeId: number) { + this.nukesToIgnore.add(nukeId); + } + + private isInRange(tile: TileRef) { + const samTile = this.sam.tile(); + const rangeSquared = this.mg.config().defaultSamRange() ** 2; + return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; + } + + private tickToReach(currentTile: TileRef, tile: TileRef): number { + const missileSpeed = this.mg.config().defaultSamMissileSpeed(); + return Math.ceil(this.mg.manhattanDist(currentTile, tile) / missileSpeed); + } + + private computeInterceptionTile(unit: Unit): TileRef | undefined { + const trajectory = unit.trajectory(); + const samTile = this.sam.tile(); + const currentIndex = unit.trajectoryIndex(); + const explosionTick: number = trajectory.length - currentIndex; + for (let i = unit.trajectoryIndex(); i < trajectory.length; i++) { + const trajectoryTile = trajectory[i]; + if (trajectoryTile.targetable && this.isInRange(trajectoryTile.tile)) { + const nukeTickToReach = i - currentIndex; + const samTickToReach = this.tickToReach(samTile, trajectoryTile.tile); + const reachableOnTime = Math.abs(nukeTickToReach - samTickToReach) <= 1; + if (reachableOnTime && samTickToReach < explosionTick) { + return trajectoryTile.tile; + } + } + } + return undefined; + } + + public getSingleTarget(): Target | null { + // Look beyond the SAM range so it can preshot nukes + const detectionRange = this.mg.config().defaultSamRange() * 1.5; + const nukes = this.mg.nearbyUnits( + this.sam.tile(), + detectionRange, + [UnitType.AtomBomb, UnitType.HydrogenBomb], + ({ unit }) => { + return ( + unit.owner() !== this.player && !this.player.isFriendly(unit.owner()) + ); + }, + ); + + // Clear unreachable nukes that went out of range + this.updateUnreachableNukes(nukes); + + const targets: Array = []; + for (const nuke of nukes) { + if (this.nukesToIgnore.has(nuke.unit.id())) { + continue; + } + const interceptionTile = this.computeInterceptionTile(nuke.unit); + if (interceptionTile !== undefined) { + targets.push({ unit: nuke.unit, tile: interceptionTile }); + } else { + // Store unreachable nukes in order to prevent useless interception computation + this.storeUnreachableNukes(nuke.unit.id()); + } + } + + 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 + ); + } +} + export class SAMLauncherExecution implements Execution { private mg: Game; private active: boolean = true; @@ -19,6 +131,7 @@ export class SAMLauncherExecution implements Execution { // shoot the one targeting very close (MIRVWarheadProtectionRadius) private MIRVWarheadSearchRadius = 400; private MIRVWarheadProtectionRadius = 50; + private targetingSystem: SAMTargetingSystem; private cargoPlaneSearchRadius = 150; private cargoPlaneCheckOffset: number = 0; @@ -39,44 +152,6 @@ export class SAMLauncherExecution implements Execution { this.mg = mg; this.cargoPlaneCheckOffset = mg.ticks() % 20; } - - private getSingleTarget(): Unit | null { - if (this.sam === null) return null; - const nukes = this.mg.nearbyUnits( - this.sam.tile(), - this.mg.config().defaultSamRange(), - [UnitType.AtomBomb, UnitType.HydrogenBomb], - ({ unit }) => { - if (!isUnit(unit)) return false; - if (unit.owner() === this.player) return false; - if (this.player.isFriendly(unit.owner() as Player)) return false; - return unit.isTargetable(); - }, - ); - - return ( - nukes.sort((a, b) => { - const { unit: unitA, distSquared: distA } = a; - const { unit: unitB, distSquared: distB } = b; - - // Prioritize Hydrogen Bombs - if ( - unitA.type() === UnitType.HydrogenBomb && - unitB.type() !== UnitType.HydrogenBomb - ) - return -1; - if ( - unitA.type() !== UnitType.HydrogenBomb && - unitB.type() === UnitType.HydrogenBomb - ) - return 1; - - // If both are the same type, sort by distance (lower `distSquared` means closer) - return distA - distB; - })[0]?.unit ?? null - ); - } - private isHit(type: UnitType, random: number): boolean { if (!this.sam) return false; // Should not happen const healthPercentage = this.sam.hasHealth() @@ -118,6 +193,16 @@ export class SAMLauncherExecution implements Execution { } this.sam = this.player.buildUnit(UnitType.SAMLauncher, spawnTile, {}); } + this.targetingSystem ??= new SAMTargetingSystem( + this.mg, + this.player, + this.sam, + ); + + if (this.sam.isInCooldown()) { + return; + } + if (!this.sam.isActive()) { this.active = false; return; @@ -147,9 +232,9 @@ export class SAMLauncherExecution implements Execution { }, ); - let target: Unit | null = null; + let target: Target | null = null; if (mirvWarheadTargets.length === 0) { - target = this.getSingleTarget(); + target = this.targetingSystem.getSingleTarget(); } const cooldown = this.sam.ticksLeftInCooldown(); @@ -157,15 +242,16 @@ export class SAMLauncherExecution implements Execution { this.sam.touch(); } - const isSingleTarget = target && !target.targetedBySAM(); + const isSingleTarget = !!(target && !target.unit.targetedBySAM()); if ( (isSingleTarget || mirvWarheadTargets.length > 0) && - !this.sam.isInCooldown() && !isPeaceTimerActive ) { this.sam.launch(); const type = - mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type(); + mirvWarheadTargets.length > 0 + ? UnitType.MIRVWarhead + : target?.unit.type(); if (type === undefined) throw new Error("Unknown unit type"); const random = this.pseudoRandom.next(); const hit = this.isHit(type, random); @@ -198,20 +284,19 @@ export class SAMLauncherExecution implements Execution { UnitType.MIRVWarhead, mirvWarheadTargets.length, ); - } else if (target !== null && hit) { - target.setTargetedBySAM(true); + } else if (target !== null) { + target.unit.setTargetedBySAM(true); this.mg.addExecution( new SAMMissileExecution( this.sam.tile(), this.sam.owner(), this.sam, - target, + target.unit, + target.tile, ), ); - } else if (target !== null) { - // Do nothing, the missile missed } else { - throw new Error("target is null"); + // No valid target to engage (should not happen when firing) } } if ((this.mg.ticks() + this.cargoPlaneCheckOffset) % 20 === 0) { @@ -302,6 +387,7 @@ export class SAMLauncherExecution implements Execution { this.sam!.owner(), this.sam!, targetPlane, + targetPlane.tile(), ), ); } else { diff --git a/src/core/execution/SAMMissileExecution.ts b/src/core/execution/SAMMissileExecution.ts index 6b871a4f7..06c345b0a 100644 --- a/src/core/execution/SAMMissileExecution.ts +++ b/src/core/execution/SAMMissileExecution.ts @@ -16,18 +16,20 @@ export class SAMMissileExecution implements Execution { private pathFinder: AirPathFinder; private SAMMissile: Unit | undefined; private mg: Game; + private speed: number = 0; constructor( private spawn: TileRef, private _owner: Player, private ownerUnit: Unit, private target: Unit, - private speed: number = 12, + private targetTile: TileRef, ) {} init(mg: Game, ticks: number): void { this.pathFinder = new AirPathFinder(mg, new PseudoRandom(mg.ticks())); this.mg = mg; + this.speed = this.mg.config().defaultSamMissileSpeed(); } tick(ticks: number): void { @@ -71,7 +73,7 @@ export class SAMMissileExecution implements Execution { for (let i = 0; i < this.speed; i++) { const result = this.pathFinder.nextTile( this.SAMMissile.tile(), - this.target.tile(), + this.targetTile, ); if (result === true) { if ( diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index 616525527..0974f8d9d 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -402,6 +402,7 @@ export class WarshipExecution implements Execution { this.warship.owner(), this.warship, bestTarget, + bestTarget.tile(), ), ); bestTarget.setTargetedBySAM(true); diff --git a/src/core/execution/utils/CityAntiAirUtils.ts b/src/core/execution/utils/CityAntiAirUtils.ts index 9e88980db..c98b9671e 100644 --- a/src/core/execution/utils/CityAntiAirUtils.ts +++ b/src/core/execution/utils/CityAntiAirUtils.ts @@ -56,7 +56,13 @@ export function attemptInterception(target: Unit, game: Game, city: Unit) { } target.setTargetedBySAM(true); - const sam = new SAMMissileExecution(city.tile(), city.owner(), city, target); + const sam = new SAMMissileExecution( + city.tile(), + city.owner(), + city, + target, + target.tile(), + ); game.addExecution(sam); // Start city SAM cooldown using standard unit cooldown API city.launch(game.config().citySamCooldown()); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 4f660e806..ac7277b61 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -219,6 +219,10 @@ export interface OwnerComp { owner: Player; } +export type TrajectoryTile = { + tile: TileRef; + targetable: boolean; +}; export interface UnitParamsMap { [UnitType.TransportShip]: { troops?: number; @@ -241,10 +245,12 @@ export interface UnitParamsMap { [UnitType.AtomBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.HydrogenBomb]: { targetTile?: number; + trajectory: TrajectoryTile[]; }; [UnitType.TradeShip]: { @@ -465,6 +471,9 @@ export interface Unit { // Targeting setTargetTile(cell: TileRef | undefined): void; targetTile(): TileRef | undefined; + setTrajectoryIndex(i: number): void; + trajectoryIndex(): number; + trajectory(): TrajectoryTile[]; setTargetUnit(unit: Unit | undefined): void; targetUnit(): Unit | undefined; setTargetedBySAM(targeted: boolean): void; diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 7605ff68f..bcd24d78a 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -6,6 +6,7 @@ import { Player, PlayerID, Tick, + TrajectoryTile, Unit, UnitInfo, UnitType, @@ -52,6 +53,9 @@ export class UnitImpl implements Unit { // 3 seconds * 10 ticks/sec = 30 ticks return this.mg.ticks() - this.lastVisibleTick < 30; } + // Nuke only + private _trajectoryIndex: number = 0; + private _trajectory: TrajectoryTile[]; constructor( private _type: UnitType, @@ -66,6 +70,7 @@ export class UnitImpl implements Unit { this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1); this._targetTile = "targetTile" in params ? (params.targetTile ?? undefined) : undefined; + this._trajectory = "trajectory" in params ? (params.trajectory ?? []) : []; this._troops = "troops" in params ? (params.troops ?? 0) : 0; this._lastSetSafeFromPirates = "lastSetSafeFromPirates" in params @@ -555,6 +560,19 @@ export class UnitImpl implements Unit { return this._targetTile; } + setTrajectoryIndex(i: number): void { + const max = this._trajectory.length - 1; + this._trajectoryIndex = i < 0 ? 0 : i > max ? max : i; + } + + trajectoryIndex(): number { + return this._trajectoryIndex; + } + + trajectory(): TrajectoryTile[] { + return this._trajectory; + } + setTargetUnit(target: Unit | undefined): void { this._targetUnit = target; } diff --git a/src/core/pathfinding/PathFinding.ts b/src/core/pathfinding/PathFinding.ts index 8a5efc93e..236afc13e 100644 --- a/src/core/pathfinding/PathFinding.ts +++ b/src/core/pathfinding/PathFinding.ts @@ -15,6 +15,7 @@ export class ParabolaPathFinder { computeControlPoints( orig: TileRef, dst: TileRef, + increment: number = 3, distanceBasedHeight = true, ) { const p0 = { x: this.mg.x(orig), y: this.mg.y(orig) }; @@ -35,7 +36,7 @@ export class ParabolaPathFinder { y: Math.max(p0.y + ((p3.y - p0.y) * 3) / 4 - maxHeight, 0), }; - this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3); + this.curve = new DistanceBasedBezierCurve(p0, p1, p2, p3, increment); } nextTile(speed: number): TileRef | true { @@ -48,6 +49,22 @@ export class ParabolaPathFinder { } return this.mg.ref(Math.floor(nextPoint.x), Math.floor(nextPoint.y)); } + + currentIndex(): number { + if (!this.curve) { + return 0; + } + return this.curve.getCurrentIndex(); + } + + allTiles(): TileRef[] { + if (!this.curve) { + return []; + } + return this.curve + .getAllPoints() + .map((point) => this.mg.ref(Math.floor(point.x), Math.floor(point.y))); + } } export class AirPathFinder { diff --git a/src/core/utilities/Line.ts b/src/core/utilities/Line.ts index 2e1fea97b..e8c673533 100644 --- a/src/core/utilities/Line.ts +++ b/src/core/utilities/Line.ts @@ -78,76 +78,103 @@ export class CubicBezierCurve { */ export class DistanceBasedBezierCurve extends CubicBezierCurve { private totalDistance: number = 0; - private distanceLUT: Array<{ t: number; distance: number }> = []; - private lastFoundIndex: number = 0; // To keep track of the last found index + private cachedPoints: Point[] = []; + private currentIndex: number = 0; + constructor( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + distanceIncrement: number, + ) { + super(p0, p1, p2, p3); + this.computeAllPoints(distanceIncrement, 0.002); + } + + getAllPoints(): Point[] { + return this.cachedPoints; + } + /** + * Move forward along the curve by the given distance. + * Returns the next cached point, or null if at the end. + */ increment(distance: number): Point | null { this.totalDistance += distance; - const targetDistance = Math.min( - this.totalDistance, - this.distanceLUT[this.distanceLUT.length - 1]?.distance || - this.totalDistance, - ); - const t = this.computeTForDistance(targetDistance); - if (t >= 1) { - return null; // end reached + + // Step forward through cached points until we're at the correct distance + while ( + this.currentIndex < this.cachedPoints.length - 1 && + this.getDistanceUpToIndex(this.currentIndex + 1) < this.totalDistance + ) { + this.currentIndex++; + } + + if (this.currentIndex >= this.cachedPoints.length - 1) { + return null; // End of curve } - return this.getPointAt(t); + + return this.cachedPoints[this.currentIndex]; + } + + getCurrentIndex(): number { + return this.currentIndex; } /** - * Generate @p numSteps segments, starting from the beginning of the curve - * Each segment size is added in the LUT + * Precompute all points spaced @p pixelSpacing apart */ - generateCumulativeDistanceLUT(numSteps: number = 500): void { - this.distanceLUT = []; + computeAllPoints(pixelSpacing: number, precision): void { + this.cachedPoints = []; + this.totalDistance = 0; + this.currentIndex = 0; + + let t = 0; + let prevPoint = this.getPointAt(t); + this.cachedPoints.push(prevPoint); + let cumulativeDistance = 0; - let prevPoint = this.getPointAt(0); - for (let i = 1; i <= numSteps; i++) { - const t = i / numSteps; + while (t < 1) { + t = Math.min(t + precision, 1); const currentPoint = this.getPointAt(t); const dx = currentPoint.x - prevPoint.x; const dy = currentPoint.y - prevPoint.y; const segmentLength = Math.sqrt(dx * dx + dy * dy); - cumulativeDistance += segmentLength; - this.distanceLUT.push({ t, distance: cumulativeDistance }); + + if (cumulativeDistance >= pixelSpacing) { + this.cachedPoints.push(currentPoint); + cumulativeDistance = 0; + } + prevPoint = currentPoint; } - } - computeTForDistance(distance: number): number { - if (this.distanceLUT.length === 0) { - this.generateCumulativeDistanceLUT(); - } - if (distance <= 0) return 0; - if (distance >= this.distanceLUT[this.distanceLUT.length - 1].distance) { - return 1; + // Make sure the last point is exactly at t=1 + const finalPoint = this.getPointAt(1); + if ( + this.cachedPoints.length === 0 || + finalPoint.x !== this.cachedPoints[this.cachedPoints.length - 1].x || + finalPoint.y !== this.cachedPoints[this.cachedPoints.length - 1].y + ) { + this.cachedPoints.push(finalPoint); } + } - let lowerIndex = this.lastFoundIndex; - let upperIndex = this.distanceLUT.length - 1; - // Binary search for the closest range - while (upperIndex - lowerIndex > 1) { - const midIndex = Math.floor((upperIndex + lowerIndex) / 2); - if (this.distanceLUT[midIndex].distance < distance) { - lowerIndex = midIndex; - } else { - upperIndex = midIndex; - } + /** + * Optional helper: get distance along the cached points up to a given index + */ + private getDistanceUpToIndex(index: number): number { + let dist = 0; + for (let i = 1; i <= index; i++) { + const p1 = this.cachedPoints[i - 1]; + const p2 = this.cachedPoints[i]; + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + dist += Math.sqrt(dx * dx + dy * dy); } - - const lower = this.distanceLUT[lowerIndex]; - const upper = this.distanceLUT[upperIndex]; - this.lastFoundIndex = lowerIndex; - - // Linear interpolation of t based on the distance - const t = - lower.t + - ((distance - lower.distance) * (upper.t - lower.t)) / - (upper.distance - lower.distance); - return t; + return dist; } } diff --git a/tests/core/execution/WarshipExecution.test.ts b/tests/core/execution/WarshipExecution.test.ts index 7ca93f928..7fa955faf 100644 --- a/tests/core/execution/WarshipExecution.test.ts +++ b/tests/core/execution/WarshipExecution.test.ts @@ -146,7 +146,13 @@ describe("WarshipExecution AA Capability", () => { player1.addUpgrade(UpgradeType.WarshipAntiAir); const addExecutionSpy = jest.spyOn(game, "addExecution"); jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); - player2.buildUnit(UnitType.AtomBomb, game.ref(11, 11), {}); + player2.buildUnit(UnitType.AtomBomb, game.ref(11, 11), { + targetTile: game.ref(0, 0), + trajectory: [ + { tile: game.ref(11, 11), targetable: true }, + { tile: game.ref(10, 11), targetable: true }, + ], + }); executeTicks(game, 10); expect(addExecutionSpy).not.toHaveBeenCalledWith( expect.any(SAMMissileExecution), diff --git a/tests/core/executions/SAMLauncherExecution.test.ts b/tests/core/executions/SAMLauncherExecution.test.ts index 389b25ee5..ce9326ce4 100644 --- a/tests/core/executions/SAMLauncherExecution.test.ts +++ b/tests/core/executions/SAMLauncherExecution.test.ts @@ -81,10 +81,16 @@ describe("SAM", () => { test("one sam should take down one nuke", async () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); - attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { - targetTile: game.ref(2, 1), - }); + // Sam will only target nukes it can destroy before it reaches its target + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], + }); executeTicks(game, 3); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(0); @@ -94,10 +100,20 @@ describe("SAM", () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 1), { - targetTile: game.ref(2, 1), + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], }); attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: game.ref(1, 2), + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 3), targetable: true }, + ], }); expect(attacker.units(UnitType.AtomBomb)).toHaveLength(2); @@ -110,8 +126,13 @@ describe("SAM", () => { const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam)); expect(sam.isInCooldown()).toBeFalsy(); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), { - targetTile: game.ref(1, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], }); executeTicks(game, 3); @@ -132,8 +153,13 @@ describe("SAM", () => { game.addExecution(new SAMLauncherExecution(defender, null, sam1)); const sam2 = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 2), {}); game.addExecution(new SAMLauncherExecution(defender, null, sam2)); - const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(2, 2), { - targetTile: game.ref(2, 2), + const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(1, 3), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(1, 3), targetable: true }, + ], }); executeTicks(game, 3); @@ -157,7 +183,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); @@ -192,7 +218,7 @@ describe("SAM", () => { game.addExecution(nukeExecution); // Long distance nuke: compute the proper number of ticks const ticksToExecute = Math.ceil( - targetDistance / game.config().defaultNukeSpeed(), + targetDistance / game.config().defaultNukeSpeed() + 1, ); executeTicks(game, ticksToExecute); expect(nukeExecution.isActive()).toBeFalsy(); diff --git a/tests/core/executions/SAMSmartTargetingAdditional.test.ts b/tests/core/executions/SAMSmartTargetingAdditional.test.ts new file mode 100644 index 000000000..b9626f291 --- /dev/null +++ b/tests/core/executions/SAMSmartTargetingAdditional.test.ts @@ -0,0 +1,104 @@ +import { NukeExecution } from "../../../src/core/execution/NukeExecution"; +import { SAMLauncherExecution } from "../../../src/core/execution/SAMLauncherExecution"; +import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../../../src/core/game/Game"; +import { PseudoRandom } from "../../../src/core/PseudoRandom"; +import { setup } from "../../util/Setup"; +import { constructionExecution, executeTicks } from "../../util/utils"; + +let game: Game; +let attacker: Player; +let defender: Player; + +describe("SAM smart targeting integration (additional)", () => { + beforeEach(async () => { + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); + + const defender_info = new PlayerInfo( + "us", + "defender_id_ex", + PlayerType.Human, + null, + "defender_id_ex", + ); + const attacker_info = new PlayerInfo( + "fr", + "attacker_id_ex", + PlayerType.Human, + null, + "attacker_id_ex", + ); + + // Register players + game.addPlayer(defender_info); + game.addPlayer(attacker_info); + + game.addExecution( + new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)), + new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + attacker = game.player(attacker_info.id); + defender = game.player(defender_info.id); + + // Ensure attacker has a missile silo to launch nukes + constructionExecution(game, attacker, 7, 7, UnitType.MissileSilo); + }); + + test("nuke trajectory available for smart interception", () => { + const target = game.ref(10, 1); + const nukeExec = new NukeExecution( + UnitType.AtomBomb, + attacker, + target, + null, + ); + game.addExecution(nukeExec); + + // Allow NukeExecution to initialize and move enough steps + executeTicks(game, 30); + + const nuke = nukeExec.getNuke(); + expect(nuke).not.toBeNull(); + // Ensure trajectory is populated to enable smart interception + expect(nuke!.trajectory().length).toBeGreaterThan(1); + + // Now add SAM and let it intercept to ensure end-to-end remains functional + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + // Let SAM intercept to ensure end-to-end remains functional + executeTicks(game, 20); + expect(nuke!.isActive()).toBeFalsy(); + }); + + test("SAM still intercepts hostile planes (bomber)", () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + // Place a hostile bomber within plane detection radius + jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); // Guarantee hit + const bomber = attacker.buildUnit(UnitType.Bomber, game.ref(5, 1), { + targetTile: game.ref(0, 0), + }); + + // Run enough ticks to trigger periodic plane checks and missile travel + executeTicks(game, 60); + + // Bomber should be intercepted (deleted) or at least targeted + const stillThere = attacker.units(UnitType.Bomber).includes(bomber); + const targeted = bomber.targetedBySAM?.() ?? false; + + expect(stillThere ? targeted : true).toBeTruthy(); + }); +}); diff --git a/tests/core/executions/SAMSmartTargetingEdgeCases.test.ts b/tests/core/executions/SAMSmartTargetingEdgeCases.test.ts new file mode 100644 index 000000000..6d7031e61 --- /dev/null +++ b/tests/core/executions/SAMSmartTargetingEdgeCases.test.ts @@ -0,0 +1,161 @@ +import { NukeExecution } from "../../../src/core/execution/NukeExecution"; +import { SAMLauncherExecution } from "../../../src/core/execution/SAMLauncherExecution"; +import { SAMMissileExecution } from "../../../src/core/execution/SAMMissileExecution"; +import { SpawnExecution } from "../../../src/core/execution/SpawnExecution"; +import { + Game, + Player, + PlayerInfo, + PlayerType, + UnitType, +} from "../../../src/core/game/Game"; +import { PseudoRandom } from "../../../src/core/PseudoRandom"; +import { setup } from "../../util/Setup"; +import { constructionExecution, executeTicks } from "../../util/utils"; + +let game: Game; +let attacker: Player; +let defender: Player; + +describe("SAM smart targeting edge cases", () => { + beforeEach(async () => { + game = await setup("BigPlains", { infiniteGold: true, instantBuild: true }); + + const defender_info = new PlayerInfo( + "us", + "defender_edge", + PlayerType.Human, + null, + "defender_edge", + ); + const attacker_info = new PlayerInfo( + "fr", + "attacker_edge", + PlayerType.Human, + null, + "attacker_edge", + ); + + game.addPlayer(defender_info); + game.addPlayer(attacker_info); + + game.addExecution( + new SpawnExecution(game.player(defender_info.id).info(), game.ref(1, 1)), + new SpawnExecution(game.player(attacker_info.id).info(), game.ref(7, 7)), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + + attacker = game.player(attacker_info.id); + defender = game.player(defender_info.id); + }); + + test("prioritizes Hydrogen Bomb over Atom Bomb when both reachable", () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + // Build two nukes with short, targetable trajectories within range + const atom = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 1), { + targetTile: game.ref(3, 1), + trajectory: [ + { tile: game.ref(1, 1), targetable: true }, + { tile: game.ref(2, 1), targetable: true }, + { tile: game.ref(3, 1), targetable: true }, + ], + }); + const h2 = attacker.buildUnit(UnitType.HydrogenBomb, game.ref(1, 2), { + targetTile: game.ref(3, 2), + trajectory: [ + { tile: game.ref(1, 2), targetable: true }, + { tile: game.ref(2, 2), targetable: true }, + { tile: game.ref(3, 2), targetable: true }, + ], + }); + + // Ensure hit roll succeeds so we see the target flag apply + jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); + + executeTicks(game, 2); + + expect(h2.targetedBySAM()).toBe(true); + expect(atom.targetedBySAM()).toBe(false); + }); + + test("respects plane cooldown between shots", () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + const addExecSpy = jest.spyOn(game, "addExecution"); + jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); + + attacker.buildUnit(UnitType.Airfield, game.ref(6, 1), {}); + attacker.buildUnit(UnitType.Bomber, game.ref(5, 1), { + targetTile: game.ref(0, 0), + }); + + // First shot (plane checks run every 20 ticks with offset) + executeTicks(game, 25); + expect(addExecSpy).toHaveBeenCalledWith(expect.any(SAMMissileExecution)); + const callsAfterFirst = addExecSpy.mock.calls.length; + + // New target before plane cooldown elapses + attacker.buildUnit(UnitType.Bomber, game.ref(6, 2), { + targetTile: game.ref(0, 0), + }); + // Ensure a plane check occurs but cooldown still blocks + executeTicks(game, 20); + expect(addExecSpy.mock.calls.length).toBe(callsAfterFirst); + }); + + test("does not target returning bombers", () => { + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + const addExecSpy = jest.spyOn(game, "addExecution"); + jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); + + const bomber = attacker.buildUnit(UnitType.Bomber, game.ref(5, 1), { + targetTile: game.ref(0, 0), + }); + bomber.setReturning(true); + + executeTicks(game, 40); + + expect(addExecSpy).not.toHaveBeenCalledWith( + expect.any(SAMMissileExecution), + ); + expect(bomber.targetedBySAM()).toBe(false); + }); + + test("does not launch at nukes with only out-of-range targetable segments", () => { + // Build a SAM in the middle, with targetable nuke segments only near ends + const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(50, 1), {}); + game.addExecution(new SAMLauncherExecution(defender, null, sam)); + + // Ensure attacker has a missile silo to launch nukes + constructionExecution(game, attacker, 7, 7, UnitType.MissileSilo); + + const nukeExec = new NukeExecution( + UnitType.AtomBomb, + attacker, + game.ref(100, 1), + game.ref(1, 1), + ); + game.addExecution(nukeExec); + + const addExecSpy = jest.spyOn(game, "addExecution"); + jest.spyOn(PseudoRandom.prototype, "next").mockReturnValue(0.1); + + // Run enough ticks for the nuke to pass near the SAM + executeTicks(game, 80); + + // SAM should not have fired (no SAM missile launches) and did not enter cooldown due to nuke + expect(addExecSpy).not.toHaveBeenCalledWith( + expect.any(SAMMissileExecution), + ); + expect(sam.isInCooldown()).toBe(false); + // Nuke may have detonated by now depending on path speed; we only care SAM didn't fire + }); +}); From 65e1ca647c14a243f468446457234184e9ffb593 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 13 Nov 2025 19:50:58 +0100 Subject: [PATCH 02/11] feat(upgrade-structure): add SAMLauncher upgrade logic and range enhancements --- src/client/graphics/layers/StructureLayer.ts | 10 +++++++- src/core/configuration/Config.ts | 2 ++ src/core/configuration/DefaultConfig.ts | 9 +++++++ src/core/execution/SAMLauncherExecution.ts | 25 +++++++++++++++++--- src/core/game/UnitImpl.ts | 14 +++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index da9814e82..c2c912fea 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -796,7 +796,8 @@ export class StructureLayer implements Layer { clickedUnit.type() === UnitType.Port || clickedUnit.type() === UnitType.Hospital || clickedUnit.type() === UnitType.Academy || - clickedUnit.type() === UnitType.MissileSilo) + clickedUnit.type() === UnitType.MissileSilo || + clickedUnit.type() === UnitType.SAMLauncher) ) { // Only if affordable // And only if not at level cap for Missile Silo @@ -806,6 +807,13 @@ export class StructureLayer implements Layer { ) { return; } + // SAMs also cap at level 3 + if ( + clickedUnit.type() === UnitType.SAMLauncher && + clickedUnit.level() >= 3 + ) { + return; + } if (this.canAffordUpgrade(clickedUnit)) { // Fire transport event to send intent; rely on server update to change level this.eventBus.emit( diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index cf7574cff..6b075c195 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -234,6 +234,8 @@ export interface Config { defaultNukeTargetableRange(): number; defaultSamMissileSpeed(): number; defaultSamRange(): number; + // Percentage (0..1) increase applied per SAM level beyond 1 + samRangeUpgradePercent(): number; nukeDeathFactor(humans: number, tilesOwned: number): number; structureMinDist(): number; isReplay(): boolean; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 3503f21da..c6cc072c2 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1200,6 +1200,11 @@ export class DefaultConfig implements Config { return 70; } + samRangeUpgradePercent(): number { + // Each upgrade increases range by 35%; level 3 > H-bomb range, level 2 does not + return 0.35; + } + defaultSamMissileSpeed(): number { return 12; } @@ -1316,6 +1321,8 @@ export class DefaultConfig implements Config { return 4; // Default 80% -> 4/5 case UnitType.MissileSilo: return 1; // Missile silo: 50% -> 1/2 + case UnitType.SAMLauncher: + return 2; // SAM: 40% -> 2/5 default: return 1; } @@ -1329,6 +1336,8 @@ export class DefaultConfig implements Config { return 5; // Default 80% -> 4/5 case UnitType.MissileSilo: return 2; // Missile silo: 50% -> 1/2 + case UnitType.SAMLauncher: + return 5; // SAM: 40% -> 2/5 default: return 1; } diff --git a/src/core/execution/SAMLauncherExecution.ts b/src/core/execution/SAMLauncherExecution.ts index 62ce352a0..12c4124b2 100644 --- a/src/core/execution/SAMLauncherExecution.ts +++ b/src/core/execution/SAMLauncherExecution.ts @@ -42,9 +42,19 @@ class SAMTargetingSystem { this.nukesToIgnore.add(nukeId); } + private effectiveSamRange(): number { + const base = this.mg.config().defaultSamRange(); + const bonus = this.mg.config().samRangeUpgradePercent(); + const lvl = this.sam.level?.() ?? 1; + if (lvl <= 1) return base; + // Apply per-upgrade multiplicative increase + const factor = Math.pow(1 + bonus, lvl - 1); + return Math.round(base * factor); + } + private isInRange(tile: TileRef) { const samTile = this.sam.tile(); - const rangeSquared = this.mg.config().defaultSamRange() ** 2; + const rangeSquared = this.effectiveSamRange() ** 2; return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared; } @@ -74,7 +84,7 @@ class SAMTargetingSystem { public getSingleTarget(): Target | null { // Look beyond the SAM range so it can preshot nukes - const detectionRange = this.mg.config().defaultSamRange() * 1.5; + const detectionRange = this.effectiveSamRange() * 1.5; const nukes = this.mg.nearbyUnits( this.sam.tile(), detectionRange, @@ -309,9 +319,18 @@ export class SAMLauncherExecution implements Execution { this.mg.peaceTimerEndsAtTick !== null && this.mg.ticks() < this.mg.peaceTimerEndsAtTick; + const effectiveRange = (() => { + const base = this.mg.config().defaultSamRange(); + const bonus = this.mg.config().samRangeUpgradePercent(); + const lvl = this.sam!.level?.() ?? 1; + if (lvl <= 1) return base; + const factor = Math.pow(1 + bonus, lvl - 1); + return Math.round(base * factor); + })(); + const potentialAirborneTargets = this.mg.nearbyUnits( this.sam!.tile(), - this.cargoPlaneSearchRadius, + effectiveRange, [ UnitType.CargoPlane, UnitType.Bomber, diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index bcd24d78a..b47da16e2 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -254,6 +254,20 @@ export class UnitImpl implements Unit { this.mg.addUpdate(this.toUpdate()); return; } + case UnitType.SAMLauncher: { + // Cap SAM upgrades at level 3 + if (this._level >= 3) { + return; + } + this._level += 1; + // Small durability boost per upgrade, aligned with MissileSilo behavior + this._bonusMaxHealth += 250; + const healed = Number(this._health) + 250; + const capped = Math.min(healed, this.effectiveMaxHealth()); + this._health = toInt(capped); + this.mg.addUpdate(this.toUpdate()); + return; + } case UnitType.Port: { this._level += 1; this._bonusMaxHealth += 1000; From ad27baaa3611a56f71370b7dc839e07b238a089f Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 13 Nov 2025 20:40:28 +0100 Subject: [PATCH 03/11] fix(intent-schema): remove duplicate UpgradeStructureIntentSchema entry --- src/core/Schemas.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 3134fa55f..08799a84f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -506,7 +506,6 @@ const IntentSchema = z.discriminatedUnion("type", [ BuildUnitIntentSchema, PurchaseUpgradeIntentSchema, UpgradeStructureIntentSchema, - UpgradeStructureIntentSchema, ResearchTreeSelectIntentSchema, EmbargoIntentSchema, MoveWarshipIntentSchema, From f0a8fd1c5d1b83d9df2873dc4da8b230a53d915d Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 13 Nov 2025 20:50:02 +0100 Subject: [PATCH 04/11] feat(upgrade-structure): add affordability tracking for SAMLauncher upgrades --- src/client/graphics/layers/StructureLayer.ts | 36 +++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index c2c912fea..31eeadd69 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -85,6 +85,7 @@ export class StructureLayer implements Layer { private lastAffordableForUpgradeHospital: boolean | null = null; private lastAffordableForUpgradeAcademy: boolean | null = null; private lastAffordableForUpgradeSilo: boolean | null = null; + private lastAffordableForUpgradeSAM: boolean | null = null; // Client-side level tracking for structures (temporary) private structureLevels = new Map< number, @@ -162,7 +163,8 @@ export class StructureLayer implements Layer { r.unit.type() === UnitType.Port || r.unit.type() === UnitType.Hospital || r.unit.type() === UnitType.Academy || - r.unit.type() === UnitType.MissileSilo + r.unit.type() === UnitType.MissileSilo || + r.unit.type() === UnitType.SAMLauncher ) { r.pixiSprite.texture = this.createTexture(r.unit); } @@ -297,13 +299,15 @@ export class StructureLayer implements Layer { const affordableHospital = this.canAffordUpgradeForType(UnitType.Hospital); const affordableAcademy = this.canAffordUpgradeForType(UnitType.Academy); const affordableSilo = this.canAffordUpgradeForType(UnitType.MissileSilo); + const affordableSAM = this.canAffordUpgradeForType(UnitType.SAMLauncher); if (!this.upgradeMode) { if ( this.lastAffordableForUpgradeCity !== null || this.lastAffordableForUpgradePort !== null || this.lastAffordableForUpgradeHospital !== null || this.lastAffordableForUpgradeAcademy !== null || - this.lastAffordableForUpgradeSilo !== null + this.lastAffordableForUpgradeSilo !== null || + this.lastAffordableForUpgradeSAM !== null ) { for (const r of this.renders) { if ( @@ -311,7 +315,8 @@ export class StructureLayer implements Layer { r.unit.type() === UnitType.Port || r.unit.type() === UnitType.Hospital || r.unit.type() === UnitType.Academy || - r.unit.type() === UnitType.MissileSilo + r.unit.type() === UnitType.MissileSilo || + r.unit.type() === UnitType.SAMLauncher ) { r.pixiSprite.texture = this.createTexture(r.unit); } @@ -321,6 +326,7 @@ export class StructureLayer implements Layer { this.lastAffordableForUpgradeHospital = null; this.lastAffordableForUpgradeAcademy = null; this.lastAffordableForUpgradeSilo = null; + this.lastAffordableForUpgradeSAM = null; this.shouldRedraw = true; } // When exiting upgrade mode, ensure any previously highlighted sprites are refreshed @@ -343,12 +349,14 @@ export class StructureLayer implements Layer { const academyChanged = this.lastAffordableForUpgradeAcademy !== affordableAcademy; const siloChanged = this.lastAffordableForUpgradeSilo !== affordableSilo; + const samChanged = this.lastAffordableForUpgradeSAM !== affordableSAM; if ( cityChanged || portChanged || hospitalChanged || academyChanged || - siloChanged + siloChanged || + samChanged ) { for (const r of this.renders) { const t = r.unit.type(); @@ -357,7 +365,8 @@ export class StructureLayer implements Layer { (portChanged && t === UnitType.Port) || (hospitalChanged && t === UnitType.Hospital) || (academyChanged && t === UnitType.Academy) || - (siloChanged && t === UnitType.MissileSilo) + (siloChanged && t === UnitType.MissileSilo) || + (samChanged && t === UnitType.SAMLauncher) ) { r.pixiSprite.texture = this.createTexture(r.unit); } @@ -367,6 +376,7 @@ export class StructureLayer implements Layer { this.lastAffordableForUpgradeHospital = affordableHospital; this.lastAffordableForUpgradeAcademy = affordableAcademy; this.lastAffordableForUpgradeSilo = affordableSilo; + this.lastAffordableForUpgradeSAM = affordableSAM; this.shouldRedraw = true; } @@ -379,7 +389,8 @@ export class StructureLayer implements Layer { t !== UnitType.Port && t !== UnitType.Hospital && t !== UnitType.Academy && - t !== UnitType.MissileSilo + t !== UnitType.MissileSilo && + t !== UnitType.SAMLauncher ) { continue; } @@ -477,7 +488,8 @@ export class StructureLayer implements Layer { t === UnitType.Port || t === UnitType.Hospital || t === UnitType.Academy || - t === UnitType.MissileSilo + t === UnitType.MissileSilo || + t === UnitType.SAMLauncher ) { const hl = this.shouldHighlight(unit) ? 1 : 0; cacheKey += `-hl${hl}`; @@ -533,7 +545,8 @@ export class StructureLayer implements Layer { structureType === UnitType.Port || structureType === UnitType.Hospital || structureType === UnitType.Academy || - structureType === UnitType.MissileSilo) && + structureType === UnitType.MissileSilo || + structureType === UnitType.SAMLauncher) && this.shouldHighlight(unit) ) { // Blend neon green with the base border color to reduce intensity @@ -653,13 +666,18 @@ export class StructureLayer implements Layer { unit.type() !== UnitType.Port && unit.type() !== UnitType.Hospital && unit.type() !== UnitType.Academy && - unit.type() !== UnitType.MissileSilo + unit.type() !== UnitType.MissileSilo && + unit.type() !== UnitType.SAMLauncher ) return false; // Do not highlight missile silos at max level (3) if (unit.type() === UnitType.MissileSilo && unit.level() >= 3) { return false; } + // Do not highlight SAM launchers at max level (3) + if (unit.type() === UnitType.SAMLauncher && unit.level() >= 3) { + return false; + } return unit.owner().id() === me.id() && this.canAffordUpgrade(unit); } From 9d514fe03cce40f02167d21d7ea0f364c12f0d5c Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Thu, 13 Nov 2025 20:52:30 +0100 Subject: [PATCH 05/11] feat(upgrade-structure): enhance upgrade logic to include SAMLauncher with level cap enforcement --- src/core/execution/UpgradeStructureExecution.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index de08cd987..51f30d9b0 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -40,11 +40,13 @@ export class UpgradeStructureExecution implements Execution { case UnitType.Port: case UnitType.Hospital: case UnitType.Academy: - case UnitType.MissileSilo: { + case UnitType.MissileSilo: + case UnitType.SAMLauncher: { const unitType = this.unit.type(); // Enforce missile silo max level 3 on the executor side to avoid charging when capped if ( - unitType === UnitType.MissileSilo && + (unitType === UnitType.MissileSilo || + unitType === UnitType.SAMLauncher) && (this.unit.level?.call(this.unit) ?? 1) >= 3 ) { this._isActive = false; From bffd1fef95361c3d0d2c8f21afd8bd1ab66d40e5 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Fri, 14 Nov 2025 23:29:05 +0100 Subject: [PATCH 06/11] fix(structure-layer): adjust secondary label rendering logic for better visibility --- src/client/graphics/layers/StructureLayer.ts | 30 ++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 31eeadd69..9e8f00bd1 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -958,13 +958,20 @@ export class StructureLayer implements Layer { }); const tPrimary = new PIXI.Text(String(levels.primary), stylePrimary); - const tSecondary = new PIXI.Text(String(levels.secondary), styleSecondary); + const showSecondary = (levels.secondary ?? 0) > 0; + const tSecondary = showSecondary + ? new PIXI.Text(String(levels.secondary), styleSecondary) + : null; // Measure and layout const gap = Math.round(fontSize * 0.4); const paddingX = Math.round(fontSize * 0.5); const paddingY = Math.round(fontSize * 0.35); - const contentWidth = tPrimary.width + tSecondary.width + gap; - const contentHeight = Math.max(tPrimary.height, tSecondary.height); + const contentWidth = showSecondary + ? tPrimary.width + (tSecondary?.width ?? 0) + gap + : tPrimary.width; + const contentHeight = showSecondary + ? Math.max(tPrimary.height, tSecondary!.height) + : tPrimary.height; const pillWidth = contentWidth + paddingX * 2; const pillHeight = contentHeight + paddingY * 2; const bg = new PIXI.Graphics(); @@ -983,11 +990,18 @@ export class StructureLayer implements Layer { this.labelContainer.addChild(bg); // Position texts inside pill - tPrimary.x = bgX + paddingX; - tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); - tSecondary.x = tPrimary.x + tPrimary.width + gap; - tSecondary.y = bgY + Math.round((pillHeight - tSecondary.height) / 2); - this.labelContainer.addChild(tPrimary, tSecondary); + if (showSecondary && tSecondary) { + tPrimary.x = bgX + paddingX; + tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); + tSecondary.x = tPrimary.x + tPrimary.width + gap; + tSecondary.y = bgY + Math.round((pillHeight - tSecondary.height) / 2); + this.labelContainer.addChild(tPrimary, tSecondary); + } else { + // Center the single primary value + tPrimary.x = bgX + Math.round((pillWidth - tPrimary.width) / 2); + tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); + this.labelContainer.addChild(tPrimary); + } // Force a re-render so hover feedback is immediate this.shouldRedraw = true; if (this.renderer) { From 0fee20d9de177046907177f6eb76a9f67603ef62 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Fri, 14 Nov 2025 23:36:23 +0100 Subject: [PATCH 07/11] fix(styles): remove external font links for improved loading performance --- src/client/index.html | 9 +-------- src/client/styles/layout/header.css | 1 - 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/src/client/index.html b/src/client/index.html index 42fb78052..9706fd023 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -7,14 +7,7 @@ content="width=device-width, initial-scale=1.0, user-scalable=no" /> Terratomic (ALPHA) - - + diff --git a/src/client/styles/layout/header.css b/src/client/styles/layout/header.css index 00ab09b9f..5c2734b15 100644 --- a/src/client/styles/layout/header.css +++ b/src/client/styles/layout/header.css @@ -35,7 +35,6 @@ color: #fff; /* version badge color */ font-weight: 900; font-size: 1rem; /* 10% smaller than original 1.25rem */ - font-family: "Anton", "Bebas Neue", "Impact", sans-serif; /* Cold War feel */ letter-spacing: 1px; text-transform: uppercase; text-shadow: From a2784c3b9f70596dca25095809e5440f6756c91d Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 15 Nov 2025 00:37:10 +0100 Subject: [PATCH 08/11] refactor(upgrade-cost): replace structure upgrade cost fractions with multipliers for improved precision --- src/client/graphics/layers/StructureLayer.ts | 7 ++--- src/core/configuration/Config.ts | 5 ++-- src/core/configuration/DefaultConfig.ts | 27 +++++-------------- .../execution/UpgradeStructureExecution.ts | 11 +++++--- .../UpgradeStructureExecution.test.ts | 14 +++++----- 5 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 9e8f00bd1..07666685a 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -287,9 +287,10 @@ export class StructureLayer implements Layer { if (!me) return false; const cfg = this.game.config(); const baseCost = cfg.unitInfo(unitType).cost(me as any); - const num = BigInt(cfg.structureUpgradeCostNum(unitType)); - const den = BigInt(cfg.structureUpgradeCostDen(unitType)); - const upgradeCost = den === 0n ? baseCost : (baseCost * num) / den; + const multiplier = cfg.structureUpgradeCostMultiplier(unitType); + const scale = 100n; // fixed-point precision: 2 decimals + const scaledMultiplier = BigInt(Math.round(multiplier * Number(scale))); + const upgradeCost = (baseCost * scaledMultiplier) / scale; return me.gold() >= upgradeCost; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 6b075c195..5f9045c25 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -177,9 +177,8 @@ export interface Config { automationTroopRegenMultiplierNum(): number; automationTroopRegenMultiplierDen(): number; - // Structure upgrade cost fraction per structure type (e.g., 4/5 for 80%) - structureUpgradeCostNum(type: UnitType): number; - structureUpgradeCostDen(type: UnitType): number; + // Structure upgrade cost multiplier per structure type (e.g., 0.8 for 80%) + structureUpgradeCostMultiplier(type: UnitType): number; cargoPlaneGold(dist: number): Gold; cargoPlaneSpawnRate(numberOfAirplanes: number): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index c6cc072c2..ea33a2611 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1311,35 +1311,20 @@ export class DefaultConfig implements Config { automationTroopRegenMultiplierDen(): number { return 5; } - // --- Structure upgrade cost fractions --- - structureUpgradeCostNum(type: UnitType): number { + // --- Structure upgrade cost multipliers --- + structureUpgradeCostMultiplier(type: UnitType): number { switch (type) { case UnitType.City: case UnitType.Port: case UnitType.Hospital: case UnitType.Academy: - return 4; // Default 80% -> 4/5 + return 0.8; // Default 80% case UnitType.MissileSilo: - return 1; // Missile silo: 50% -> 1/2 + return 0.2; // Missile silo: 20% case UnitType.SAMLauncher: - return 2; // SAM: 40% -> 2/5 + return 0.4; // SAM: 40% default: - return 1; - } - } - structureUpgradeCostDen(type: UnitType): number { - switch (type) { - case UnitType.City: - case UnitType.Port: - case UnitType.Hospital: - case UnitType.Academy: - return 5; // Default 80% -> 4/5 - case UnitType.MissileSilo: - return 2; // Missile silo: 50% -> 1/2 - case UnitType.SAMLauncher: - return 5; // SAM: 40% -> 2/5 - default: - return 1; + return 1.0; } } // --- Research system defaults --- diff --git a/src/core/execution/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts index 51f30d9b0..3bcd6aeb5 100644 --- a/src/core/execution/UpgradeStructureExecution.ts +++ b/src/core/execution/UpgradeStructureExecution.ts @@ -53,10 +53,13 @@ export class UpgradeStructureExecution implements Execution { return; } const baseCost: Gold = this.mg.unitInfo(unitType).cost(this.player); - const num = BigInt(this.mg.config().structureUpgradeCostNum(unitType)); - const den = BigInt(this.mg.config().structureUpgradeCostDen(unitType)); - const upgradeCost: Gold = - den === 0n ? baseCost : (baseCost * num) / den; + // Use decimal multiplier; compute BigInt-safe using fixed scale + const multiplier = this.mg + .config() + .structureUpgradeCostMultiplier(unitType); + const scale = 100n; // two decimal digits of precision + const scaledMultiplier = BigInt(Math.round(multiplier * Number(scale))); + const upgradeCost: Gold = (baseCost * scaledMultiplier) / scale; if (this.player.gold() < upgradeCost) { this._isActive = false; return; diff --git a/tests/core/execution/UpgradeStructureExecution.test.ts b/tests/core/execution/UpgradeStructureExecution.test.ts index c110f824c..f06f3ac15 100644 --- a/tests/core/execution/UpgradeStructureExecution.test.ts +++ b/tests/core/execution/UpgradeStructureExecution.test.ts @@ -14,8 +14,7 @@ describe("UpgradeStructureExecution", () => { cost: jest.fn().mockReturnValue(1_250_000n as Gold), }), config: jest.fn().mockReturnValue({ - structureUpgradeCostNum: jest.fn().mockImplementation(() => 4), - structureUpgradeCostDen: jest.fn().mockImplementation(() => 5), + structureUpgradeCostMultiplier: jest.fn().mockImplementation(() => 0.8), }), } as unknown as jest.Mocked; @@ -62,19 +61,18 @@ describe("UpgradeStructureExecution", () => { expect(mockUnit.upgradeStructure).not.toHaveBeenCalled(); }); - it("charges 50% of base cost and upgrades a Missile Silo", () => { + it("charges 20% of base cost and upgrades a Missile Silo", () => { const { mockPlayer, mockGame, mockUnit } = makeMocks(UnitType.MissileSilo); - // Override config for silo to 1/2 + // Override config for silo to 0.2 (mockGame.config as jest.Mock).mockReturnValue({ - structureUpgradeCostNum: jest.fn().mockImplementation(() => 1), - structureUpgradeCostDen: jest.fn().mockImplementation(() => 2), + structureUpgradeCostMultiplier: jest.fn().mockImplementation(() => 0.2), }); const exec = new UpgradeStructureExecution(mockPlayer, mockUnit); exec.init(mockGame, 0); - // 50% of 1,250,000 = 625,000 - expect(mockPlayer.removeGold).toHaveBeenCalledWith(625_000n); + // 20% of 1,250,000 = 250,000 + expect(mockPlayer.removeGold).toHaveBeenCalledWith(250_000n); expect(mockUnit.upgradeStructure).toHaveBeenCalled(); }); From b22387cba664405552ceab132297347bfa39e6de Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 15 Nov 2025 00:48:55 +0100 Subject: [PATCH 09/11] feat(upgrade-structure): implement upgrade cost calculation and display for upgradeable structures --- src/client/graphics/layers/StructureLayer.ts | 302 ++++++++++++------- 1 file changed, 198 insertions(+), 104 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index 07666685a..a1d5cabd6 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -18,6 +18,7 @@ import { ToggleUpgradeModeEvent } from "../../events/ToggleUpgradeModeEvent"; import { UnitCooldownEndedEvent } from "../../events/UnitCooldownEndedEvent"; import { MouseMoveEvent, MouseUpEvent } from "../../InputHandler"; import { SendUpgradeStructureIntentEvent } from "../../Transport"; +import { renderNumber } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; class StructureRenderInfo { @@ -172,6 +173,8 @@ export class StructureLayer implements Layer { // Force redraw so highlight state applies instantly. this.shouldRedraw = true; this.updateHighlights(); + // Rebuild price labels when toggling upgrade mode + this.updateLabels(); if (this.renderer) this.renderer.render(this.stage); }); } @@ -294,6 +297,41 @@ export class StructureLayer implements Layer { return me.gold() >= upgradeCost; } + // Compute raw upgrade cost for a given structure type for the current player + private computeUpgradeCostForType(unitType: UnitType): bigint { + const me = this.game.myPlayer(); + if (!me) return 0n; + const cfg = this.game.config(); + const baseCost = cfg.unitInfo(unitType).cost(me as any); + const multiplier = cfg.structureUpgradeCostMultiplier(unitType); + const scale = 100n; // fixed-point precision: 2 decimals + const scaledMultiplier = BigInt(Math.round(multiplier * Number(scale))); + const upgradeCost = (baseCost * scaledMultiplier) / scale; + return upgradeCost; + } + + // Compact gold formatter using k/m lowercase suffixes + private formatGoldCompact(amount: bigint): string { + // Reuse renderNumber for thresholds, then lowercase the suffix + const s = renderNumber(amount).replace("K", "k").replace("M", "m"); + return s; + } + + private isUpgradeableStructure(unit: UnitView): boolean { + if ( + unit.type() !== UnitType.City && + unit.type() !== UnitType.Port && + unit.type() !== UnitType.Hospital && + unit.type() !== UnitType.Academy && + unit.type() !== UnitType.MissileSilo && + unit.type() !== UnitType.SAMLauncher + ) + return false; + if (unit.type() === UnitType.MissileSilo && unit.level() >= 3) return false; + if (unit.type() === UnitType.SAMLauncher && unit.level() >= 3) return false; + return true; + } + private updateHighlights() { const affordableCity = this.canAffordUpgradeForType(UnitType.City); const affordablePort = this.canAffordUpgradeForType(UnitType.Port); @@ -661,24 +699,7 @@ export class StructureLayer implements Layer { const me = this.game.myPlayer(); if (!me) return false; if (unit.type() === UnitType.Construction) return false; - // Upgrades apply to City, Port, Hospital, Academy, Missile Silo - if ( - unit.type() !== UnitType.City && - unit.type() !== UnitType.Port && - unit.type() !== UnitType.Hospital && - unit.type() !== UnitType.Academy && - unit.type() !== UnitType.MissileSilo && - unit.type() !== UnitType.SAMLauncher - ) - return false; - // Do not highlight missile silos at max level (3) - if (unit.type() === UnitType.MissileSilo && unit.level() >= 3) { - return false; - } - // Do not highlight SAM launchers at max level (3) - if (unit.type() === UnitType.SAMLauncher && unit.level() >= 3) { - return false; - } + if (!this.isUpgradeableStructure(unit)) return false; return unit.owner().id() === me.id() && this.canAffordUpgrade(unit); } @@ -913,97 +934,170 @@ export class StructureLayer implements Layer { private updateLabels() { // Clear existing labels this.labelContainer.removeChildren(); + + // 1) If hovering a structure, show its levels ABOVE (existing behavior) const unit = this.hoveredStructure; - if (!unit || unit.type() === UnitType.Construction) return; - const levels = this.structureLevels.get(unit.id()); - if (!levels) return; + if (unit && unit.type() !== UnitType.Construction) { + const levels = this.structureLevels.get(unit.id()); + if (levels) { + const tile = unit.tile(); + const worldX = this.game.x(tile); + const worldY = this.game.y(tile); + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, worldY), + ); + const shape: BgShape = + STRUCTURE_BG_SHAPES[unit.type() as UnitType] ?? "circle"; + const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; + const scale = this.iconScreenScale(); - const tile = unit.tile(); - const worldX = this.game.x(tile); - const worldY = this.game.y(tile); - const screenPos = this.transformHandler.worldToScreenCoordinates( - new Cell(worldX, worldY), - ); + const baseColorStr = this.relationshipColorHexStr(unit); // "#RRGGBB" + const baseRaw = baseColorStr.replace(/^#/, ""); + const secondaryRaw = colord(`#${baseRaw}`) + .desaturate(0.2) + .lighten(0.35) + .toHex() + .replace(/^#/, ""); + const baseFill = parseInt(baseRaw, 16); + const secondaryFill = parseInt(secondaryRaw, 16); + const fontSize = Math.max(10, Math.round(iconDim * scale * 0.55)); + const stylePrimary = new PIXI.TextStyle({ + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + fontSize, + fontWeight: "600", + fill: baseFill, + align: "center", + }); + const styleSecondary = new PIXI.TextStyle({ + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + fontSize, + fontWeight: "600", + fill: secondaryFill, + align: "center", + }); - // Determine icon size for offset - const shape: BgShape = - STRUCTURE_BG_SHAPES[unit.type() as UnitType] ?? "circle"; - const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; - const scale = this.iconScreenScale(); - - // Build texts - const baseColorStr = this.relationshipColorHexStr(unit); // "#RRGGBB" - const baseRaw = baseColorStr.replace(/^#/, ""); - const secondaryRaw = colord(`#${baseRaw}`) - .desaturate(0.2) - .lighten(0.35) - .toHex() - .replace(/^#/, ""); - // Use numeric fills (PIXIs accepts number) to avoid string parsing edge cases - const baseFill = parseInt(baseRaw, 16); - const secondaryFill = parseInt(secondaryRaw, 16); - const fontSize = Math.max(10, Math.round(iconDim * scale * 0.55)); - const stylePrimary = new PIXI.TextStyle({ - fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", - fontSize, - fontWeight: "600", - fill: baseFill, - align: "center", - }); - const styleSecondary = new PIXI.TextStyle({ - fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", - fontSize, - fontWeight: "600", - fill: secondaryFill, - align: "center", - }); + const tPrimary = new PIXI.Text(String(levels.primary), stylePrimary); + const showSecondary = (levels.secondary ?? 0) > 0; + const tSecondary = showSecondary + ? new PIXI.Text(String(levels.secondary), styleSecondary) + : null; + const gap = Math.round(fontSize * 0.4); + const paddingX = Math.round(fontSize * 0.5); + const paddingY = Math.round(fontSize * 0.35); + const contentWidth = showSecondary + ? tPrimary.width + (tSecondary?.width ?? 0) + gap + : tPrimary.width; + const contentHeight = showSecondary + ? Math.max(tPrimary.height, tSecondary!.height) + : tPrimary.height; + const pillWidth = contentWidth + paddingX * 2; + const pillHeight = contentHeight + paddingY * 2; + const bg = new PIXI.Graphics(); + const bgX = Math.round(screenPos.x - pillWidth / 2); + const bgY = Math.round( + screenPos.y - + (iconDim * scale) / 2 - + pillHeight - + Math.max(4, Math.round(6 * scale)), + ); + bg.roundRect( + bgX, + bgY, + pillWidth, + pillHeight, + Math.min(14, fontSize), + ).fill({ + color: 0x000000, + alpha: 0.55, + }); + this.labelContainer.addChild(bg); + if (showSecondary && tSecondary) { + tPrimary.x = bgX + paddingX; + tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); + tSecondary.x = tPrimary.x + tPrimary.width + gap; + tSecondary.y = bgY + Math.round((pillHeight - tSecondary.height) / 2); + this.labelContainer.addChild(tPrimary, tSecondary); + } else { + tPrimary.x = bgX + Math.round((pillWidth - tPrimary.width) / 2); + tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); + this.labelContainer.addChild(tPrimary); + } + } + } - const tPrimary = new PIXI.Text(String(levels.primary), stylePrimary); - const showSecondary = (levels.secondary ?? 0) > 0; - const tSecondary = showSecondary - ? new PIXI.Text(String(levels.secondary), styleSecondary) - : null; - // Measure and layout - const gap = Math.round(fontSize * 0.4); - const paddingX = Math.round(fontSize * 0.5); - const paddingY = Math.round(fontSize * 0.35); - const contentWidth = showSecondary - ? tPrimary.width + (tSecondary?.width ?? 0) + gap - : tPrimary.width; - const contentHeight = showSecondary - ? Math.max(tPrimary.height, tSecondary!.height) - : tPrimary.height; - const pillWidth = contentWidth + paddingX * 2; - const pillHeight = contentHeight + paddingY * 2; - const bg = new PIXI.Graphics(); - const bgX = Math.round(screenPos.x - pillWidth / 2); - const bgY = Math.round( - screenPos.y - - (iconDim * scale) / 2 - - pillHeight - - Math.max(4, Math.round(6 * scale)), - ); - // PIXI v8+: use the new Graphics fill API instead of beginFill/endFill - bg.roundRect(bgX, bgY, pillWidth, pillHeight, Math.min(14, fontSize)).fill({ - color: 0x000000, - alpha: 0.55, - }); - this.labelContainer.addChild(bg); - - // Position texts inside pill - if (showSecondary && tSecondary) { - tPrimary.x = bgX + paddingX; - tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); - tSecondary.x = tPrimary.x + tPrimary.width + gap; - tSecondary.y = bgY + Math.round((pillHeight - tSecondary.height) / 2); - this.labelContainer.addChild(tPrimary, tSecondary); - } else { - // Center the single primary value - tPrimary.x = bgX + Math.round((pillWidth - tPrimary.width) / 2); - tPrimary.y = bgY + Math.round((pillHeight - tPrimary.height) / 2); - this.labelContainer.addChild(tPrimary); + // 2) In upgrade mode, show UPGRADE PRICE BELOW for all upgradeable structures owned by me + if (this.upgradeMode) { + const me = this.game.myPlayer(); + if (me) { + // Style for price labels + const priceFontSizeBase = 12; + for (const r of this.renders) { + const u = r.unit; + if (!u.isActive()) continue; + if (u.owner() !== me) continue; + if (!this.isUpgradeableStructure(u)) continue; + + const tile = u.tile(); + const worldX = this.game.x(tile); + const worldY = this.game.y(tile); + const screenPos = this.transformHandler.worldToScreenCoordinates( + new Cell(worldX, worldY), + ); + const shape: BgShape = + STRUCTURE_BG_SHAPES[u.type() as UnitType] ?? "circle"; + const iconDim = ICON_SIZES[shape] ?? ICON_SIZE; + const scale = this.iconScreenScale(); + + const fontSize = Math.max( + 10, + Math.round(iconDim * scale * 0.5 || priceFontSizeBase), + ); + // Match the primary level label color (relationship color), which is green for self + const baseColorStr = this.relationshipColorHexStr(u); // "#RRGGBB" + const baseRaw = baseColorStr.replace(/^#/, ""); + const baseFill = parseInt(baseRaw, 16); + const style = new PIXI.TextStyle({ + fontFamily: + "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + fontSize, + fontWeight: "600", + fill: baseFill, + align: "center", + }); + const priceText = this.formatGoldCompact( + this.computeUpgradeCostForType(u.type()), + ); + const t = new PIXI.Text(priceText, style); + + const paddingX = Math.round(fontSize * 0.5); + const paddingY = Math.round(fontSize * 0.35); + const pillWidth = t.width + paddingX * 2; + const pillHeight = t.height + paddingY * 2; + const bg = new PIXI.Graphics(); + const gapBelow = Math.max(4, Math.round(6 * scale)); + const bgX = Math.round(screenPos.x - pillWidth / 2); + const bgY = Math.round( + screenPos.y + (iconDim * scale) / 2 + gapBelow, + ); + bg.roundRect( + bgX, + bgY, + pillWidth, + pillHeight, + Math.min(14, fontSize), + ).fill({ + color: 0x000000, + alpha: 0.55, + }); + this.labelContainer.addChild(bg); + t.x = bgX + Math.round((pillWidth - t.width) / 2); + t.y = bgY + Math.round((pillHeight - t.height) / 2); + this.labelContainer.addChild(t); + } + } } - // Force a re-render so hover feedback is immediate + + // Request redraw after rebuilding labels this.shouldRedraw = true; if (this.renderer) { this.renderer.render(this.stage); From 0af116bf691ee24dc9f5e0d57b001b5410afb089 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 15 Nov 2025 00:52:54 +0100 Subject: [PATCH 10/11] feat(upgrade-structure): allow upgrades for SAM Launcher in upgrade_structure execution --- src/core/execution/ExecutionManager.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 29a5fb14a..15d63ea92 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -150,13 +150,14 @@ export class Executor { case "upgrade_structure": { const unit = player.units().find((u) => u.id() === intent.unitId); if (!unit || unit.owner() !== player) return new NoOpExecution(); - // Allow upgrades for City, Port, Hospital, Academy + // Allow upgrades for City, Port, Hospital, Academy, Missile Silo, SAM Launcher const allowed = intent.unitType === UnitType.City || intent.unitType === UnitType.Port || intent.unitType === UnitType.Hospital || intent.unitType === UnitType.Academy || - intent.unitType === UnitType.MissileSilo; + intent.unitType === UnitType.MissileSilo || + intent.unitType === UnitType.SAMLauncher; if (!allowed || unit.type() !== intent.unitType) { return new NoOpExecution(); } From c45d7ba569792fde2eb3003c273b4516b9f0cafe Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Sat, 15 Nov 2025 00:53:23 +0100 Subject: [PATCH 11/11] fix(structure-layer): update label color based on upgrade affordability --- src/client/graphics/layers/StructureLayer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts index a1d5cabd6..32d0cdad4 100644 --- a/src/client/graphics/layers/StructureLayer.ts +++ b/src/client/graphics/layers/StructureLayer.ts @@ -1052,16 +1052,18 @@ export class StructureLayer implements Layer { 10, Math.round(iconDim * scale * 0.5 || priceFontSizeBase), ); - // Match the primary level label color (relationship color), which is green for self - const baseColorStr = this.relationshipColorHexStr(u); // "#RRGGBB" + // Use green (self relationship color) only when affordable; otherwise white + const baseColorStr = this.relationshipColorHexStr(u); // "#RRGGBB" (self => green) const baseRaw = baseColorStr.replace(/^#/, ""); const baseFill = parseInt(baseRaw, 16); + const affordable = this.canAffordUpgradeForType(u.type()); + const fillColor = affordable ? baseFill : 0xffffff; const style = new PIXI.TextStyle({ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", fontSize, fontWeight: "600", - fill: baseFill, + fill: fillColor, align: "center", }); const priceText = this.formatGoldCompact(