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 + }); +});