diablo2-web/src/game/skills/registry.ts

1000 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II: Lord of Destruction v1.13c Zero-Conflict Skill Registry (`src/game/skills/registry.ts`).
*
* 1. Auto-discovers per-skill implementation modules in `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts`
* via `import.meta.glob('./impl/**\/*.ts', { eager: true })` so isolated `git worktree` skill branches
* add only their own file and never touch any shared registry file.
* 2. Provides complete 1.13c data-driven evaluation (`evaluateSkill113c`) and runtime execution
* (`executeSkill113c`) across all 221 player skills (`210` class skills + `11` universal skills)
* and `17` summoned pet/trap AI sub-skills (`IDs 281..338`), directly from `Patch_D2.mpq`.
*/
import type {
SkillEvalContext,
SkillEvalResult,
SkillExecContext,
SkillExecOutcome,
SkillModule,
} from './types.ts'
import {
compute3BandDuration,
compute5BandScaling,
computeSkillManaCost256,
evaluateCalc,
} from '../engine/calc-ast.ts'
import { FIXED_ONE } from '../engine/stat-list.ts'
import { computeCorpseExplosion113c, executeSUnitDmg } from '../engine/combat-pipeline.ts'
import {
calculateStaticFieldDamage,
calculateStaticFieldRadiusPx,
hasLineOfSight,
} from '../engine/missile-engine.ts'
const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>()
// Auto-discover any per-skill module under `./impl/**/*.ts` in Vite & Vitest
const globLoader = (
import.meta as unknown as {
glob?: <T>(pattern: string, opts: { eager: boolean }) => Record<string, T>
}
).glob
if (typeof globLoader === 'function') {
const discovered = globLoader<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>(
'./impl/**/*.ts',
{ eager: true },
)
for (const mod of Object.values(discovered)) {
const handler = mod.skillModule ?? mod.default
if (handler !== undefined && typeof handler.skillId === 'number') {
REGISTERED_SKILL_MODULES.set(handler.skillId, handler)
}
}
}
export function registerSkillModule(mod: SkillModule): void {
REGISTERED_SKILL_MODULES.set(mod.skillId, mod)
}
export function getRegisteredSkillModule(skillId: number): SkillModule | undefined {
return REGISTERED_SKILL_MODULES.get(skillId)
}
export function listRegisteredSkillModules(): SkillModule[] {
return Array.from(REGISTERED_SKILL_MODULES.values()).sort((a, b) => a.skillId - b.skillId)
}
/**
* Evaluate exact 1.13c numbers (`MinDmg`, `MaxDmg`, `ManaCost`, `ToHit`, `Radius`, `Duration`,
* `SynergyBonusPct`, `PetMax`, `CooldownFrames`) for any skill from `Skills.txt` + `Missiles.txt`.
*/
export function evaluateSkill113c(ctx: SkillEvalContext): SkillEvalResult {
const customMod = REGISTERED_SKILL_MODULES.get(ctx.skill.id)
if (customMod?.evaluate) {
return customMod.evaluate(ctx)
}
return evaluateSkillCore113c(ctx)
}
export function evaluateSkillCore113c(ctx: SkillEvalContext): SkillEvalResult {
const { registry, skill, slvl, blvl, statList } = ctx
const effLvl = Math.max(1, slvl)
const calcCtx = {
registry,
activeSkill: skill,
slvl: effLvl,
blvl,
ulvl: statList.getAccruedStat('level'),
getBaseSkillLevel: (id: number) => statList.getBaseSkillLevel(id),
getEffectiveSkillLevel: (id: number) => statList.getEffectiveSkillLevel(id),
getUnitStat: (statNameOrId: string | number, baseOnly?: boolean) =>
statList.getAccruedStat(statNameOrId, baseOnly),
}
// 1. Mana cost (`usmc` in 256-fixed-point and integer `manaCost`)
const { manaCost256, manaCost } = computeSkillManaCost256(skill, effLvl)
// 2. Attack Rating bonus (`toht`)
const toHitBonusPct = skill.toHitCalc
? evaluateCalc(skill.toHitCalc, calcCtx)
: skill.toHit + (effLvl - 1) * skill.toHitLev
// 3. Synergy percentage (`EDmgSymPerCalc` or `DmgSymPerCalc`)
const eSynPct = skill.eDmgSymPerCalc ? evaluateCalc(skill.eDmgSymPerCalc, calcCtx) : 0
const physSynPct = skill.dmgSymPerCalc ? evaluateCalc(skill.dmgSymPerCalc, calcCtx) : 0
// Dragon Claw (`260`) 1.13c synergy: +4% damage per `blvl` in Claw Mastery (`252`) (`Param8=4`)
const dragonClawSyn =
skill.id === 260 ? statList.getBaseSkillLevel(252) * (skill.param8 || 4) : 0
const synergyBonusPct = eSynPct !== 0 ? eSynPct : physSynPct + dragonClawSyn
// 4. Physical flat & %ED damage (`MinDam`..`MinLevDam5`, `MaxDam`..`MaxLevDam5`, `calc1`)
const basePhysMin = compute5BandScaling(
effLvl,
skill.minDam,
skill.minLevDam1,
skill.minLevDam2,
skill.minLevDam3,
skill.minLevDam4,
skill.minLevDam5,
)
const basePhysMax = compute5BandScaling(
effLvl,
skill.maxDam,
skill.maxLevDam1,
skill.maxLevDam2,
skill.maxLevDam3,
skill.maxLevDam4,
skill.maxLevDam5,
)
const physMinScaled = Math.trunc((basePhysMin * (100 + physSynPct)) / 100)
const physMaxScaled = Math.trunc((basePhysMax * (100 + physSynPct)) / 100)
const calc1Val = skill.calc1 ? evaluateCalc(skill.calc1, calcCtx) : 0
const physDamagePct =
skill.srcDam > 0 ? calc1Val + physSynPct + dragonClawSyn : physSynPct
// 5. Elemental / Magic / Poison damage (`EMin`..`EMinLev5`, `EMax`..`EMaxLev5`, or primary missile fallback)
let baseEMin = compute5BandScaling(
effLvl,
skill.eMin,
skill.eMinLev1,
skill.eMinLev2,
skill.eMinLev3,
skill.eMinLev4,
skill.eMinLev5,
)
let baseEMax = compute5BandScaling(
effLvl,
skill.eMax,
skill.eMaxLev1,
skill.eMaxLev2,
skill.eMaxLev3,
skill.eMaxLev4,
skill.eMaxLev5,
)
let elemHitShift = skill.hitShift
let elemType = skill.eType
const primaryMissileName = skill.srvMissile || skill.srvMissileA || skill.cltMissile || skill.cltMissileA
if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) {
const msl = registry.getMissileByName(primaryMissileName)
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
baseEMin = compute5BandScaling(
effLvl,
msl.eMin,
msl.eMinLev1,
msl.eMinLev2,
msl.eMinLev3,
msl.eMinLev4,
msl.eMinLev5,
)
baseEMax = compute5BandScaling(
effLvl,
msl.eMax,
msl.eMaxLev1,
msl.eMaxLev2,
msl.eMaxLev3,
msl.eMaxLev4,
msl.eMaxLev5,
)
elemHitShift = msl.hitShift
if (!elemType) elemType = msl.eType
}
}
const rawMin256 = baseEMin << elemHitShift
const rawMax256 = baseEMax << elemHitShift
const minElemDmg256 = Math.trunc((rawMin256 * (100 + eSynPct)) / 100)
const maxElemDmg256 = Math.trunc((rawMax256 * (100 + eSynPct)) / 100)
// 6. Duration (`auralencalc` or `ELen`..`ELevLen3` or 1.13c Assassin combo charge 375 frames)
let durationFrames = 0
if (skill.auraLenCalc) {
durationFrames = evaluateCalc(skill.auraLenCalc, calcCtx)
} else if (skill.eLen > 0 || skill.eLevLen1 > 0) {
const baseLen = compute3BandDuration(effLvl, skill.eLen, skill.eLevLen1, skill.eLevLen2, skill.eLevLen3)
const lenSyn = skill.eLenSymPerCalc ? evaluateCalc(skill.eLenSymPerCalc, calcCtx) : 0
durationFrames = Math.trunc((baseLen * (100 + lenSyn)) / 100)
} else if (skill.progressive) {
// 1.13c Assassin Martial Arts charge duration (`Param3 = 375` frames = 15s)
durationFrames = skill.param3 || 375
}
// 7. Radius (`aurarangecalc` or `calc2`/`Param1`)
const radiusSubtiles = skill.auraRangeCalc
? evaluateCalc(skill.auraRangeCalc, calcCtx)
: skill.param1 > 0 && skill.param2 > 0
? evaluateCalc('ln12', calcCtx)
: 0
// 8. State & Curse type
const stateName = skill.auraTgtState || skill.auraState || skill.passiveState || skill.state1
const stateRec = stateName ? registry.getStateByName(stateName) : undefined
const curseType =
stateRec?.cursetype ??
(skill.id === 86 ? 2 : skill.srvDoFunc === 30 || skill.id === 81 || skill.id === 264 || skill.id === 146 ? 1 : 0)
// 9. Pet max
const petMax = skill.petMax
? Math.max(1, evaluateCalc(skill.petMax, calcCtx))
: skill.petType.toLowerCase() === 'assassintrap'
? 5
: skill.summon
? 1
: 0
// 10. Cooldown (`delay` in frames, e.g. Hydra 40 frames in 1.13c)
const cooldownFrames = skill.delay ? evaluateCalc(skill.delay, calcCtx) : 0
return {
skillId: skill.id,
name: skill.name,
charClass: skill.charClass || 'uni',
slvl: effLvl,
blvl,
manaCost,
manaCost256,
toHitBonusPct,
physDamagePct,
srcDam: skill.srcDam,
minPhysDmg: physMinScaled,
maxPhysDmg: physMaxScaled,
elemType: elemType || (skill.srcDam > 0 ? 'phys' : 'mag'),
minElemDmg: minElemDmg256 >> 8,
maxElemDmg: maxElemDmg256 >> 8,
minElemDmg256,
maxElemDmg256,
durationFrames,
radiusSubtiles,
missileA: primaryMissileName,
auraState: stateName,
curseType,
summonMon: skill.summon,
petMax,
synergyBonusPct,
cooldownFrames,
}
}
/**
* Execute any of the 221 player skills (`210` class skills + `11` universal skills) or `17` pet sub-skills
* in the 1.13c runtime (`srvdofunc` + `srvmissile` + `States.txt` + `PetType.txt` + `SUnitDmg`).
*/
export function executeSkill113c(ctx: SkillExecContext): SkillExecOutcome {
const customMod = REGISTERED_SKILL_MODULES.get(ctx.skill.id)
if (customMod?.executeDo) {
return customMod.executeDo(ctx)
}
return executeSkillCore113c(ctx)
}
export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
const { skill, evalResult, caster, targets, targetPositions, corpses, missileEngine, auraScanner, summonManager, currentTick, targetX, targetY } = ctx
const missilesSpawned: string[] = []
const statesApplied: string[] = []
const petsSummoned: string[] = []
let corpsesConsumed = 0
let totalDamageDealt = 0
const notes: string[] = []
// Edge Case #5: When `srvDoFunc === 0` and `srvMissile` is set (`Fire Ball` 47, `Fire Bolt` 36,
// `Magic Arrow` 6, `Bone Spear` 84, `Frozen Orb` 64, `Lightning Fury` 35, `Holy Bolt` 101, etc.),
// `D2Game` executes `SKILLS_SrvDoDefaultMissile` (mapped to `srvDoFunc = 68` missile launch).
const effectiveDoFunc =
skill.srvDoFunc === 0 && (skill.srvMissile !== '' || skill.srvMissileA !== '')
? 68
: skill.srvDoFunc
// 1. Passive skills (`passive === true`)
if (skill.passive) {
const pState = skill.passiveState || `passive_${skill.name.toLowerCase().replace(/\s+/g, '_')}`
const stats: Record<string, number> = {}
const calcCtx = {
registry: ctx.registry,
activeSkill: skill,
slvl: evalResult.slvl,
blvl: evalResult.blvl,
getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id),
getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id),
}
for (const [st, clc] of [
[skill.passiveStat1, skill.passiveCalc1],
[skill.passiveStat2, skill.passiveCalc2],
[skill.passiveStat3, skill.passiveCalc3],
[skill.passiveStat4, skill.passiveCalc4],
[skill.passiveStat5, skill.passiveCalc5],
] as const) {
if (st && clc) stats[st.toLowerCase()] = evaluateCalc(clc, calcCtx)
}
// Barbarian Weapon Masteries (`127..129`, `134..136`): 1.13c critical strike `dm56` (`0..35%`)
if ([127, 128, 129, 134, 135, 136].includes(skill.id)) {
stats['passive_mastery_crit'] = evaluateCalc('dm56', calcCtx)
}
caster.stateBus.applyState({
stateNameOrId: pState,
sourceUnitId: caster.id,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
stats,
})
statesApplied.push(pState)
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: 0,
manaSpent256: 0,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes: ['passive_attached'],
}
}
// 2. Unsummon (`skillId = 3`, `srvDoFunc = 4`)
if (skill.id === 3 || effectiveDoFunc === 4) {
const dismissed = summonManager.unsummonPet()
if (dismissed) notes.push(`unsummoned:${dismissed.monId}`)
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: 4,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 3. Summons, Golems, Traps, Hydras, Vines, Spirits, Shadows, Decoy, Valkyrie, Bone Wall/Prison
const isSummonSkill =
skill.summon !== '' ||
[15, 16, 31, 45, 49, 56, 57, 58, 60, 62, 114, 115, 119, 144].includes(effectiveDoFunc)
if (isSummonSkill) {
const countToCreate = skill.id === 62 ? 3 : skill.id === 78 || skill.id === 88 ? 3 : 1
for (let i = 0; i < countToCreate; i += 1) {
const res = summonManager.createPet({
owner: caster,
skillId: skill.id,
slvl: evalResult.slvl,
x: targetX + i * 16,
y: targetY,
...(i === 0 ? { corpses } : {}),
})
if (res.created && res.pet) {
petsSummoned.push(res.pet.monId)
if (skill.id === 70 || skill.id === 80 || skill.id === 95) {
corpsesConsumed += 1
}
}
}
return {
skillId: skill.id,
name: skill.name,
executed: petsSummoned.length > 0,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 4. Paladin / Druid Auras (`aura === true` or `srvDoFunc === 65, 66, 81, 82`)
if (skill.aura || [65, 66, 81, 82].includes(effectiveDoFunc)) {
const src = auraScanner.setActiveAura(caster, skill.id, evalResult.slvl, currentTick)
if (src) {
const pulseAllies = ctx.allies ?? [caster]
const pulse = auraScanner.pulseAura(
src,
currentTick,
pulseAllies,
targets,
corpses,
ctx.isTown !== undefined ? { isTown: ctx.isTown } : undefined,
)
totalDamageDealt += pulse.pulseDamageDealt
corpsesConsumed += pulse.corpsesRedeemed
statesApplied.push(skill.auraState || skill.auraTgtState || skill.name.toLowerCase())
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 5. Corpse Explosion (`74` / `312` `srvDoFunc = 55`), Poison Explosion (`83` `srvDoFunc = 63`),
// Find Potion (`131` `69`), Find Item (`142` `72`), Grim Ward (`150` `75`)
if ([55, 63, 69, 72, 75].includes(effectiveDoFunc)) {
const availCorpse = corpses.find(c => !c.consumed)
if (availCorpse) {
availCorpse.consumed = true
corpsesConsumed += 1
if (effectiveDoFunc === 55) {
// 1.13c Corpse Explosion: 70%–120% of base monster HP (50% Phys + 50% Fire)
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
const ce = computeCorpseExplosion113c({
corpseBaseHp: availCorpse.baseMaxHp ?? 1000,
targetPhysRes: t.statList.getAccruedStat('damageresist'),
targetFireRes: t.statList.getAccruedStat('fireresist'),
convictionPierce: t.statList.getModifierBonus('conviction_pierce'),
lowerResistPierce: t.statList.getModifierBonus('lowerresist_pierce'),
})
const dmg256 = ce.finalTotalDamage * FIXED_ONE
t.statList.setHp256(t.statList.getHp256() - dmg256)
totalDamageDealt += ce.finalTotalDamage
}
} else if (effectiveDoFunc === 63) {
// Poison Explosion cloud
const msl = missileEngine.spawnMissile({
missileNameOrId: skill.srvMissile || 'poisonexplosioncloud',
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: targetX,
startY: targetY,
targetX,
targetY,
dmgPacket: {
skillId: skill.id,
attackKind: 'spell',
elemType: 'pois',
elemMin256: Math.max(FIXED_ONE * 40, evalResult.minElemDmg256),
elemMax256: Math.max(FIXED_ONE * 60, evalResult.maxElemDmg256),
autoHit: true,
},
})
if (msl) missilesSpawned.push(msl.name)
} else if (effectiveDoFunc === 75) {
// Grim Ward fear totem
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
t.stateBus.applyState({ stateNameOrId: 'terror', slvl: evalResult.slvl, durationFrames: 250 })
statesApplied.push('terror')
}
} else {
notes.push(effectiveDoFunc === 69 ? 'potion_found' : 'item_found')
}
}
return {
skillId: skill.id,
name: skill.name,
executed: corpsesConsumed > 0,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 6. Curses & AoE Debuffs (`srvDoFunc = 6, 30, 47, 59, 61, 71`)
if ([6, 30, 47, 59, 61, 71].includes(effectiveDoFunc)) {
const curseState =
skill.auraTgtState ||
skill.auraState ||
(skill.id === 86 ? 'attract' : skill.id === 81 ? 'confuse' : skill.name.toLowerCase().replace(/\s+/g, ''))
const calcCtx = {
registry: ctx.registry,
activeSkill: skill,
slvl: evalResult.slvl,
blvl: evalResult.blvl,
getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id),
getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id),
}
const debuffStats: Record<string, number> = {}
if (skill.id === 66) debuffStats['amplify_phys_pierce'] = 100
if (skill.id === 87) {
debuffStats['amplify_phys_pierce'] = 50
debuffStats['item_slow'] = 50
}
if (skill.id === 91) {
debuffStats['lowerresist_pierce'] = evaluateCalc('dm56', calcCtx)
}
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
const res = t.stateBus.applyState({
stateNameOrId: curseState,
sourceUnitId: caster.id,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
durationFrames: evalResult.durationFrames || 250,
currentFrame: currentTick,
stats: debuffStats,
curseTypeOverride: skill.id === 86 ? 2 : 1,
})
if (res.applied) {
statesApplied.push(res.stateName)
}
if (skill.id === 86) break // Attract targets a single monster
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 7. Self/Ally Buffs & Shapeshift Toggles (`srvDoFunc = 18, 23, 25, 29, 54, 113, 116`)
if ([18, 23, 25, 29, 54, 113, 116].includes(effectiveDoFunc)) {
const buffState =
skill.auraState ||
skill.auraTgtState ||
(skill.id === 223 ? 'wolf' : skill.id === 228 ? 'bear' : skill.name.toLowerCase().replace(/\s+/g, ''))
const stats: Record<string, number> = {}
// 1.13c Werebear (`228`): +50% HP (+Lycanthropy), +55%+(lvl-1)*8% ED, +25%+(lvl-1)*6% Defense
if (skill.id === 228) {
stats['item_maxhp_percent'] = 50 + caster.statList.getEffectiveSkillLevel(224) * 20
stats['damagepercent'] = 55 + (evalResult.slvl - 1) * 8
stats['item_armor_percent'] = 25 + (evalResult.slvl - 1) * 6
caster.stateBus.removeState('wolf')
} else if (skill.id === 223) {
stats['item_maxhp_percent'] = 25 + caster.statList.getEffectiveSkillLevel(224) * 20
stats['skill_ias'] = Math.min(70, 20 + evalResult.slvl * 3)
caster.stateBus.removeState('bear')
} else if (skill.id === 278) {
// Venom (`278`): 10-frame poison clamp + bitrate
stats['venom_bitrate_256'] = Math.max(25 * FIXED_ONE, Math.trunc(evalResult.minElemDmg256 / 10))
}
caster.stateBus.applyState({
stateNameOrId: buffState,
sourceUnitId: caster.id,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
durationFrames: evalResult.durationFrames || 3000,
currentFrame: currentTick,
stats,
})
statesApplied.push(buffState)
// Blaze (`46`) / Thunder Storm (`57`) / Blade Shield (`277`) also deal immediate or trail damage
if (skill.id === 46 || skill.id === 57 || skill.id === 277) {
const alive = targets.find(t => t.statList.getHp256() > 0)
if (alive) {
const out = executeSUnitDmg(caster, alive, {
skillId: skill.id,
attackKind: skill.id === 277 ? 'melee' : 'spell',
srcDam: skill.srcDam,
elemType: evalResult.elemType,
elemMin256: Math.max(FIXED_ONE * 15, evalResult.minElemDmg256),
elemMax256: Math.max(FIXED_ONE * 25, evalResult.maxElemDmg256),
autoHit: true,
})
totalDamageDealt += out.totalDamage
}
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 8. Teleport (`54`, `srvDoFunc = 27`)
if (skill.id === 54 || effectiveDoFunc === 27) {
notes.push(`teleported:${String(Math.round(targetX))},${String(Math.round(targetY))}`)
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: 27,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 9. Warcries (`Shout` 138, `Battle Cry` 146, `Battle Orders` 149, `War Cry` 154, `Battle Command` 155)
// and Radial Novas (`Frost Nova` 44, `Nova` 48, `Poison Nova` 92, `Howl` 130, `Static Field` 42)
if ([20, 22, 68].includes(effectiveDoFunc) && (skill.charClass === 'bar' || [42, 44, 48, 92, 130].includes(skill.id))) {
if (skill.id === 138 || skill.id === 149 || skill.id === 155) {
const stateName = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
const stats: Record<string, number> = {}
if (skill.id === 138) stats['item_armor_percent'] = 100 + (evalResult.slvl - 1) * 10
if (skill.id === 149) {
const pct = 35 + (evalResult.slvl - 1) * 3
stats['item_maxhp_percent'] = pct
stats['item_maxmana_percent'] = pct
}
if (skill.id === 155) stats['item_allskills'] = 1
caster.stateBus.applyState({
stateNameOrId: stateName,
sourceUnitId: caster.id,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
durationFrames: evalResult.durationFrames || 3000,
currentFrame: currentTick,
stats,
})
statesApplied.push(stateName)
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
if (skill.id === 146) {
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
t.stateBus.applyState({
stateNameOrId: 'battlecry',
slvl: evalResult.slvl,
durationFrames: 300,
stats: { item_armor_percent: -(50 + evalResult.slvl * 2) },
curseTypeOverride: 1,
})
statesApplied.push('battlecry')
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// Nova (Skill 48): 1.13c expanding ring of 64 bolts with 2:1 isometric aspect ratio 0.5 and NextDelay 4
if (skill.id === 48) {
const casterPos = targetPositions?.get(caster.id) ?? { x: targetX, y: targetY }
const bolts = missileEngine.spawnNovaRing({
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: casterPos.x,
startY: casterPos.y,
dmgPacket: {
skillId: skill.id,
attackKind: 'spell',
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: true,
},
})
missilesSpawned.push(...bolts.map(b => b.name))
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt: 0,
notes,
}
}
// Static Field (Skill 42): 1.13c Instantaneous 360° area discharge
// Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
// Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors
if (skill.id === 42) {
const radiusPx = calculateStaticFieldRadiusPx(evalResult.slvl)
const cPos = targetPositions?.get(caster.id) ?? { x: 0, y: 0 }
const diff = 'normal'
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
const tPos = targetPositions?.get(t.id)
if (tPos) {
const dist = Math.hypot(tPos.x - cPos.x, tPos.y - cPos.y)
if (dist > radiusPx) continue
if (missileEngine.terrain && !hasLineOfSight(cPos.x, cPos.y, tPos.x, tPos.y, missileEngine.terrain)) {
continue
}
}
const currentHp = t.statList.getHp()
const maxHp = t.statList.getMaxHp()
const ltngRes = t.statList.getAccruedStat('lightresist')
const res = calculateStaticFieldDamage(currentHp, maxHp, diff, ltngRes)
if (res.damage > 0) {
t.statList.setHp256((currentHp - res.damage) * FIXED_ONE)
totalDamageDealt += res.damage
}
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// Frost Nova (`44`), Poison Nova (`92`), War Cry (`154`), Howl (`130`)
for (const t of targets) {
if (t.statList.getHp256() <= 0) continue
if (skill.id === 130) {
t.stateBus.applyState({ stateNameOrId: 'terror', slvl: evalResult.slvl, durationFrames: 150 })
statesApplied.push('terror')
continue
}
const out = executeSUnitDmg(caster, t, {
skillId: skill.id,
attackKind: 'spell',
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: true,
})
totalDamageDealt += out.totalDamage
}
if (evalResult.missileA) {
const m = missileEngine.spawnMissile({
missileNameOrId: evalResult.missileA,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: 0,
startY: 0,
targetX,
targetY,
dmgPacket: {
skillId: skill.id,
attackKind: 'spell',
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: true,
},
})
if (m) missilesSpawned.push(m.name)
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 10. Missile / Ground Spawner / Ranged Skills (`srvDoFunc = 3, 5, 8, 10, 12, 17, 19, 24, 26, 28, 43, 44, 48, 68, 73, 74, 80, 117, 118, 123, 124`)
const isMissileSkill =
evalResult.missileA !== '' &&
(skill.range === 'none' ||
skill.range === 'rng' ||
skill.range === 'both' ||
[3, 5, 8, 10, 12, 17, 19, 24, 26, 28, 43, 44, 48, 68, 73, 74, 80, 117, 118, 123, 124].includes(effectiveDoFunc))
if (isMissileSkill) {
const projectileCount =
effectiveDoFunc === 8 || effectiveDoFunc === 17
? Math.min(24, 3 + evalResult.slvl)
: effectiveDoFunc === 117
? 3
: 1
const dmgPacket = {
skillId: skill.id,
attackKind: (skill.srcDam > 0 ? 'missile' : 'spell') as 'missile' | 'spell',
srcDam: skill.srcDam,
physDamagePct: evalResult.physDamagePct,
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: skill.srcDam === 0 || skill.id === 22,
}
const piercePct =
caster.statList.getEffectiveSkillLevel(33) > 0
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
: 0
for (let i = 0; i < projectileCount; i += 1) {
const spreadOffset = (i - (projectileCount - 1) / 2) * 14
const m = missileEngine.spawnMissile({
missileNameOrId: evalResult.missileA,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: 0,
startY: 0,
targetX: targetX + spreadOffset,
targetY,
dmgPacket,
pierceChancePct: piercePct,
})
if (m) missilesSpawned.push(m.name)
}
// Advance missile engine so immediate or close-range missiles land and record damage
const stepRes = missileEngine.tick(currentTick, targets, targetPositions)
for (const h of stepRes.hits) {
totalDamageDealt += h.damage
}
for (const sub of stepRes.subMissilesSpawned) {
missilesSpawned.push(sub)
}
// For stationary ground controllers (`Fire Wall` 51, `Meteor` 56, `Blizzard` 59, `Fissure` 234, `Volcano` 244, `Armageddon` 249, `Hurricane` 250),
// also apply the first ground tick damage to targets in the impact zone
if (totalDamageDealt === 0 && [24, 28, 123, 124].includes(effectiveDoFunc)) {
const primaryTarget = targets.find(t => t.statList.getHp256() > 0)
if (primaryTarget) {
const out = executeSUnitDmg(caster, primaryTarget, dmgPacket)
totalDamageDealt += out.totalDamage
}
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}
// 11. Melee, Martial Arts Charge-up / Finisher, Multi-Hit (`Zeal`, `Fend`, `Fury`, `Jab`, `Frenzy`, `Whirlwind`, `Smite`, `Sacrifice`, `Berserk`)
const primaryTarget = targets.find(t => t.statList.getHp256() > 0)
if (primaryTarget) {
// Assassin Martial Arts progressive charge-up (`progressive === true`, `srvDoFunc = 34, 35`)
// CRITICAL 1.13c RULE (`patch.txt`): Combo charges last 15 seconds (`375` frames)!
if (skill.progressive) {
const chargeState = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
caster.stateBus.applyState({
stateNameOrId: chargeState,
sourceUnitId: caster.id,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
durationFrames: 375,
currentFrame: currentTick,
stats: { progressive_charges: 3 },
})
statesApplied.push(chargeState)
}
const hitCount =
[7, 13, 42, 46, 70, 76].includes(effectiveDoFunc)
? Math.min(5, Math.max(2, 1 + Math.trunc(evalResult.slvl / 4)))
: 1
for (let h = 0; h < hitCount; h += 1) {
if (primaryTarget.statList.getHp256() <= 0) break
const out = executeSUnitDmg(caster, primaryTarget, {
skillId: skill.id,
attackKind: 'melee',
srcDam: skill.srcDam || 128,
physDamagePct: evalResult.physDamagePct,
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
elemType: evalResult.elemType,
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
physToElemPct: skill.id === 152 ? 100 : 0, // Berserk converts 100% physical to Magic
autoHit: skill.id === 97, // Smite (`97`) always hits
unblockable: skill.id === 97,
})
totalDamageDealt += out.totalDamage
}
// Sacrifice (`96`): deducts 8% of damage dealt from caster HP
if (skill.id === 96 && totalDamageDealt > 0) {
const selfCost256 = Math.trunc((totalDamageDealt * 8 * FIXED_ONE) / 100)
caster.statList.setHp256(Math.max(FIXED_ONE, caster.statList.getHp256() - selfCost256))
}
// Charged Strike (`24`) / Lightning Strike (`34`): also spawns radial/chaining lightning bolts on melee hit
if ((skill.id === 24 || skill.id === 34) && evalResult.missileA) {
const m = missileEngine.spawnMissile({
missileNameOrId: evalResult.missileA,
sourceSkillId: skill.id,
slvl: evalResult.slvl,
owner: caster,
startX: 0,
startY: 120,
targetX: 70,
targetY: 120,
dmgPacket: {
skillId: skill.id,
attackKind: 'spell',
elemType: 'ltng',
elemMin256: evalResult.minElemDmg256,
elemMax256: evalResult.maxElemDmg256,
autoHit: true,
},
})
if (m) missilesSpawned.push(m.name)
}
}
return {
skillId: skill.id,
name: skill.name,
executed: true,
srvDoFuncUsed: effectiveDoFunc,
manaSpent256: evalResult.manaCost256,
missilesSpawned,
statesApplied,
petsSummoned,
corpsesConsumed,
totalDamageDealt,
notes,
}
}