1000 lines
34 KiB
TypeScript
1000 lines
34 KiB
TypeScript
/**
|
||
* 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,
|
||
}
|
||
}
|