diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts
index da9814e82..32d0cdad4 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 {
@@ -85,6 +86,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 +164,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);
}
@@ -170,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);
});
}
@@ -285,25 +290,63 @@ 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;
}
+ // 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);
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 +354,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 +365,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 +388,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 +404,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 +415,7 @@ export class StructureLayer implements Layer {
this.lastAffordableForUpgradeHospital = affordableHospital;
this.lastAffordableForUpgradeAcademy = affordableAcademy;
this.lastAffordableForUpgradeSilo = affordableSilo;
+ this.lastAffordableForUpgradeSAM = affordableSAM;
this.shouldRedraw = true;
}
@@ -379,7 +428,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 +527,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 +584,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
@@ -647,19 +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
- )
- return false;
- // Do not highlight missile silos at max level (3)
- if (unit.type() === UnitType.MissileSilo && unit.level() >= 3) {
- return false;
- }
+ if (!this.isUpgradeableStructure(unit)) return false;
return unit.owner().id() === me.id() && this.canAffordUpgrade(unit);
}
@@ -796,7 +836,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 +847,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(
@@ -886,83 +934,172 @@ 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 tSecondary = new PIXI.Text(String(levels.secondary), styleSecondary);
- // 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 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
- 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);
- // Force a re-render so hover feedback is immediate
+ // 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),
+ );
+ // 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: fillColor,
+ 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);
+ }
+ }
+ }
+
+ // Request redraw after rebuilding labels
this.shouldRedraw = true;
if (this.renderer) {
this.renderer.render(this.stage);
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/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:
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,
diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts
index 4cb12e0d9..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;
@@ -232,7 +231,10 @@ export interface Config {
nukeAllianceBreakThreshold(): number;
defaultNukeSpeed(): number;
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 c83073861..ea33a2611 100644
--- a/src/core/configuration/DefaultConfig.ts
+++ b/src/core/configuration/DefaultConfig.ts
@@ -1193,11 +1193,20 @@ export class DefaultConfig implements Config {
}
defaultNukeTargetableRange(): number {
- return 120;
+ return 150;
}
defaultSamRange(): number {
- return 80;
+ 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;
}
// Humans can be population, soldiers attacking, soldiers in boat etc.
@@ -1302,31 +1311,20 @@ export class DefaultConfig implements Config {
automationTroopRegenMultiplierDen(): number {
return 5;
}
- // --- Structure upgrade cost fractions ---
- structureUpgradeCostNum(type: UnitType): number {
- switch (type) {
- case UnitType.City:
- case UnitType.Port:
- case UnitType.Hospital:
- case UnitType.Academy:
- return 4; // Default 80% -> 4/5
- case UnitType.MissileSilo:
- return 1; // Missile silo: 50% -> 1/2
- default:
- return 1;
- }
- }
- structureUpgradeCostDen(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 5; // Default 80% -> 4/5
+ return 0.8; // Default 80%
case UnitType.MissileSilo:
- return 2; // Missile silo: 50% -> 1/2
+ return 0.2; // Missile silo: 20%
+ case UnitType.SAMLauncher:
+ return 0.4; // SAM: 40%
default:
- return 1;
+ return 1.0;
}
}
// --- Research system defaults ---
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();
}
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..12c4124b2 100644
--- a/src/core/execution/SAMLauncherExecution.ts
+++ b/src/core/execution/SAMLauncherExecution.ts
@@ -11,6 +11,128 @@ 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 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.effectiveSamRange() ** 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.effectiveSamRange() * 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 +141,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 +162,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 +203,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 +242,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 +252,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 +294,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) {
@@ -224,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,
@@ -302,6 +406,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/UpgradeStructureExecution.ts b/src/core/execution/UpgradeStructureExecution.ts
index de08cd987..3bcd6aeb5 100644
--- a/src/core/execution/UpgradeStructureExecution.ts
+++ b/src/core/execution/UpgradeStructureExecution.ts
@@ -40,21 +40,26 @@ 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;
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/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..b47da16e2 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
@@ -249,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;
@@ -555,6 +574,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/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();
});
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
+ });
+});