diablo2-web/src/game/engine/missile-engine.ts

2099 lines
71 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c `UNIT_MISSILE` Runtime (`MissileEngine`).
*
* Implements:
* - `Missiles.txt` trajectory & frame lifecycle (`Vel`, `MaxVel`, `Accel`, `Range + (slvl - 1) * LevRange`).
* - `NextHit` & `NextDelay` per-target immunity window (`lastNextDelayTickByTargetId`) so
* multi-projectile skills (`Multiple Shot`, `Teeth`, `Charged Bolt`, `Nova`, `Shock Web`,
* `Tornado`, `Twister`, `Wake of Fire`) obey exact 1.13c `NextDelay` frames.
* - `Pierce` (`Pierce` column + Amazon `Pierce` `skillId=33` chance, capped at 4 pierces / 5 hits).
* - `pSrvDoFunc` (linear flight, spiral `Blessed Hammer`, homing `Guided Arrow`/`Bone Spirit`,
* radial bolt emitter `Frozen Orb` `pSrvDoFunc=15`, ground fire trail `Blaze`/`Fire Wall`/`Molten Boulder`).
* - `pSrvHitFunc` (`Fire Ball` radial splash `1`, `Holy Bolt` undead/ally heal `7`,
* `Chain Lightning`/`Lightning Strike`/`royalstrikechainlightning` `*12` chain jump,
* `Lightning Fury` `20` radial bolts, `Fist of the Heavens` `22` radial `holybolt`,
* `Frozen Orb` `29` final ice bolt nova).
*/
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
import { getBlizzardDropOffset } from './blizzard-table.ts'
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
import { calculateMeteorResidualFireDamage } from '../skills.ts'
export const ISO_GROUND_ASPECT_RATIO = 0.5
export interface StaticFieldResult {
readonly damage: number
readonly newHp: number
readonly immune: boolean
readonly blockedByFloor: boolean
}
/**
* Authentic 1.13c Static Field damage calculation with pre-check invariant and difficulty floors.
* Source: D2Game.dll VA 0x6FC62F13 (SKILLS_AuraCallback_StaticField) & DifficultyLevels.txt
*/
export function calculateStaticFieldDamage(
currentHp: number,
maxHp: number,
difficulty: 'normal' | 'nightmare' | 'hell' = 'normal',
lightningRes = 0,
): StaticFieldResult {
const minPct = difficulty === 'hell' ? 50 : difficulty === 'nightmare' ? 33 : 0
const hpFloor = difficulty === 'normal' ? 1 : Math.max(1, Math.floor((maxHp * minPct) / 100))
// 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField at 0x6FC62F13):
// If current HP is already at or below the difficulty floor, return 0 damage immediately!
if (currentHp <= hpFloor) {
return { damage: 0, newHp: currentHp, immune: false, blockedByFloor: true }
}
if (lightningRes >= 100) {
return { damage: 0, newHp: currentHp, immune: true, blockedByFloor: false }
}
let damage = Math.floor(currentHp * 0.25)
// Support negative resistance scaling (Lower Resist, Conviction, -% enemy lightning resistance).
// Clamped to [-100, 99]% per Diablo II resistance lower bound.
const clampedRes = Math.max(-100, Math.min(99, lightningRes))
damage = Math.floor((damage * (100 - clampedRes)) / 100)
// CRITICAL SAFETY INVARIANT: Non-lethal damage clamping MUST occur AFTER resistance scaling
// so Static Field NEVER deals lethal damage (newHp >= 1, damage <= currentHp - 1)
damage = Math.min(damage, Math.max(0, currentHp - 1))
return { damage, newHp: currentHp - damage, immune: false, blockedByFloor: false }
}
export function calculateStaticFieldRadiusYards(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 4 + lvl
}
export function calculateStaticFieldRadiusPx(slvl: number): number {
return calculateStaticFieldRadiusYards(slvl) * 40
}
/**
* Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48):
* EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10
* EMax: 20, EMaxLev1..5: 8, 9, 10, 11, 12
* 0 synergies in 1.13c.
*/
export function calculateNovaDamage(
slvl: number,
synergies?: {
readonly lightningMastery?: number | undefined
} | number,
lightningMastery?: number,
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = 1
let max = 20
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 6
max += b1 * 8
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 7
max += b2 * 9
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 8
max += b3 * 10
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 9
max += b4 * 11
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 10
max += b5 * 12
}
}
const masteryLvl =
typeof lightningMastery === 'number'
? lightningMastery
: typeof synergies === 'object' && synergies !== null
? synergies.lightningMastery ?? 0
: 0
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
return { min, max }
}
/**
* Authentic 1.13c Lightning (Skill 49) synergy multiplier:
* +8% lightning damage per hard point in Charged Bolt (38), Nova (48), and Chain Lightning (53).
*/
export function calculateLightningSynergyMultiplier(synergies: {
readonly chargedBolt?: number | undefined
readonly chargedBoltPoints?: number | undefined
readonly nova?: number | undefined
readonly novaPoints?: number | undefined
readonly chainLightning?: number | undefined
readonly chainLightningPoints?: number | undefined
}): number {
const points =
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
(synergies.nova ?? synergies.novaPoints ?? 0) +
(synergies.chainLightning ?? synergies.chainLightningPoints ?? 0)
return 1.0 + points * 0.08
}
/**
* Authentic 1.13c Lightning (Skill 49) 5-band damage scaling (Skills.txt row 51):
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
* EMax: 40, EMaxLev1..5: 8, 12, 20, 28, 36
* Synergy: +8% per hard point in Charged Bolt, Nova, Chain Lightning.
*/
export function calculateLightningDamage(
slvl: number,
synergies?: {
readonly chargedBolt?: number | undefined
readonly chargedBoltPoints?: number | undefined
readonly nova?: number | undefined
readonly novaPoints?: number | undefined
readonly chainLightning?: number | undefined
readonly chainLightningPoints?: number | undefined
readonly lightningMastery?: number | undefined
} | number,
lightningMastery?: number,
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = 1
let max = 40
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
max += b1 * 8
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
max += b2 * 12
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
max += b3 * 20
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
max += b4 * 28
}
if (lvl > 28) {
const b5 = lvl - 28
max += b5 * 36
}
}
const synMult =
typeof synergies === 'number'
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
: synergies
? calculateLightningSynergyMultiplier(synergies)
: 1.0
const masteryLvl =
typeof lightningMastery === 'number'
? lightningMastery
: typeof synergies === 'object' && synergies !== null
? synergies.lightningMastery ?? 0
: 0
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
return { min, max }
}
/**
* Authentic 1.13c Chain Lightning (Skill 53) synergy multiplier:
* +4% lightning damage per hard point in Charged Bolt (38), Nova (48), and Lightning (49).
*/
export function calculateChainLightningSynergyMultiplier(synergies: {
readonly chargedBolt?: number | undefined
readonly chargedBoltPoints?: number | undefined
readonly nova?: number | undefined
readonly novaPoints?: number | undefined
readonly lightning?: number | undefined
readonly lightningPoints?: number | undefined
}): number {
const points =
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
(synergies.nova ?? synergies.novaPoints ?? 0) +
(synergies.lightning ?? synergies.lightningPoints ?? 0)
return 1.0 + points * 0.04
}
/**
* Authentic 1.13c Chain Lightning (Skill 53) jumps calculation:
* 1.13c Skills.txt: Param5 = 5 (base jumps), calc1 = ln34 / 5 -> 5 + Math.floor(slvl / 5).
*/
export function calculateChainLightningJumps(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 5 + Math.floor(lvl / 5)
}
/**
* Authentic 1.13c Chain Lightning (Skill 53) 5-band damage scaling (Skills.txt row 55):
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
* EMax: 40, EMaxLev1..5: 11, 13, 15, 15, 15
* Synergy: +4% per hard point in Charged Bolt, Nova, Lightning.
*/
export function calculateChainLightningDamage(
slvl: number,
synergies?: {
readonly chargedBolt?: number | undefined
readonly chargedBoltPoints?: number | undefined
readonly nova?: number | undefined
readonly novaPoints?: number | undefined
readonly lightning?: number | undefined
readonly lightningPoints?: number | undefined
readonly lightningMastery?: number | undefined
} | number,
lightningMastery?: number,
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = 1
let max = 40
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
max += b1 * 11
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
max += b2 * 13
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
max += b3 * 15
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
max += b4 * 15
}
if (lvl > 28) {
const b5 = lvl - 28
max += b5 * 15
}
}
const synMult =
typeof synergies === 'number'
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
: synergies
? calculateChainLightningSynergyMultiplier(synergies)
: 1.0
const masteryLvl =
typeof lightningMastery === 'number'
? lightningMastery
: typeof synergies === 'object' && synergies !== null
? synergies.lightningMastery ?? 0
: 0
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
return { min, max }
}
/**
* Authentic 1.13c Charged Bolt (Skill 38) 32-entry discrete orthogonal jitter offset lookup table.
* Source: D2Common.dll VA 0x6FD85470 (PATH_ComputePathChargedBolt)
*/
export const GAT_CHARGED_BOLT_OFFSETS: readonly number[] = Object.freeze([
-1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1,
0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 0, 1, -1, 1,
])
/**
* Authentic 1.13c Charged Bolt (Skill 38) bolt count:
* min(24, slvl + 2)
*/
export function calculateChargedBoltCount(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return Math.min(24, lvl + 2)
}
/**
* Authentic 1.13c Charged Bolt (Skill 38) synergy multiplier:
* +6% lightning damage per hard point in Lightning (49).
*/
export function calculateChargedBoltSynergyMultiplier(synergies: {
readonly lightning?: number | undefined
readonly lightningPoints?: number | undefined
} | number): number {
if (typeof synergies === 'number') {
return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
}
const points = synergies?.lightning ?? synergies?.lightningPoints ?? 0
return 1.0 + points * 0.06
}
/**
* Authentic 1.13c Charged Bolt (Skill 38) 5-band damage scaling (Skills.txt row 40):
* Base: 2-4, lvl 2-8: +1/+1, lvl 9-16: +1/+1, lvl 17-22: +2/+2, lvl 23-28: +3/+3, lvl 29+: +4/+4.
* Synergy: +6% per hard point in Lightning (49).
*/
export function calculateChargedBoltDamage(
slvl: number,
synergies?: {
readonly lightning?: number | undefined
readonly lightningPoints?: number | undefined
readonly lightningMastery?: number | undefined
} | number,
lightningMastery?: number,
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = 2
let max = 4
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 1
max += b1 * 1
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 1
max += b2 * 1
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 2
max += b3 * 2
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 3
max += b4 * 3
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 4
max += b5 * 4
}
}
const synMult =
typeof synergies === 'number'
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
: synergies
? calculateChargedBoltSynergyMultiplier(synergies)
: 1.0
const masteryLvl =
typeof lightningMastery === 'number'
? lightningMastery
: typeof synergies === 'object' && synergies !== null
? synergies.lightningMastery ?? 0
: 0
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
return { min, max }
}
/**
* Authentic 1.13c Telekinesis (Skill 43) hard synergy to Energy Shield (Skill 58):
* Reduces Energy Shield damage-to-mana consumption ratio by 1/16 (0.0625) per hard point:
* ratio = (32 - Math.min(20, TK_blvl)) / 16.
* Level 0 -> 2.00, Level 8 -> 1.50, Level 16 -> 1.00, Level 20 -> 0.75.
*/
export function calculateEnergyShieldManaRatio(telekinesisBaseLevel: number): number {
const blvl = Number.isFinite(telekinesisBaseLevel) ? Math.max(0, Math.floor(telekinesisBaseLevel)) : 0
return (32 - Math.min(20, blvl)) / 16
}
/**
* Authentic 1.13c Telekinesis (Skill 43) combat damage (Skills.txt row 45):
* Deals 1-2 lightning damage base (slvl to slvl + 1).
*/
export function calculateTelekinesisDamage(slvl: number): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return { min: lvl, max: lvl + 1 }
}
/**
* Authentic 1.13c Telekinesis (Skill 43) remote item pickup whitelist:
* Allowed: Potions, scrolls, gold, ammo (arrows/bolts), keys.
* Forbidden: Equipment (weapons, armor, helms, shields, boots, gloves, belts, rings, amulets).
*/
export function isTelekinesisItemAllowed(item: any): boolean {
if (!item) return false
if (item.isGold === true) return true
const code = (item.code || item.item?.code || item.base?.code || item.id || item.item?.id || '').toLowerCase().trim()
const name = (item.name || item.item?.name || item.nameZh || item.item?.nameZh || '').toLowerCase()
const type = (item.type || item.item?.type || item.base?.type || item.category || '').toLowerCase()
if (code === 'gld' || code === 'gold' || item.isGold) return true
if (/^(hp[1-5]|mp[1-5]|rvs|rvl|vps|yps|wms|ant|thw|sta)$/.test(code)) return true
if (name.includes('potion') || name.includes('药剂') || name.includes('药水') || name.includes('活力') || name.includes('解毒') || name.includes('解冻')) return true
if (/^(tsc|isc|tbk|ibk)$/.test(code)) return true
if (name.includes('scroll') || name.includes('卷轴') || name.includes('tome') || name.includes('书')) return true
if (/^(aqv|cqv)$/.test(code)) return true
if (name.includes('arrow') || name.includes('bolt') || name.includes('箭矢') || name.includes('十字弓弹')) return true
if (code === 'key' || name.includes('key') || name.includes('钥匙')) return true
if (
type === 'potion' ||
type.includes('potion') ||
type === 'scroll' ||
type.includes('scroll') ||
type === 'gold' ||
type === 'ammo' ||
type.includes('ammo') ||
type.includes('arrow') ||
type.includes('bolt') ||
type === 'key'
) {
return true
}
return false
}
/**
* Authentic 1.13c Telekinesis (Skill 43) remote interactive object whitelist:
* Supports: chest, locked chest, waypoint, portal, shrine, door, well, urn, sarcophagus, casket, stash, barrel.
*/
export function isTelekinesisObjectAllowed(objectTypeOrName: string): boolean {
if (!objectTypeOrName) return false
const norm = objectTypeOrName.toLowerCase().trim()
return (
norm.includes('chest') ||
norm.includes('waypoint') ||
norm.includes('portal') ||
norm.includes('shrine') ||
norm.includes('door') ||
norm.includes('well') ||
norm.includes('urn') ||
norm.includes('casket') ||
norm.includes('stash') ||
norm.includes('barrel') ||
norm.includes('sarcophagus') ||
norm.includes('tomb')
)
}
export function hasLineOfSight(
x0: number,
y0: number,
x1: number,
y1: number,
terrain?: MissileTerrainCollision | ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined,
): boolean {
if (!terrain) return true
const dist = Math.hypot(x1 - x0, y1 - y0)
if (dist === 0) return true
const stepSize = 8
const steps = Math.max(1, Math.ceil(dist / stepSize))
let prevX = x0
let prevY = y0
for (let i = 1; i <= steps; i++) {
const t = Math.min(1, i / steps)
const curX = x0 + (x1 - x0) * t
const curY = y0 + (y1 - y0) * t
let blocked = false
if (typeof terrain === 'function') {
blocked = Boolean(terrain(curX, curY, prevX, prevY))
} else if (terrain.isMissileBlocked !== undefined) {
blocked = terrain.isMissileBlocked(curX, curY, prevX, prevY)
} else if (terrain.overlap !== undefined) {
blocked = terrain.overlap(curX, curY) > 0
}
if (blocked) {
return false
}
prevX = curX
prevY = curY
}
return true
}
/**
* Authentic D2Game 64-entry discrete circle lookup tables (radius 30).
* Replicated byte-for-byte from D2Game.dll 0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb)
* and 0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb).
*/
export const FROZEN_ORB_X_POSITIONS: readonly number[] = Object.freeze([
30, 29, 29, 28, 27, 26, 24, 23, 21, 19, 16, 14, 11, 8, 5, 2,
0, -2, -5, -8, -11, -14, -16, -19, -21, -23, -24, -26, -27, -28, -29, -29,
-30, -29, -29, -28, -27, -26, -24, -23, -21, -19, -16, -14, -11, -8, -5, -2,
0, 2, 5, 8, 11, 14, 16, 19, 21, 23, 24, 26, 27, 28, 29, 29,
])
export const FROZEN_ORB_Y_POSITIONS: readonly number[] = Object.freeze([
0, 2, 5, 8, 11, 14, 16, 19, 21, 23, 24, 26, 27, 28, 29, 29,
30, 29, 29, 28, 27, 26, 24, 23, 21, 19, 16, 14, 11, 8, 5, 2,
0, -2, -5, -8, -11, -14, -16, -19, -21, -23, -24, -26, -27, -28, -29, -29,
-30, -29, -29, -28, -27, -26, -24, -23, -21, -19, -16, -14, -11, -8, -5, -2,
])
export interface ActiveMissile {
readonly uid: string
readonly missileId: number
readonly name: string
readonly celFile: string
readonly sourceSkillId: number
readonly slvl: number
readonly owner: CombatUnitContext
readonly record: MissileRecord
x: number
y: number
vx: number
vy: number
angleRad: number
ageTicks: number
readonly maxRangeTicks: number
readonly nextHit: boolean
readonly nextDelayFrames: number
readonly canPierce: boolean
readonly pierceChancePct: number
pierceCount: number
readonly hitTargetIds: Set<string>
readonly dmgPacket: SUnitDmgPacket
chainRemaining: number
lastHitTargetId?: string | undefined
expired: boolean
subMissileIdx?: number
currentHitFrame?: number
isTurret?: boolean | undefined
fireCadenceTicks?: number | undefined
spawnMissileType?: string | undefined
altitude?: number | undefined
seed?: number | undefined
jitterIndex?: number | undefined
}
export interface MissileTerrainCollision {
readonly overlap?: ((x: number, y: number) => number) | undefined
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
}
export interface MissileStepResult {
readonly hits: {
readonly missileName: string
readonly targetId: string
readonly damage: number
readonly immune: boolean
}[]
readonly subMissilesSpawned: string[]
}
export class MissileEngine {
readonly registry: D2DataRegistry
private readonly missiles: ActiveMissile[] = []
private readonly nextDelayExpireByTarget = new Map<string, number>()
private uidCounter = 1
terrain?: MissileTerrainCollision | undefined
constructor(registry: D2DataRegistry, terrain?: MissileTerrainCollision) {
this.registry = registry
this.terrain = terrain
}
setTerrain(terrain?: MissileTerrainCollision): void {
this.terrain = terrain
}
spawnMissile(params: {
missileNameOrId: string | number
sourceSkillId: number
slvl: number
owner: CombatUnitContext
startX: number
startY: number
targetX: number
targetY: number
dmgPacket: SUnitDmgPacket
pierceChancePct?: number
chainCount?: number
chainRemaining?: number
lastHitTargetId?: string
isTurret?: boolean
fireCadenceTicks?: number
spawnMissileType?: string
maxRangeTicks?: number
altitude?: number
seed?: number
jitterIndex?: number
vx?: number
vy?: number
angleRad?: number
}): ActiveMissile | null {
const rec =
typeof params.missileNameOrId === 'number'
? this.registry.getMissileById(params.missileNameOrId)
: this.registry.getMissileByName(params.missileNameOrId)
const fallbackName = String(params.missileNameOrId).trim().toLowerCase()
const effectiveRec: MissileRecord =
rec ?? {
id: 0,
name: fallbackName || 'defaultmissile',
pCltDoFunc: 1,
pCltHitFunc: 0,
pSrvDoFunc: 1,
pSrvHitFunc: 0,
pSrvDmgFunc: 0,
srvCalc1: '',
param1: 0,
param2: 0,
param3: 0,
param4: 0,
param5: 0,
cltCalc1: '',
cParam1: 0,
cParam2: 0,
cParam3: 0,
cParam4: 0,
cParam5: 0,
sHitCalc1: '',
sHitPar1: 0,
sHitPar2: 0,
sHitPar3: 0,
cHitCalc1: '',
cHitPar1: 0,
cHitPar2: 0,
cHitPar3: 0,
dmgCalc1: '',
dParam1: 0,
dParam2: 0,
vel: 16,
maxVel: 24,
velLev: 0,
accel: 0,
range: 40,
levRange: 0,
light: 0,
flicker: 0,
red: 255,
green: 255,
blue: 255,
initSteps: 0,
activate: 0,
loopAnim: 0,
celFile: fallbackName || 'fireball',
animRate: 1024,
animLen: 8,
animSpeed: 16,
subLoop: false,
subStart: 0,
subStop: 0,
collideType: 3,
collideKill: true,
collideFriend: false,
lastCollide: false,
collision: true,
clientCol: true,
nextHit: false,
nextDelay: 0,
size: 2,
returnFire: false,
trans: 1,
alwaysExplode: false,
explosion: false,
town: true,
noUniqueMod: false,
noMultiShot: false,
holy: 0,
canSlow: false,
canDestroy: false,
toHit: false,
pierce: false,
skill: '',
hitShift: 8,
srcDamage: 0,
minDamage: 0,
minLevDam1: 0,
minLevDam2: 0,
minLevDam3: 0,
minLevDam4: 0,
minLevDam5: 0,
maxDamage: 0,
maxLevDam1: 0,
maxLevDam2: 0,
maxLevDam3: 0,
maxLevDam4: 0,
maxLevDam5: 0,
dmgSymPerCalc: '',
eType: 'fire',
eMin: 0,
eMinLev1: 0,
eMinLev2: 0,
eMinLev3: 0,
eMinLev4: 0,
eMinLev5: 0,
eMax: 0,
eMaxLev1: 0,
eMaxLev2: 0,
eMaxLev3: 0,
eMaxLev4: 0,
eMaxLev5: 0,
eDmgSymPerCalc: '',
eLen: 0,
eLevLen1: 0,
eLevLen2: 0,
eLevLen3: 0,
hitClass: 0,
numDirections: 16,
localBlood: 0,
damageRate: 0,
explosionMissile: '',
subMissile1: '',
subMissile2: '',
subMissile3: '',
hitSubMissile1: '',
hitSubMissile2: '',
hitSubMissile3: '',
hitSubMissile4: '',
cltSubMissile1: '',
cltSubMissile2: '',
cltSubMissile3: '',
cltHitSubMissile1: '',
cltHitSubMissile2: '',
cltHitSubMissile3: '',
cltHitSubMissile4: '',
raw: {},
}
const dx = params.targetX - params.startX
const dy = params.targetY - params.startY
const calculatedAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
const angleRad = params.angleRad ?? calculatedAngleRad
const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret')
const isStationary =
isTurret ||
effectiveRec.vel === 0 ||
effectiveRec.name.toLowerCase().startsWith('blizzard') ||
effectiveRec.name.toLowerCase().startsWith('meteor')
const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18)
const vx = params.vx !== undefined ? params.vx : (isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick)
const vy = params.vy !== undefined ? params.vy : (isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick)
const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35
const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0)
const maxRangeTicks = params.maxRangeTicks ?? (
isTurret
? 250 // 1.13c Hydra duration: 250 frames (10 seconds)
: calculatedRange
)
const missile: ActiveMissile = {
uid: `msl-${String(this.uidCounter++)}`,
missileId: effectiveRec.id,
name: effectiveRec.name,
celFile: effectiveRec.celFile || effectiveRec.name,
sourceSkillId: params.sourceSkillId,
slvl: params.slvl,
owner: params.owner,
record: effectiveRec,
x: params.startX,
y: params.startY,
vx,
vy,
angleRad,
ageTicks: 0,
maxRangeTicks,
nextHit: effectiveRec.nextHit,
nextDelayFrames: effectiveRec.nextDelay,
canPierce: effectiveRec.pierce || (params.pierceChancePct ?? 0) > 0,
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce ? 100 : 0),
pierceCount: 0,
hitTargetIds: new Set<string>(),
dmgPacket: params.dmgPacket,
chainRemaining: params.chainRemaining ?? params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
lastHitTargetId: params.lastHitTargetId,
expired: false,
isTurret,
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
...(params.fireCadenceTicks !== undefined
? { fireCadenceTicks: params.fireCadenceTicks }
: isTurret
? { fireCadenceTicks: 25 }
: {}),
...(params.spawnMissileType !== undefined
? { spawnMissileType: params.spawnMissileType }
: isTurret
? { spawnMissileType: 'hydra' }
: {}),
...(params.seed !== undefined ? { seed: params.seed } : {}),
...(params.jitterIndex !== undefined
? { jitterIndex: params.jitterIndex }
: params.seed !== undefined
? { jitterIndex: Math.abs(params.seed) % 32 }
: {}),
}
this.missiles.push(missile)
return missile
}
/**
* Check if a target unit is currently within its `NextDelay` immunity window.
*/
isTargetInNextDelay(targetId: string, currentTick: number): boolean {
const exp = this.nextDelayExpireByTarget.get(targetId)
return exp !== undefined && exp > currentTick
}
/**
* Step all active missiles by 1 tick (`25 Hz`), resolving movement (`pSrvDoFunc`),
* collisions (`pSrvHitFunc`), `NextDelay` gating, `Pierce`, and sub-missiles.
*/
tick(
currentTick: number = 0,
targets: readonly CombatUnitContext[] = [],
targetPositions: ReadonlyMap<string, { x: number; y: number }> = new Map(),
collision?: MissileTerrainCollision,
): MissileStepResult {
const hits: { missileName: string; targetId: string; damage: number; immune: boolean }[] = []
const subMissilesSpawned: string[] = []
for (const m of [...this.missiles]) {
if (m.expired) continue
m.ageTicks += 1
if (m.name === 'meteor' || m.name === 'meteortail') {
if (m.altitude === undefined) m.altitude = 60 * 25
m.altitude = Math.max(0, m.altitude - 25)
}
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying
// Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb):
// - 64-entry discrete circle lookup table (radius 30)
// - Trigger frequency: dwParam[0] (1 frame)
// - Angular step: dwParam[1] (19 directions per step, coprime with 64)
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
if (m.record.pSrvDoFunc === 15) {
const nFrames = Math.max(1, m.record.param1 || 1)
if (m.ageTicks % nFrames === 0 || (m.maxRangeTicks - m.ageTicks) % nFrames <= 0) {
const subName = m.record.subMissile1 || 'frozenorbbolt'
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
const offX = FROZEN_ORB_X_POSITIONS[idx]!
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
this.spawnMissile({
missileNameOrId: subName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x + offX * 10,
targetY: m.y + offY * 10,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subName)
const step = m.record.param2 || 19
m.subMissileIdx = (idx + step) % 64
}
}
// `pSrvDoFunc = 16` (`frozenorbnova`): authentic D2Game.0x6FC58340
// In the final frames before expiry (dwParam[0] = 6), every dwParam[1] = 2 frames,
// apply affine rotation & damping curl: x' = (x - y) / 2, y' = (x + y) / 2
if (m.record.pSrvDoFunc === 16) {
const remaining = m.maxRangeTicks - m.ageTicks
const threshold = m.record.param1 || 6
const nParam = Math.max(1, m.record.param2 || 2)
if (remaining < threshold && remaining % nParam === 0) {
const diff = m.vx - m.vy
const sum = m.vx + m.vy
m.vx = diff / 2
m.vy = sum / 2
}
}
// `pSrvDoFunc = 10` (`blizzardcenter`): emits falling shards across 64-entry circle table
// Authentic D2Game.0x6FC60CA0 (MISSMODE_SrvDo10_blizzardcenter):
// - Trigger frequency: dwParam[2] (4 frames, Param3 in Skills.txt)
// - Angular step: 13 (coprime with 64)
// - Radius: 7 subtiles
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
if (m.record.pSrvDoFunc === 10 || m.name === 'blizzardcenter') {
const cadence = Math.max(1, m.record.param3 || 4)
if (m.ageTicks % cadence === 0) {
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
const offset = getBlizzardDropOffset(idx, 7)
const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const
const shardName = shardVariants[Math.floor((m.ageTicks / cadence) - 1) % shardVariants.length]!
this.spawnMissile({
missileNameOrId: shardName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x + offset.dx,
startY: m.y + offset.dy,
targetX: m.x + offset.dx,
targetY: m.y + offset.dy,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(shardName)
m.subMissileIdx = (idx + 13) % 64
}
}
// Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target
const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret')
if (isTurretEmitter) {
const cadence = m.fireCadenceTicks ?? 25
if (m.ageTicks % cadence === 0) {
let closestTargetId: string | null = null
let closestDist = Infinity
const maxRangePx = 480 // 1.13c Range = 30, Vel = 16 -> 480 px
for (const target of targets) {
if (target.statList.getHp256() <= 0) continue
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const dx = pos.x - m.x
const dy = (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO
const dist = Math.hypot(dx, dy)
if (dist <= maxRangePx && dist < closestDist) {
closestDist = dist
closestTargetId = target.id
}
}
if (closestTargetId) {
const targetPos = targetPositions.get(closestTargetId) ?? { x: 0, y: 120 }
const subName = m.spawnMissileType || 'hydra'
this.spawnMissile({
missileNameOrId: subName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: targetPos.x,
targetY: targetPos.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subName)
}
}
}
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
// In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5.
if (isTurretEmitter) {
// Stationary turret does not move
} else if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') {
m.angleRad += 0.24
const radius = 14 + m.ageTicks * 4
m.x += Math.cos(m.angleRad) * (radius * 0.25)
m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5)
} else {
const prevX = m.x
const prevY = m.y
m.x += m.vx
m.y += m.vy
// Authentic 1.13c Charged Bolt (Skill 38 / missile 56 / pDoFunc 11):
// Discrete orthogonal jitter table: 32-entry lookup table gatChargedBoltOffsets
// applied perpendicularly to main propagation vector per step (2 subtiles/step) with individual low-seed PRNG.
if (m.name === 'chargedbolt' || m.sourceSkillId === 38 || m.record.pSrvDoFunc === 11 || m.record.pCltDoFunc === 11) {
const speed = Math.hypot(m.vx, m.vy)
if (speed > 0) {
const perpX = -m.vy / speed
const perpY = m.vx / speed
const idx = Math.abs((m.jitterIndex ?? m.ageTicks) % 32)
const offset = GAT_CHARGED_BOLT_OFFSETS[idx]! * 3
m.x += perpX * offset
m.y += perpY * (offset * ISO_GROUND_ASPECT_RATIO)
m.jitterIndex = (idx + 1) % 32
}
}
const effectiveTerrain = collision ?? this.terrain
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
// Continuous collision stepping between (prevX, prevY) and (m.x, m.y) to prevent tunneling through thin (<24px) walls
const moveDist = Math.hypot(m.vx, m.vy)
const stepSize = 8
const steps = Math.max(1, Math.ceil(moveDist / stepSize))
let blocked = false
let checkPrevX = prevX
let checkPrevY = prevY
for (let s = 1; s <= steps; s++) {
const t = s / steps
const curX = prevX + (m.x - prevX) * t
const curY = prevY + (m.y - prevY) * t
if (effectiveTerrain.isMissileBlocked !== undefined) {
if (effectiveTerrain.isMissileBlocked(curX, curY, checkPrevX, checkPrevY)) {
blocked = true
break
}
} else if (effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(curX, curY) > 0) {
blocked = true
break
}
checkPrevX = curX
checkPrevY = curY
}
if (blocked) {
m.expired = true
continue
}
}
}
// Check collision against alive enemy targets
if (m.name === 'meteorfire' && m.ageTicks % 5 === 0) {
m.hitTargetIds.clear()
}
const hitRadius = Math.max(24, (m.record.size || 2) * 14)
for (const target of targets) {
if (target.statList.getHp256() <= 0) continue
if (m.hitTargetIds.has(target.id)) continue
// Holy Bolt (`skillId = 101` or `fistoftheheavensbolt` `234`): only damages Undead (or Demons if FoH main bolt)
if (
(m.name.toLowerCase() === 'holybolt' || m.name.toLowerCase() === 'fistoftheheavensbolt') &&
!target.isUndead
) {
continue
}
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), Meteor Center (pSrvHitFunc === 14 / meteorcenter),
// and Hydra turret emitters do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
if (
m.record.pSrvDoFunc === 15 ||
m.record.pSrvDoFunc === 10 ||
m.name === 'blizzardcenter' ||
m.record.pSrvHitFunc === 14 ||
m.name === 'meteorcenter' ||
m.name === 'meteor' ||
m.name === 'meteortail' ||
isTurretEmitter
) {
continue
}
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const dist =
m.name === 'meteorfire'
? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO)
: Math.hypot(pos.x - m.x, pos.y - m.y)
if (dist <= hitRadius) {
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
continue
}
m.hitTargetIds.add(target.id)
if (m.nextDelayFrames > 0) {
this.nextDelayExpireByTarget.set(target.id, currentTick + m.nextDelayFrames)
}
const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket)
hits.push({
missileName: m.name,
targetId: target.id,
damage: dmgOut.totalDamage,
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
})
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
if (
m.record.pSrvHitFunc === 1 ||
m.record.pSrvHitFunc === 4 ||
m.record.pSrvHitFunc === 9 ||
m.record.pSrvHitFunc === 13
) {
for (const other of targets) {
if (other.id === target.id || other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= 85) {
const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket)
hits.push({
missileName: `${m.name}:splash`,
targetId: other.id,
damage: splashOut.totalDamage,
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
})
}
}
}
// `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568):
// Authentic 1.13c ping-pong chaining: excludes the target just hit (target.id),
// searches up to 20 subtiles (10 yards = 400 px), and leaps to the closest valid alive enemy.
if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) {
const searchRadiusPx = 400
let nextTarget: CombatUnitContext | undefined
let minDistance = Infinity
for (const cand of targets) {
if (cand.id === target.id || cand.statList.getHp256() <= 0) continue
const candPos = targetPositions.get(cand.id) ?? { x: 0, y: 120 }
const dist = Math.hypot(candPos.x - pos.x, candPos.y - pos.y)
if (dist <= searchRadiusPx && dist < minDistance) {
minDistance = dist
nextTarget = cand
}
}
if (nextTarget) {
const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 }
this.spawnMissile({
missileNameOrId: m.name,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: pos.x,
startY: pos.y,
targetX: nPos.x,
targetY: nPos.y,
dmgPacket: m.dmgPacket,
chainCount: m.chainRemaining - 1,
lastHitTargetId: target.id,
})
subMissilesSpawned.push(m.name)
}
}
// `pSrvHitFunc = 20` (`Lightning Fury` 35): release radial `lightningjavelin` bolts toward nearby enemies
if (m.record.pSrvHitFunc === 20 || m.sourceSkillId === 35) {
const subBolt = m.record.hitSubMissile1 || 'lightningjavelin'
for (const other of targets) {
if (other.id === target.id || other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
this.spawnMissile({
missileNameOrId: subBolt,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: pos.x,
startY: pos.y,
targetX: oPos.x,
targetY: oPos.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subBolt)
}
}
// `pSrvHitFunc = 22` (`Fist of the Heavens` 121): release radial `fistoftheheavensbolt` (`holybolt`)
if (m.record.pSrvHitFunc === 22 || m.sourceSkillId === 121) {
const subBolt = m.record.hitSubMissile1 || 'fistoftheheavensbolt'
for (const other of targets) {
if (other.id === target.id || other.statList.getHp256() <= 0) continue
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
this.spawnMissile({
missileNameOrId: subBolt,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: pos.x,
startY: pos.y,
targetX: oPos.x,
targetY: oPos.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subBolt)
}
}
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning)
const isInfinitePierce =
m.name === 'lightningbolt' ||
m.name === 'lightning' ||
(!m.record.collideKill && m.canPierce)
if (m.name === 'meteorfire') {
// Ground fire persists for its full duration and does not die on hit
} else if (isInfinitePierce) {
m.pierceCount += 1
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
m.pierceCount += 1
} else if (m.record.collideKill || !m.canPierce) {
if (
m.name.startsWith('blizzard') &&
!m.name.includes('explode') &&
m.name !== 'blizzardcenter'
) {
const expName = m.record.explosionMissile || 'blizzardexplode1'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
}
m.expired = true
break
}
}
}
if (m.ageTicks >= m.maxRangeTicks) {
// `pSrvHitFunc = 14` (`Meteor` detonation and ground fire patch cascade)
// Authentic D2Game 0x6FC5DEF0 (MISSMODE_SrvHit14_MeteorCenter) & 0x6FC5CEC0:
if (
(m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter') &&
(!m.currentHitFrame || m.currentHitFrame === 0)
) {
m.currentHitFrame = 1
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
const blastRadius = 120
for (const target of targets) {
if (target.statList.getHp256() <= 0) continue
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const inRange =
Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
if (inRange) {
const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket)
hits.push({
missileName: 'meteorexplode',
targetId: target.id,
damage: dmgOut.totalDamage,
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
})
}
}
// 2. Spawn explosion missile: meteorexplode (ID 103)
const expName = m.record.explosionMissile || 'meteorexplode'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
// 3. Spawn 18 meteorfire submissiles using 18-offset table
const subFireName = m.record.hitSubMissile1 || 'meteorfire'
const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15
const fireDmg = calculateMeteorResidualFireDamage(m.slvl)
const fireDmgPacket: SUnitDmgPacket = {
skillId: m.sourceSkillId,
attackKind: 'spell',
elemType: 'fire',
elemMin256: fireDmg.min * 8, // HitShift 3: << 3 = * 8
elemMax256: fireDmg.max * 8,
}
const effectiveTerrain = collision ?? this.terrain
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
const fx = m.x + off.x * 20
const fy = m.y + off.y * 10
if (effectiveTerrain !== undefined) {
const blocked =
effectiveTerrain.isMissileBlocked !== undefined
? effectiveTerrain.isMissileBlocked(fx, fy, m.x, m.y)
: effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(fx, fy) > 0
if (blocked) {
continue
}
}
this.spawnMissile({
missileNameOrId: subFireName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: fx,
startY: fy,
targetX: fx,
targetY: fy,
dmgPacket: fireDmgPacket,
maxRangeTicks: fireDuration,
})
subMissilesSpawned.push(subFireName)
}
}
// Blizzard falling shard ground impact explosion (Missiles 159..162)
if (
m.name.startsWith('blizzard') &&
!m.name.includes('explode') &&
m.name !== 'blizzardcenter' &&
(!m.currentHitFrame || m.currentHitFrame === 0)
) {
m.currentHitFrame = 1
const expName = m.record.explosionMissile || 'blizzardexplode1'
this.spawnMissile({
missileNameOrId: expName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
}
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
// - Triggers only on first frame of hit (currentHitFrame === 0)
// - Loop with step sHitPar1 = 4 over 64 directions -> exactly 16 frozenorbnova missiles!
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
if (m.record.pSrvHitFunc === 29 && (!m.currentHitFrame || m.currentHitFrame === 0)) {
m.currentHitFrame = 1
const subName = m.record.hitSubMissile1 || 'frozenorbnova'
const step = Math.max(1, m.record.sHitPar1 || 4)
for (let i = 0; i < 64; i += step) {
const offX = FROZEN_ORB_X_POSITIONS[i]!
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
this.spawnMissile({
missileNameOrId: subName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x + offX * 10,
targetY: m.y + offY * 10,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subName)
}
const explosionName = m.record.explosionMissile || 'frozenorbexplode'
this.spawnMissile({
missileNameOrId: explosionName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(explosionName)
}
m.expired = true
}
}
// Purge expired missiles
for (let i = this.missiles.length - 1; i >= 0; i -= 1) {
if (this.missiles[i]!.expired) {
this.missiles.splice(i, 1)
}
}
return { hits, subMissilesSpawned }
}
getActiveMissiles(): readonly ActiveMissile[] {
return this.missiles
}
/**
* Spawns authentic 1.13c 64-bolt circular Nova ring with 2:1 isometric ground foreshortening.
* Speed = 24, Range = 13 frames, NextDelay = 4 frames.
*/
spawnNovaRing(params: {
sourceSkillId?: number
slvl?: number
owner: CombatUnitContext
startX: number
startY: number
dmgPacket: SUnitDmgPacket
missileName?: string
}): ActiveMissile[] {
const spawned: ActiveMissile[] = []
const skillId = params.sourceSkillId ?? 48
const slvl = params.slvl ?? 1
const missileName = params.missileName ?? 'nova'
const speed = 24 // 1.13c Missiles.txt: Vel = 24
for (let i = 0; i < 64; i++) {
const angle = (i / 64) * 2 * Math.PI
const vx = Math.cos(angle) * speed
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
const m = this.spawnMissile({
missileNameOrId: missileName,
sourceSkillId: skillId,
slvl,
owner: params.owner,
startX: params.startX,
startY: params.startY,
targetX: params.startX + Math.cos(angle) * 100,
targetY: params.startY + Math.sin(angle) * 100 * ISO_GROUND_ASPECT_RATIO,
dmgPacket: params.dmgPacket,
vx,
vy,
angleRad: angle,
})
if (m) spawned.push(m)
}
return spawned
}
/**
* Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS.
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.
*/
executeStaticField(params: {
caster: CombatUnitContext
casterPos: { x: number; y: number }
slvl?: number
targets: readonly CombatUnitContext[]
targetPositions: ReadonlyMap<string, { x: number; y: number }>
difficulty?: 'normal' | 'nightmare' | 'hell'
terrain?: MissileTerrainCollision
}): {
hits: {
targetId: string
damage: number
immune: boolean
blockedByFloor: boolean
blockedByLos: boolean
}[]
radiusPx: number
} {
const slvl = params.slvl ?? 1
const radiusPx = calculateStaticFieldRadiusPx(slvl)
const difficulty = params.difficulty ?? 'normal'
const effectiveTerrain = params.terrain ?? this.terrain
const hits: {
targetId: string
damage: number
immune: boolean
blockedByFloor: boolean
blockedByLos: boolean
}[] = []
for (const target of params.targets) {
if (target.statList.getHp256() <= 0) continue
const pos = params.targetPositions.get(target.id) ?? { x: 0, y: 0 }
const dist = Math.hypot(pos.x - params.casterPos.x, pos.y - params.casterPos.y)
if (dist > radiusPx) continue
const los = hasLineOfSight(params.casterPos.x, params.casterPos.y, pos.x, pos.y, effectiveTerrain)
if (!los) {
hits.push({
targetId: target.id,
damage: 0,
immune: false,
blockedByFloor: false,
blockedByLos: true,
})
continue
}
const currentHp = target.statList.getHp()
const maxHp = target.statList.getMaxHp() || currentHp
const ltngRes = target.statList.getAccruedStat('lightresist') || 0
const res = calculateStaticFieldDamage(currentHp, maxHp, difficulty, ltngRes)
if (res.damage > 0) {
target.statList.setHp256(target.statList.getHp256() - res.damage * 256)
}
hits.push({
targetId: target.id,
damage: res.damage,
immune: res.immune,
blockedByFloor: res.blockedByFloor,
blockedByLos: false,
})
}
return { hits, radiusPx }
}
}
/**
* Standalone helper to spawn 64 radial Projectile objects for Nova with 2:1 isometric ground foreshortening.
*/
export function spawnNovaRing(
startX: number,
startY: number,
damage: number,
options?: {
skillId?: string
missileType?: string
fromPlayer?: boolean
}
): {
skillId: string
missileType: string
x: number
y: number
vx: number
vy: number
damage: number
ttl: number
fromPlayer: boolean
nextDelay: number
}[] {
const speed = 24 // 1.13c Missiles.txt: vel = 24 (600 px/s)
const skillId = options?.skillId ?? '48'
const missileType = options?.missileType ?? 'electricnova'
const fromPlayer = options?.fromPlayer ?? true
const bolts: {
skillId: string
missileType: string
x: number
y: number
vx: number
vy: number
damage: number
ttl: number
fromPlayer: boolean
nextDelay: number
}[] = []
for (let i = 0; i < 64; i++) {
const angle = (i / 64) * 2 * Math.PI
const vx = Math.cos(angle) * speed
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
bolts.push({
skillId,
missileType,
x: startX,
y: startY,
vx,
vy,
damage,
ttl: 13,
fromPlayer,
nextDelay: 4,
})
}
return bolts
}
export interface StaticFieldTarget {
readonly id: string
readonly x: number
readonly y: number
readonly currentHp: number
readonly maxHp: number
readonly lightResist?: number
}
/**
* Standalone helper to execute 1.13c Static Field on target records.
*/
export function executeStaticField(params: {
casterX: number
casterY: number
slvl: number
targets: readonly StaticFieldTarget[]
difficulty?: 'normal' | 'nightmare' | 'hell'
isBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | MissileTerrainCollision | undefined
}): {
radiusPx: number
hits: {
targetId: string
damage: number
newHp: number
immune: boolean
blockedByFloor: boolean
blockedByLos: boolean
}[]
} {
const radiusPx = calculateStaticFieldRadiusPx(params.slvl)
const difficulty = params.difficulty ?? 'normal'
const hits: {
targetId: string
damage: number
newHp: number
immune: boolean
blockedByFloor: boolean
blockedByLos: boolean
}[] = []
for (const t of params.targets) {
const dist = Math.hypot(t.x - params.casterX, t.y - params.casterY)
if (dist > radiusPx) continue
if (params.isBlocked && !hasLineOfSight(params.casterX, params.casterY, t.x, t.y, params.isBlocked)) {
hits.push({
targetId: t.id,
damage: 0,
newHp: t.currentHp,
immune: false,
blockedByFloor: false,
blockedByLos: true,
})
continue
}
const res = calculateStaticFieldDamage(t.currentHp, t.maxHp, difficulty, t.lightResist ?? 0)
hits.push({
targetId: t.id,
damage: res.damage,
newHp: res.newHp,
immune: res.immune,
blockedByFloor: res.blockedByFloor,
blockedByLos: false,
})
}
return { radiusPx, hits }
}
/**
* Authentic 1.13c Lightning Mastery (Skill 63) passive bonus percentage:
* Skills.txt row 63: Param1: 50, Param2: 12.
* Formula: 50 + (slvl - 1) * 12 percent.
* Level 1 -> 50%, Level 2 -> 62%, Level 10 -> 158%, Level 20 -> 278%.
*/
export function calculateLightningMasteryBonusPct(slvl: number): number {
if (!Number.isFinite(slvl) || slvl <= 0) return 0
const lvl = Math.floor(slvl)
return 50 + (lvl - 1) * 12
}
/**
* Authentic 1.13c Thunder Storm (Skill 57) duration in frames (Skills.txt row 57):
* Param1: 800, Param2: 200.
* Duration: 800 + (slvl - 1) * 200 frames (32s base + 8s/lvl).
*/
export function calculateThunderStormDuration(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 800 + (lvl - 1) * 200
}
/**
* Authentic 1.13c Thunder Storm (Skill 57) strike interval in frames:
* Param3: 25 (minimum floor), Param4: 100, Param6: 100.
* Standard 1.13c hyperbolic cadence formula:
* interval = Math.max(25, 125 - Math.floor((110 * slvl) / (slvl + 6))) frames.
* Level 1 -> 110 frames (4.4s), Level 10 -> 57 frames (2.28s), Level 20 -> 41 frames (1.64s).
*/
export function calculateThunderStormInterval(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return Math.max(25, 125 - Math.floor((110 * lvl) / (lvl + 6)))
}
/**
* Authentic 1.13c Thunder Storm (Skill 57) 5-band damage scaling (Skills.txt row 57):
* EMin: 1, EMinLev1..5: 10, 10, 11, 11, 11
* EMax: 100, EMaxLev1..5: 10, 10, 11, 11, 11
* 0 synergies in 1.13c.
* Multiplicative scaling with Lightning Mastery:
* Final = Base * (1 + MasteryBonusPct / 100)
*/
export function calculateThunderStormDamage(
slvl: number,
synergies?: {
readonly lightningMastery?: number | undefined
} | number,
lightningMastery?: number,
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let min = 1
let max = 100
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 10
max += b1 * 10
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 10
max += b2 * 10
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 11
max += b3 * 11
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 11
max += b4 * 11
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 11
max += b5 * 11
}
}
const masteryLvl =
typeof lightningMastery === 'number'
? lightningMastery
: typeof synergies === 'object' && synergies !== null
? synergies.lightningMastery ?? 0
: 0
const masteryBonus = masteryLvl > 0 ? calculateLightningMasteryBonusPct(masteryLvl) : 0
const synMult = typeof synergies === 'number' && Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
min = Math.floor(min * synMult * (1 + masteryBonus / 100))
max = Math.floor(max * synMult * (1 + masteryBonus / 100))
return { min, max }
}
/**
* Authentic 1.13c Energy Shield (Skill 58) duration in frames (Skills.txt row 58):
* Param1: 3600, Param2: 1500.
* Duration: 3600 + (slvl - 1) * 1500 frames (144s base + 60s/lvl).
*/
export function calculateEnergyShieldDuration(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 3600 + (lvl - 1) * 1500
}
/**
* Authentic 1.13c Energy Shield (Skill 58) damage absorption percentage (Skills.txt row 58):
* EMin: 20, EMinLev1: 5 (lvl 2-8), EMinLev2: 2 (lvl 9-16), EMinLev3..5: 1 (lvl 17+).
* Hard cap: 95% (achieved at level 40).
*/
export function calculateEnergyShieldAbsorbPct(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let pct = 20
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
pct += b1 * 5
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
pct += b2 * 2
}
if (lvl > 16) {
const b3 = lvl - 16
pct += b3 * 1
}
}
return Math.min(95, pct)
}
/**
* Authentic 1.13c Frozen Armor (Skill 40) % Enhanced Defense:
* Param1: 30 (% base), Param2: 5 (% per level).
* Formula: 30 + (slvl - 1) * 5.
* Level 1 -> 30%, Level 10 -> 75%, Level 20 -> 125%.
*/
export function calculateFrozenArmorDefense(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 30 + (lvl - 1) * 5
}
/**
* Authentic 1.13c Frozen Armor (Skill 40) duration in frames:
* Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn).
* Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250.
* If options.base1200 is set: 1200 + (slvl - 1) * 120 + synPoints * 120.
*/
export function calculateFrozenArmorDuration(
slvl: number,
synergies?: { shiverArmor?: number; chillingArmor?: number } | number,
options?: { base1200?: boolean },
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0),
)
if (options?.base1200) {
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
}
return 3000 + (lvl - 1) * 300 + synPoints * 250
}
/**
* Authentic 1.13c Frozen Armor (Skill 40) freeze length in frames (calc1):
* Param5: 30 (1.2s base), Param6: 3 (+0.12s/lvl), Param8: 5 (+5% freeze length per synergy point).
* Formula: Math.floor((30 + (slvl - 1) * 3) * (100 + synPoints * 5) / 100).
* Level 1 -> 30 frames (1.20s), Level 10 -> 57 frames (2.28s), Level 20 -> 87 frames (3.48s).
*/
export function calculateFrozenArmorFreezeLength(
slvl: number,
synergies?: { shiverArmor?: number; chillingArmor?: number } | number,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.shiverArmor ?? 0) + (synergies?.chillingArmor ?? 0),
)
const baseFrames = 30 + (lvl - 1) * 3
const synFactor = 100 + synPoints * 5
return Math.floor((baseFrames * synFactor) / 100)
}
/**
* Authentic 1.13c Shiver Armor (Skill 50) % Enhanced Defense:
* Param1: 45 (% base), Param2: 6 (% per level).
* Formula: 45 + (slvl - 1) * 6.
* Level 1 -> 45%, Level 10 -> 99%, Level 20 -> 159%.
*/
export function calculateShiverArmorDefense(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 45 + (lvl - 1) * 6
}
/**
* Authentic 1.13c Shiver Armor (Skill 50) duration in frames:
* Param3: 3000 (120s base), Param4: 300 (+12s/lvl), Param7: 250 (+10s/syn).
* Default 1.13c: 3000 + (slvl - 1) * 300 + synPoints * 250.
*/
export function calculateShiverArmorDuration(
slvl: number,
synergies?: { frozenArmor?: number; chillingArmor?: number } | number,
options?: { base1200?: boolean },
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0),
)
if (options?.base1200) {
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
}
return 3000 + (lvl - 1) * 300 + synPoints * 250
}
/**
* Authentic 1.13c Shiver Armor (Skill 50) 5-band cold damage scaling (HitShift = 7 -> divide by 2):
* EMin: 12, EMax: 16 (HitShift 7 -> 6 - 8 at lvl 1)
* EMinLev: [4, 6, 8, 10, 12], EMaxLev: [5, 7, 9, 11, 13]
* Synergy: +9% (or +11% if specified) per point in Frozen Armor / Chilling Armor.
*/
export function calculateShiverArmorDamage(
slvl: number,
synergies?: { frozenArmor?: number; chillingArmor?: number; synPctPerLvl?: number } | number,
options?: { synPctPerLvl?: number; synMultiplier?: number },
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let rawMin = 12
let rawMax = 16
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
rawMin += b1 * 4
rawMax += b1 * 5
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
rawMin += b2 * 6
rawMax += b2 * 7
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
rawMin += b3 * 8
rawMax += b3 * 9
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
rawMin += b4 * 10
rawMax += b4 * 11
}
if (lvl > 28) {
const b5 = lvl - 28
rawMin += b5 * 12
rawMax += b5 * 13
}
}
let min = rawMin / 2
let max = rawMax / 2
let synMultiplier = 1.0
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
synMultiplier = Math.max(0, options.synMultiplier)
} else if (typeof synergies === 'number') {
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
const pctPerLvl = options?.synPctPerLvl ?? 9
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
} else if (typeof synergies === 'object' && synergies !== null) {
const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.chillingArmor ?? 0))
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 9
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
}
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
return { min, max }
}
/**
* Authentic 1.13c Shiver Armor (Skill 50) chill length in frames:
* ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames (lvl 9-16), ELevLen3: 50 frames (lvl 17+).
* Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 500 frames (20.0s).
* Optional synergy: +7% chill length per synergy point.
*/
export function calculateShiverArmorChillLength(
slvl: number,
synergies?: { frozenArmor?: number; chillingArmor?: number } | number,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let frames = 100
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
frames += b2 * 25
}
if (lvl > 16) {
const b3 = lvl - 16
frames += b3 * 50
}
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.frozenArmor ?? 0) + (synergies?.chillingArmor ?? 0),
)
if (synPoints > 0) {
frames = Math.floor((frames * (100 + synPoints * 7)) / 100)
}
return frames
}
/**
* Authentic 1.13c Chilling Armor (Skill 60) % Enhanced Defense:
* Param1: 45 (% base), Param2: 5 (% per level).
* Formula: 45 + (slvl - 1) * 5.
* Level 1 -> 45%, Level 10 -> 90%, Level 20 -> 140%.
*/
export function calculateChillingArmorDefense(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 45 + (lvl - 1) * 5
}
/**
* Authentic 1.13c Chilling Armor (Skill 60) duration in frames:
* Param3: 3600 (144s base), Param4: 150 (+6s/lvl), Param7: 250 (+10s/syn).
* Default 1.13c: 3600 + (slvl - 1) * 150 + synPoints * 250.
*/
export function calculateChillingArmorDuration(
slvl: number,
synergies?: { frozenArmor?: number; shiverArmor?: number } | number,
options?: { base1200?: boolean },
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0),
)
if (options?.base1200) {
return Math.floor((1200 + (lvl - 1) * 120) * (1 + (synPoints * 10) / 100))
}
return 3600 + (lvl - 1) * 150 + synPoints * 250
}
/**
* Authentic 1.13c Chilling Armor (Skill 60) retaliatory ice bolt cold damage:
* EMin: 8, EMax: 12 (HitShift 7 -> 4 - 6 at lvl 1)
* EMinLev: [2, 4, 6, 8, 10], EMaxLev: [3, 5, 7, 9, 11]
* Synergy: +7% per point in Frozen Armor / Shiver Armor.
*/
export function calculateChillingArmorDamage(
slvl: number,
synergies?: { frozenArmor?: number; shiverArmor?: number; synPctPerLvl?: number } | number,
options?: { synPctPerLvl?: number; synMultiplier?: number },
): { min: number; max: number } {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let rawMin = 8
let rawMax = 12
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
rawMin += b1 * 2
rawMax += b1 * 3
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
rawMin += b2 * 4
rawMax += b2 * 5
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
rawMin += b3 * 6
rawMax += b3 * 7
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
rawMin += b4 * 8
rawMax += b4 * 9
}
if (lvl > 28) {
const b5 = lvl - 28
rawMin += b5 * 10
rawMax += b5 * 11
}
}
let min = rawMin / 2
let max = rawMax / 2
let synMultiplier = 1.0
if (typeof options?.synMultiplier === 'number' && Number.isFinite(options.synMultiplier)) {
synMultiplier = Math.max(0, options.synMultiplier)
} else if (typeof synergies === 'number') {
const synPoints = Number.isFinite(synergies) ? Math.max(0, synergies) : 0
const pctPerLvl = options?.synPctPerLvl ?? 7
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
} else if (typeof synergies === 'object' && synergies !== null) {
const synPoints = Math.max(0, (synergies.frozenArmor ?? 0) + (synergies.shiverArmor ?? 0))
const pctPerLvl = options?.synPctPerLvl ?? synergies.synPctPerLvl ?? 7
synMultiplier = 1.0 + (synPoints * pctPerLvl) / 100
}
min = Math.floor((min * synMultiplier + 1e-9) * 10) / 10
max = Math.floor((max * synMultiplier + 1e-9) * 10) / 10
return { min, max }
}
/**
* Authentic 1.13c Chilling Armor (Skill 60) chill length in frames:
* ELen: 100 frames base (4.0s). ELevLen1: 0, ELevLen2: 25 frames, ELevLen3: 25 frames.
* Level 1-8: 100 frames (4.0s), Level 10: 150 frames (6.0s), Level 20: 400 frames (16.0s).
* Optional synergy: +10% chill length per synergy point.
*/
export function calculateChillingArmorChillLength(
slvl: number,
synergies?: { frozenArmor?: number; shiverArmor?: number } | number,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let frames = 100
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
frames += b2 * 25
}
if (lvl > 16) {
const b3 = lvl - 16
frames += b3 * 25
}
const synPoints = Math.max(
0,
typeof synergies === 'number'
? synergies
: (synergies?.frozenArmor ?? 0) + (synergies?.shiverArmor ?? 0),
)
if (synPoints > 0) {
frames = Math.floor((frames * (100 + synPoints * 10)) / 100)
}
return frames
}
/**
* Standard Cold Armor base duration calculator (1200f base + 120f/lvl = 48s + 4.8s/lvl):
*/
export function calculateColdArmorDuration(
slvl: number,
baseFrames = 1200,
perLvlFrames = 120,
synBonusPct = 10,
synPoints = 0,
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const safeSyn = Math.max(0, synPoints)
return Math.floor((baseFrames + (lvl - 1) * perLvlFrames) * (1 + (safeSyn * synBonusPct) / 100))
}