feat(skills): implement Amazon Javelin and Spear skill tree with 1.13c parity (closes #461)
- Implement 10 Javelin & Spear skills: Jab (10), Power Strike (14), Poison Javelin (15), Impale (19), Lightning Bolt (20), Charged Strike (24), Plague Javelin (25), Fend (30), Lightning Strike (34), Lightning Fury (35) - Wire 1.13c 5-band scaling, synergy matrices, mana curves, casting delays (Poison Javelin 12f, Plague Javelin 100f) - Add comprehensive adversarial stress test suite adv-ama-javelin-spear-stress.test.ts (13/13 passing) - Maintain BATCH1_SKILLS length = 38 invariant and zero runtime MPQ reads - 100% typecheck clean and 0 regressions against existing test suite
This commit is contained in:
parent
3e4350e50d
commit
8ba4908572
|
|
@ -377,8 +377,66 @@ export {
|
|||
type EvadeStats,
|
||||
type ValkyrieStats,
|
||||
type ValkyrieEquipmentTier,
|
||||
type PierceStats,
|
||||
} from './skills/amazon-passive.ts'
|
||||
import {
|
||||
calculateJabStats,
|
||||
calculatePowerStrikeStats,
|
||||
calculatePoisonJavelinStats,
|
||||
calculateImpaleStats,
|
||||
calculateLightningBoltStats,
|
||||
calculateChargedStrikeStats,
|
||||
calculatePlagueJavelinStats,
|
||||
calculateFendStats,
|
||||
calculateLightningStrikeStats,
|
||||
calculateLightningFuryStats,
|
||||
type JabStats,
|
||||
type PowerStrikeStats,
|
||||
type PoisonJavelinStats,
|
||||
type ImpaleStats,
|
||||
type LightningBoltStats,
|
||||
type ChargedStrikeStats,
|
||||
type PlagueJavelinStats,
|
||||
type FendStats,
|
||||
type LightningStrikeStats,
|
||||
type LightningFuryStats,
|
||||
type PowerStrikeSynergies,
|
||||
type PoisonJavelinSynergies,
|
||||
type LightningBoltSynergies,
|
||||
type ChargedStrikeSynergies,
|
||||
type PlagueJavelinSynergies,
|
||||
type LightningStrikeSynergies,
|
||||
type LightningFurySynergies,
|
||||
} from './skills/amazon-javelin-spear.ts'
|
||||
export {
|
||||
calculateJabStats,
|
||||
calculatePowerStrikeStats,
|
||||
calculatePoisonJavelinStats,
|
||||
calculateImpaleStats,
|
||||
calculateLightningBoltStats,
|
||||
calculateChargedStrikeStats,
|
||||
calculatePlagueJavelinStats,
|
||||
calculateFendStats,
|
||||
calculateLightningStrikeStats,
|
||||
calculateLightningFuryStats,
|
||||
type JabStats,
|
||||
type PowerStrikeStats,
|
||||
type PoisonJavelinStats,
|
||||
type ImpaleStats,
|
||||
type LightningBoltStats,
|
||||
type ChargedStrikeStats,
|
||||
type PlagueJavelinStats,
|
||||
type FendStats,
|
||||
type LightningStrikeStats,
|
||||
type LightningFuryStats,
|
||||
type PowerStrikeSynergies,
|
||||
type PoisonJavelinSynergies,
|
||||
type LightningBoltSynergies,
|
||||
type ChargedStrikeSynergies,
|
||||
type PlagueJavelinSynergies,
|
||||
type LightningStrikeSynergies,
|
||||
type LightningFurySynergies,
|
||||
} from './skills/amazon-javelin-spear.ts'
|
||||
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
|
|
@ -5655,6 +5713,9 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 9) {
|
||||
return calculateCriticalStrikeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 10) {
|
||||
return calculateJabStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 11) {
|
||||
return calculateColdArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
|
|
@ -5664,6 +5725,12 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 13) {
|
||||
return calculateDodgeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 14) {
|
||||
return calculatePowerStrikeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 15) {
|
||||
return calculatePoisonJavelinStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 16) {
|
||||
return calculateExplodingArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
|
|
@ -5673,6 +5740,12 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 18) {
|
||||
return calculateAvoidStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 19) {
|
||||
return calculateImpaleStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 20) {
|
||||
return calculateLightningBoltStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 21) {
|
||||
return calculateIceArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
|
|
@ -5682,6 +5755,12 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 23) {
|
||||
return calculatePenetrateStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 24) {
|
||||
return calculateChargedStrikeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 25) {
|
||||
return calculatePlagueJavelinStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 26) {
|
||||
return calculateStrafeStats(effectiveLevel).manaCost
|
||||
}
|
||||
|
|
@ -5694,6 +5773,9 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 29) {
|
||||
return calculateEvadeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 30) {
|
||||
return calculateFendStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 31) {
|
||||
return calculateFreezingArrowStats(effectiveLevel).manaCost
|
||||
}
|
||||
|
|
@ -5703,6 +5785,12 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
if (skillId === 33) {
|
||||
return calculatePierceStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 34) {
|
||||
return calculateLightningStrikeStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 35) {
|
||||
return calculateLightningFuryStats(effectiveLevel).manaCost
|
||||
}
|
||||
if (skillId === 38) {
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
// 1.13c Skills.txt: mana = 24, lvlmana = 4, manashift = 5 (factor = 0.125)
|
||||
|
|
@ -5840,20 +5928,28 @@ export function getSkillCooldownTicks(skillId: number): number {
|
|||
skillId === 7 ||
|
||||
skillId === 8 ||
|
||||
skillId === 9 ||
|
||||
skillId === 10 ||
|
||||
skillId === 11 ||
|
||||
skillId === 12 ||
|
||||
skillId === 13 ||
|
||||
skillId === 14 ||
|
||||
skillId === 16 ||
|
||||
skillId === 17 ||
|
||||
skillId === 18 ||
|
||||
skillId === 19 ||
|
||||
skillId === 20 ||
|
||||
skillId === 21 ||
|
||||
skillId === 22 ||
|
||||
skillId === 23 ||
|
||||
skillId === 24 ||
|
||||
skillId === 26 ||
|
||||
skillId === 28 ||
|
||||
skillId === 29 ||
|
||||
skillId === 30 ||
|
||||
skillId === 31 ||
|
||||
skillId === 33 ||
|
||||
skillId === 34 ||
|
||||
skillId === 35 ||
|
||||
skillId === 38 ||
|
||||
skillId === 40 ||
|
||||
skillId === 41 ||
|
||||
|
|
@ -5877,6 +5973,12 @@ export function getSkillCooldownTicks(skillId: number): number {
|
|||
) {
|
||||
return 0
|
||||
}
|
||||
if (skillId === 15) {
|
||||
return 12 // Poison Javelin: 12 frames (0.5s) casting delay
|
||||
}
|
||||
if (skillId === 25) {
|
||||
return 100 // Plague Javelin: 100 frames (4.0s) casting delay
|
||||
}
|
||||
if (skillId === 27) {
|
||||
return 25 // Immolation Arrow: 25 frames (1.0s)
|
||||
}
|
||||
|
|
@ -5958,12 +6060,31 @@ export function calculateSkillDamage(
|
|||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: Math.floor(baseMin * stats.damageMultiplier), max: Math.floor(baseMax * stats.damageMultiplier) }
|
||||
}
|
||||
if (skillId === 15 || skillId === '15' || skillId === 'poisonjavelin' || skillId === 'poison javelin') {
|
||||
const stats = calculatePoisonJavelinStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 16 || skillId === '16' || skillId === 'explodingarrow' || skillId === 'exploding arrow') {
|
||||
const stats = calculateExplodingArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minBlastDamage, max: baseMax + stats.maxBlastDamage }
|
||||
}
|
||||
if (skillId === 19 || skillId === '19' || skillId === 'impale') {
|
||||
const stats = calculateImpaleStats(slvl)
|
||||
const ed = 1 + stats.enhancedDamagePct / 100
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
||||
}
|
||||
if (skillId === 20 || skillId === '20' || skillId === 'lightningbolt' || skillId === 'lightning bolt') {
|
||||
const stats = calculateLightningBoltStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 21 || skillId === '21' || skillId === 'icearrow' || skillId === 'ice arrow') {
|
||||
const stats = calculateIceArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
|
|
@ -5977,6 +6098,18 @@ export function calculateSkillDamage(
|
|||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
||||
}
|
||||
if (skillId === 24 || skillId === '24' || skillId === 'chargedstrike' || skillId === 'charged strike') {
|
||||
const stats = calculateChargedStrikeStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 25 || skillId === '25' || skillId === 'plaguejavelin' || skillId === 'plague javelin') {
|
||||
const stats = calculatePlagueJavelinStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 26 || skillId === '26' || skillId === 'strafe') {
|
||||
const stats = calculateStrafeStats(slvl)
|
||||
const mult = stats.damageMultiplier * (1 + stats.enhancedDamagePct / 100)
|
||||
|
|
@ -5990,12 +6123,31 @@ export function calculateSkillDamage(
|
|||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minExplosionDamage, max: baseMax + stats.maxExplosionDamage }
|
||||
}
|
||||
if (skillId === 30 || skillId === '30' || skillId === 'fend') {
|
||||
const stats = calculateFendStats(slvl)
|
||||
const ed = 1 + stats.enhancedDamagePct / 100
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: Math.floor(baseMin * ed), max: Math.floor(baseMax * ed) }
|
||||
}
|
||||
if (skillId === 31 || skillId === '31' || skillId === 'freezingarrow' || skillId === 'freezing arrow') {
|
||||
const stats = calculateFreezingArrowStats(slvl, synergies as any)
|
||||
const baseMin = weaponDamage?.min ?? 0
|
||||
const baseMax = weaponDamage?.max ?? 0
|
||||
return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage }
|
||||
}
|
||||
if (skillId === 34 || skillId === '34' || skillId === 'lightningstrike' || skillId === 'lightning strike') {
|
||||
const stats = calculateLightningStrikeStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (skillId === 35 || skillId === '35' || skillId === 'lightningfury' || skillId === 'lightning fury') {
|
||||
const stats = calculateLightningFuryStats(slvl, synergies as any)
|
||||
const baseMin = Number.isFinite(weaponDamage?.min) ? Math.max(0, Math.floor(weaponDamage!.min)) : 0
|
||||
const baseMax = Number.isFinite(weaponDamage?.max) ? Math.max(0, Math.floor(weaponDamage!.max)) : 0
|
||||
return { min: baseMin + stats.minDamage, max: baseMax + stats.maxDamage }
|
||||
}
|
||||
if (
|
||||
skillId === 8 || skillId === '8' || skillId === 'innersight' || skillId === 'inner sight' ||
|
||||
skillId === 9 || skillId === '9' || skillId === 'criticalstrike' || skillId === 'critical strike' ||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,710 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Amazon Javelin and Spear Skills Tree Module
|
||||
*
|
||||
* Dedicated pure calculation and kinematics formulas for the 10 javelin/spear skills:
|
||||
* - Skill 010: Jab
|
||||
* - Skill 014: Power Strike
|
||||
* - Skill 015: Poison Javelin
|
||||
* - Skill 019: Impale
|
||||
* - Skill 020: Lightning Bolt
|
||||
* - Skill 024: Charged Strike
|
||||
* - Skill 025: Plague Javelin
|
||||
* - Skill 030: Fend
|
||||
* - Skill 034: Lightning Strike
|
||||
* - Skill 035: Lightning Fury
|
||||
*
|
||||
* 1.13c Ground Truth:
|
||||
* - D2Common.dll / D2Game.dll / Patch_D2.mpq: Skills.txt & Missiles.txt
|
||||
* - Fixed-point 24.8 mana calculations
|
||||
* - 5-band level scaling: compute5BandScaling(slvl, base, lev1..lev5)
|
||||
* - Jab: 3 rapid consecutive thrusts, negative-to-positive %ED scaling (-15% + 3%/lvl), AR +10% + 9%/lvl, mana 2.0 + 0.25/lvl
|
||||
* - Power Strike: 5-band lightning damage (1..16 + 18/36/54/72/90/lvl), AR +20% + 10%/lvl, CS/LS/LF synergies (+10%/lvl), mana 2.0 + 0.25/lvl
|
||||
* - Poison Javelin: duration 100 + 50 * (slvl - 1) frames, 5-band poison damage, Plague Javelin synergy (+12%/lvl), 12-frame casting delay, mana 4.0 + 0.25/lvl
|
||||
* - Impale: slow crushing blow, massive %ED (+300% + 25%/lvl), AR +100% + 20%/lvl, durability loss chance max(10%, 50% - 2%/lvl), fixed 3 mana
|
||||
* - Lightning Bolt: 100% physical-to-lightning conversion (srcDam = 0 physical), flat bonus lightning 1 - (slvl * 2 + 10), auto-hit defense bypass, 4 synergies (+3%/lvl), mana max(4.25, 9.0 - 0.25/lvl)
|
||||
* - Charged Strike: floor(slvl / 5) + 3 bolts (3..7+), 5-band lightning damage per bolt, point-blank boss shotgunning, 4 synergies (+14%/lvl), mana 4.0 + 0.25/lvl
|
||||
* - Plague Javelin: duration 75 + 25 * (slvl - 1) frames, 5-band poison damage, Poison Javelin synergy (+10%/lvl), 100-frame (4.0s) casting delay, mana 8.5 + 0.5/lvl
|
||||
* - Fend: multi-target strike against adjacent enemies, %ED +70% + 10%/lvl, AR +40% + 10%/lvl, 1.13c Fend Bug (interrupted by Dodge/Avoid/Evade), fixed 5 mana
|
||||
* - Lightning Strike: chain hops min(24, slvl + 1), 5-band lightning damage, 4-frame NextHitDelay, 4 synergies (+8%/lvl), fixed 9 mana
|
||||
* - Lightning Fury: slvl release bolts, 200px search radius, 5-band bolt damage, triggers bolt bursts on EACH hit and pierce, 4 synergies (+1%/lvl), mana 10 + 0.5/lvl
|
||||
*/
|
||||
|
||||
import { compute5BandScaling } from '../engine/calc-ast.ts'
|
||||
|
||||
// --- Interfaces for Skill Calculations ---
|
||||
|
||||
export interface JabStats {
|
||||
readonly strikesCount: number
|
||||
readonly damagePct: number
|
||||
readonly enhancedDamagePct: number
|
||||
readonly attackRatingBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface PowerStrikeStats {
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly attackRatingBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface PoisonJavelinStats {
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly tableDurationFrames: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly castingDelayFrames: number
|
||||
readonly cooldownFrames: number
|
||||
readonly tableCooldownFrames: number
|
||||
readonly trailCloudMissile: string
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface ImpaleStats {
|
||||
readonly enhancedDamagePct: number
|
||||
readonly attackRatingBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly durabilityLossChancePct: number
|
||||
readonly uninterruptible: boolean
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface LightningBoltStats {
|
||||
readonly conversionPct: number
|
||||
readonly physToElemPct: number
|
||||
readonly srcDam: number
|
||||
readonly flatBonusLightningMin: number
|
||||
readonly flatBonusLightningMax: number
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly tableMinDamage: number
|
||||
readonly tableMaxDamage: number
|
||||
readonly autoHit: boolean
|
||||
readonly bypassesDefense: boolean
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface ChargedStrikeStats {
|
||||
readonly boltsCount: number
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly shotgunning: boolean
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface PlagueJavelinStats {
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly tableDurationFrames: number
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly castingDelayFrames: number
|
||||
readonly cooldownFrames: number
|
||||
readonly impactCloudMissile: string
|
||||
readonly trailCloudMissile: string
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly tableManaCost: number
|
||||
readonly tableManaCost256: number
|
||||
}
|
||||
|
||||
export interface FendStats {
|
||||
readonly enhancedDamagePct: number
|
||||
readonly attackRatingBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly maxAdjacentTargets: number
|
||||
readonly strikesCount: number
|
||||
readonly evasionInterruptible: boolean
|
||||
readonly fendBugActive: boolean
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface LightningStrikeStats {
|
||||
readonly chainHops: number
|
||||
readonly maxChainJumps: number
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly nextHitDelayFrames: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface LightningFuryStats {
|
||||
readonly releaseBoltsCount: number
|
||||
readonly boltsCount: number
|
||||
readonly searchRadiusPx: number
|
||||
readonly searchRadiusSubtiles: number
|
||||
readonly minDamage: number
|
||||
readonly maxDamage: number
|
||||
readonly baseMinDamage: number
|
||||
readonly baseMaxDamage: number
|
||||
readonly synergyMultiplier: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly piercingBursts: boolean
|
||||
readonly subMissileName: string
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
// --- Synergy Input Types ---
|
||||
|
||||
export interface PowerStrikeSynergies {
|
||||
readonly chargedStrike?: number | undefined
|
||||
readonly lightningStrike?: number | undefined
|
||||
readonly lightningFury?: number | undefined
|
||||
readonly lightningBolt?: number | undefined
|
||||
}
|
||||
|
||||
export interface PoisonJavelinSynergies {
|
||||
readonly plagueJavelin?: number | undefined
|
||||
}
|
||||
|
||||
export interface LightningBoltSynergies {
|
||||
readonly powerStrike?: number | undefined
|
||||
readonly chargedStrike?: number | undefined
|
||||
readonly lightningStrike?: number | undefined
|
||||
readonly lightningFury?: number | undefined
|
||||
}
|
||||
|
||||
export interface ChargedStrikeSynergies {
|
||||
readonly powerStrike?: number | undefined
|
||||
readonly lightningBolt?: number | undefined
|
||||
readonly lightningStrike?: number | undefined
|
||||
readonly lightningFury?: number | undefined
|
||||
}
|
||||
|
||||
export interface PlagueJavelinSynergies {
|
||||
readonly poisonJavelin?: number | undefined
|
||||
}
|
||||
|
||||
export interface LightningStrikeSynergies {
|
||||
readonly powerStrike?: number | undefined
|
||||
readonly lightningBolt?: number | undefined
|
||||
readonly chargedStrike?: number | undefined
|
||||
readonly lightningFury?: number | undefined
|
||||
}
|
||||
|
||||
export interface LightningFurySynergies {
|
||||
readonly powerStrike?: number | undefined
|
||||
readonly lightningBolt?: number | undefined
|
||||
readonly chargedStrike?: number | undefined
|
||||
readonly lightningStrike?: number | undefined
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
function sanitizeLevel(slvl: number): number {
|
||||
if (!Number.isFinite(slvl) || slvl <= 0) return 1
|
||||
return Math.floor(slvl)
|
||||
}
|
||||
|
||||
function sanitizePoints(val?: number): number {
|
||||
if (val === undefined || !Number.isFinite(val) || val <= 0) return 0
|
||||
return Math.floor(val)
|
||||
}
|
||||
|
||||
// --- Pure Calculation Functions ---
|
||||
|
||||
/**
|
||||
* Skill 010: Jab
|
||||
*
|
||||
* 3 rapid consecutive thrusts with a javelin or spear.
|
||||
* Starts with a damage penalty at low levels that transitions to an enhanced damage bonus:
|
||||
* - %ED: -15% + 3% * (slvl - 1)
|
||||
* - AR Bonus: +10% + 9% * (slvl - 1)
|
||||
* - Mana: 2.0 + 0.25 * (slvl - 1) (mana = 8, lvlmana = 1, shift = 6)
|
||||
*/
|
||||
export function calculateJabStats(slvl: number): JabStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const ed = -15 + 3 * (lvl - 1)
|
||||
const ar = 10 + 9 * (lvl - 1)
|
||||
const manaCost = 2.0 + 0.25 * (lvl - 1)
|
||||
const manaCost256 = (8 + (lvl - 1)) * 64
|
||||
|
||||
return {
|
||||
strikesCount: 3,
|
||||
damagePct: ed,
|
||||
enhancedDamagePct: ed,
|
||||
attackRatingBonusPct: ar,
|
||||
toHitBonusPct: ar,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 014: Power Strike
|
||||
*
|
||||
* Adds direct lightning elemental damage to spear and javelin melee attacks:
|
||||
* - 5-band lightning damage: 1 - 16 base, scaling +18/36/54/72/90 per band
|
||||
* - AR Bonus: +20% + 10% * (slvl - 1)
|
||||
* - Synergies: Charged Strike, Lightning Strike, Lightning Fury (+10%/lvl)
|
||||
* - Mana: 2.0 + 0.25 * (slvl - 1)
|
||||
*/
|
||||
export function calculatePowerStrikeStats(
|
||||
slvl: number,
|
||||
synergies?: PowerStrikeSynergies | number
|
||||
): PowerStrikeStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const baseMin = 1
|
||||
const baseMax = compute5BandScaling(lvl, 16, 18, 36, 54, 72, 90)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const cs = sanitizePoints(synergies.chargedStrike)
|
||||
const ls = sanitizePoints(synergies.lightningStrike)
|
||||
const lf = sanitizePoints(synergies.lightningFury)
|
||||
const lb = sanitizePoints(synergies.lightningBolt)
|
||||
synergyBonusPct = 10 * (cs + ls + lf + lb)
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
const ar = 20 + 10 * (lvl - 1)
|
||||
const manaCost = 2.0 + 0.25 * (lvl - 1)
|
||||
const manaCost256 = (8 + (lvl - 1)) * 64
|
||||
|
||||
return {
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
attackRatingBonusPct: ar,
|
||||
toHitBonusPct: ar,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 015: Poison Javelin
|
||||
*
|
||||
* Throws a javelin that leaves a trail of toxic clouds in flight:
|
||||
* - Duration: 100 + 50 * (slvl - 1) frames (4.0s + 2.0s/lvl)
|
||||
* - 5-band poison damage: 32..48 base, scaling +16/32/48/64/96 min, +16/36/52/68/84 max
|
||||
* - Synergy: Plague Javelin (+12%/lvl)
|
||||
* - Casting Delay: 12 frames (0.5s)
|
||||
* - Mana: 4.0 + 0.25 * (slvl - 1) (mana = 16, lvlmana = 1, shift = 6)
|
||||
*/
|
||||
export function calculatePoisonJavelinStats(
|
||||
slvl: number,
|
||||
synergies?: PoisonJavelinSynergies | number
|
||||
): PoisonJavelinStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const baseMin = compute5BandScaling(lvl, 32, 16, 32, 48, 64, 96)
|
||||
const baseMax = compute5BandScaling(lvl, 48, 16, 36, 52, 68, 84)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const pj = sanitizePoints(synergies.plagueJavelin)
|
||||
synergyBonusPct = 12 * pj
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
const durationFrames = 100 + 50 * (lvl - 1)
|
||||
const tableDurationFrames = 200 + 50 * (lvl - 1)
|
||||
const durationSeconds = durationFrames / 25
|
||||
const manaCost = 4.0 + 0.25 * (lvl - 1)
|
||||
const manaCost256 = (16 + (lvl - 1)) * 64
|
||||
|
||||
return {
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
durationFrames,
|
||||
durationSeconds,
|
||||
tableDurationFrames,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
castingDelayFrames: 12,
|
||||
cooldownFrames: 12,
|
||||
tableCooldownFrames: 15,
|
||||
trailCloudMissile: 'poisonjavcloud',
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 019: Impale
|
||||
*
|
||||
* Slow, crushing melee thrust dealing massive physical damage with a weapon durability penalty:
|
||||
* - %ED: +300% + 25% * (slvl - 1)
|
||||
* - AR Bonus: +100% + 20% * (slvl - 1)
|
||||
* - Durability Loss Chance: max(10%, 50% - 2% * (slvl - 1))
|
||||
* - Mana: Fixed 3.0 (mana = 3, lvlmana = 0, shift = 8)
|
||||
* - Uninterruptible execution posture
|
||||
*/
|
||||
export function calculateImpaleStats(slvl: number): ImpaleStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const ed = 300 + 25 * (lvl - 1)
|
||||
const ar = 100 + 20 * (lvl - 1)
|
||||
const durabilityLossChancePct = Math.max(10, 50 - 2 * (lvl - 1))
|
||||
|
||||
return {
|
||||
enhancedDamagePct: ed,
|
||||
attackRatingBonusPct: ar,
|
||||
toHitBonusPct: ar,
|
||||
durabilityLossChancePct,
|
||||
uninterruptible: true,
|
||||
manaCost: 3.0,
|
||||
manaCost256: 768,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 020: Lightning Bolt
|
||||
*
|
||||
* Converts 100% of physical thrown javelin damage to lightning and adds bonus damage:
|
||||
* - 100% conversion (srcDam = 0 physical)
|
||||
* - Flat bonus lightning damage: 1 - (slvl * 2 + 10)
|
||||
* - 5-band table damage: 1 - 40 base, scaling +12/18/28/48/88
|
||||
* - Auto-hits defense (unblockable by defense, blockable by shield)
|
||||
* - 4 Synergies: Power Strike, Charged Strike, Lightning Strike, Lightning Fury (+3%/lvl)
|
||||
* - Mana: max(4.25, 9.0 - 0.25 * (slvl - 1))
|
||||
*/
|
||||
export function calculateLightningBoltStats(
|
||||
slvl: number,
|
||||
synergies?: LightningBoltSynergies | number
|
||||
): LightningBoltStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const flatMin = 1
|
||||
const flatMax = lvl * 2 + 10
|
||||
const tableMin = 1
|
||||
const tableMax = compute5BandScaling(lvl, 40, 12, 18, 28, 48, 88)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const ps = sanitizePoints(synergies.powerStrike)
|
||||
const cs = sanitizePoints(synergies.chargedStrike)
|
||||
const ls = sanitizePoints(synergies.lightningStrike)
|
||||
const lf = sanitizePoints(synergies.lightningFury)
|
||||
synergyBonusPct = 3 * (ps + cs + ls + lf)
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = flatMin
|
||||
const maxDamage = Math.floor(flatMax * synergyMultiplier)
|
||||
const manaCost = Math.max(4.25, 9.0 - 0.25 * (lvl - 1))
|
||||
const manaCost256 = Math.round(manaCost * 256)
|
||||
|
||||
return {
|
||||
conversionPct: 100,
|
||||
physToElemPct: 100,
|
||||
srcDam: 0,
|
||||
flatBonusLightningMin: flatMin,
|
||||
flatBonusLightningMax: flatMax,
|
||||
minDamage,
|
||||
maxDamage,
|
||||
tableMinDamage: tableMin,
|
||||
tableMaxDamage: tableMax,
|
||||
autoHit: true,
|
||||
bypassesDefense: true,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 024: Charged Strike
|
||||
*
|
||||
* Melee attack that emits charged lightning bolts outward from the target:
|
||||
* - Bolts Count: floor(slvl / 5) + 3 (3 at lvl 1..4, 4 at lvl 5..9, etc.)
|
||||
* - 5-band lightning damage per bolt: 1 - 30 base, scaling +12/16/20/24/28
|
||||
* - 4 Synergies: Power Strike, Lightning Bolt, Lightning Strike, Lightning Fury (+14%/lvl)
|
||||
* - Point-blank boss shotgunning capability against large hitboxes
|
||||
* - Mana: 4.0 + 0.25 * (slvl - 1)
|
||||
*/
|
||||
export function calculateChargedStrikeStats(
|
||||
slvl: number,
|
||||
synergies?: ChargedStrikeSynergies | number
|
||||
): ChargedStrikeStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const boltsCount = Math.floor(lvl / 5) + 3
|
||||
const baseMin = 1
|
||||
const baseMax = compute5BandScaling(lvl, 30, 12, 16, 20, 24, 28)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const ps = sanitizePoints(synergies.powerStrike)
|
||||
const lb = sanitizePoints(synergies.lightningBolt)
|
||||
const ls = sanitizePoints(synergies.lightningStrike)
|
||||
const lf = sanitizePoints(synergies.lightningFury)
|
||||
synergyBonusPct = 14 * (ps + lb + ls + lf)
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
const manaCost = 4.0 + 0.25 * (lvl - 1)
|
||||
const manaCost256 = (16 + (lvl - 1)) * 64
|
||||
|
||||
return {
|
||||
boltsCount,
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
shotgunning: true,
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 025: Plague Javelin
|
||||
*
|
||||
* Throws a javelin creating an expanding toxic cloud on impact and trailing clouds in flight:
|
||||
* - Duration: 75 + 25 * (slvl - 1) frames (3.0s + 1.0s/lvl)
|
||||
* - 5-band poison damage: 10..16 base, scaling +6/12/20/40/60
|
||||
* - Synergy: Poison Javelin (+10%/lvl)
|
||||
* - Casting Delay: 100 frames (4.0s)
|
||||
* - Mana: 8.5 + 0.5 * (slvl - 1)
|
||||
*/
|
||||
export function calculatePlagueJavelinStats(
|
||||
slvl: number,
|
||||
synergies?: PlagueJavelinSynergies | number
|
||||
): PlagueJavelinStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const baseMin = compute5BandScaling(lvl, 10, 6, 12, 20, 40, 60)
|
||||
const baseMax = compute5BandScaling(lvl, 16, 6, 12, 20, 40, 60)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const pj = sanitizePoints(synergies.poisonJavelin)
|
||||
synergyBonusPct = 10 * pj
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
const durationFrames = 75 + 25 * (lvl - 1)
|
||||
const durationSeconds = durationFrames / 25
|
||||
const tableDurationFrames = 75 + 10 * (lvl - 1)
|
||||
const manaCost = 8.5 + 0.5 * (lvl - 1)
|
||||
const manaCost256 = Math.round(manaCost * 256)
|
||||
const tableManaCost = 7.0 + 0.5 * (lvl - 1)
|
||||
const tableManaCost256 = (14 + (lvl - 1)) * 128
|
||||
|
||||
return {
|
||||
durationFrames,
|
||||
durationSeconds,
|
||||
tableDurationFrames,
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
castingDelayFrames: 100,
|
||||
cooldownFrames: 100,
|
||||
impactCloudMissile: 'plaguejavcloud',
|
||||
trailCloudMissile: 'poisonjavcloud',
|
||||
manaCost,
|
||||
manaCost256,
|
||||
tableManaCost,
|
||||
tableManaCost256,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 030: Fend
|
||||
*
|
||||
* Attacks all adjacent enemies in rapid succession with a spear or javelin:
|
||||
* - %ED: +70% + 10% * (slvl - 1)
|
||||
* - AR Bonus: +40% + 10% * (slvl - 1)
|
||||
* - Max Adjacent Targets: 8
|
||||
* - 1.13c "Fend Bug": Triggering Dodge, Avoid, or Evade interrupts and aborts remaining strikes
|
||||
* - Mana: Fixed 5.0 (mana = 5, lvlmana = 0, shift = 8)
|
||||
*/
|
||||
export function calculateFendStats(slvl: number, targetCount?: number): FendStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const ed = 70 + 10 * (lvl - 1)
|
||||
const ar = 40 + 10 * (lvl - 1)
|
||||
const maxAdjacentTargets = 8
|
||||
const validTargets = targetCount !== undefined && Number.isFinite(targetCount)
|
||||
? Math.max(1, Math.min(maxAdjacentTargets, Math.floor(targetCount)))
|
||||
: maxAdjacentTargets
|
||||
|
||||
return {
|
||||
enhancedDamagePct: ed,
|
||||
attackRatingBonusPct: ar,
|
||||
toHitBonusPct: ar,
|
||||
maxAdjacentTargets,
|
||||
strikesCount: validTargets,
|
||||
evasionInterruptible: true,
|
||||
fendBugActive: true,
|
||||
manaCost: 5.0,
|
||||
manaCost256: 1280,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 034: Lightning Strike
|
||||
*
|
||||
* Melee attack that causes chain lightning to leap between nearby enemies:
|
||||
* - Chain Hops: min(24, slvl + 1)
|
||||
* - 5-band lightning damage: 1 - 25 base, scaling +10/15/20/25/30
|
||||
* - NextHitDelay: 4 frames
|
||||
* - 4 Synergies: Power Strike, Lightning Bolt, Charged Strike, Lightning Fury (+8%/lvl)
|
||||
* - Mana: Fixed 9.0 (mana = 9, lvlmana = 0, shift = 8)
|
||||
*/
|
||||
export function calculateLightningStrikeStats(
|
||||
slvl: number,
|
||||
synergies?: LightningStrikeSynergies | number
|
||||
): LightningStrikeStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const chainHops = Math.min(24, lvl + 1)
|
||||
const baseMin = 1
|
||||
const baseMax = compute5BandScaling(lvl, 25, 10, 15, 20, 25, 30)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const ps = sanitizePoints(synergies.powerStrike)
|
||||
const lb = sanitizePoints(synergies.lightningBolt)
|
||||
const cs = sanitizePoints(synergies.chargedStrike)
|
||||
const lf = sanitizePoints(synergies.lightningFury)
|
||||
synergyBonusPct = 8 * (ps + lb + cs + lf)
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
|
||||
return {
|
||||
chainHops,
|
||||
maxChainJumps: chainHops,
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
nextHitDelayFrames: 4,
|
||||
manaCost: 9.0,
|
||||
manaCost256: 2304,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 035: Lightning Fury
|
||||
*
|
||||
* Throws a javelin that splits into multiple charged lightning bolts upon contact:
|
||||
* - Release Bolts Count: slvl (1 bolt per level)
|
||||
* - Search Radius: 200 sub-pixels (13.33 yards)
|
||||
* - 5-band lightning damage per bolt: 1 - 40 base, scaling +20/30/40/50/50
|
||||
* - Piercing Javelin: triggers a separate burst of slvl bolts on EVERY pierced target and collision
|
||||
* - 4 Synergies: Power Strike, Lightning Bolt, Charged Strike, Lightning Strike (+1%/lvl)
|
||||
* - Mana: 10.0 + 0.5 * (slvl - 1) (mana = 20, lvlmana = 1, shift = 7)
|
||||
*/
|
||||
export function calculateLightningFuryStats(
|
||||
slvl: number,
|
||||
synergies?: LightningFurySynergies | number
|
||||
): LightningFuryStats {
|
||||
const lvl = sanitizeLevel(slvl)
|
||||
const releaseBoltsCount = lvl
|
||||
const baseMin = 1
|
||||
const baseMax = compute5BandScaling(lvl, 40, 20, 30, 40, 50, 50)
|
||||
|
||||
let synergyBonusPct = 0
|
||||
let synergyMultiplier = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
synergyMultiplier = Math.max(1.0, synergies)
|
||||
synergyBonusPct = Math.round((synergyMultiplier - 1.0) * 100)
|
||||
} else if (synergies) {
|
||||
const ps = sanitizePoints(synergies.powerStrike)
|
||||
const lb = sanitizePoints(synergies.lightningBolt)
|
||||
const cs = sanitizePoints(synergies.chargedStrike)
|
||||
const ls = sanitizePoints(synergies.lightningStrike)
|
||||
synergyBonusPct = 1 * (ps + lb + cs + ls)
|
||||
synergyMultiplier = 1.0 + synergyBonusPct / 100
|
||||
}
|
||||
|
||||
const minDamage = Math.floor(baseMin * synergyMultiplier)
|
||||
const maxDamage = Math.floor(baseMax * synergyMultiplier)
|
||||
const manaCost = 10.0 + 0.5 * (lvl - 1)
|
||||
const manaCost256 = (20 + (lvl - 1)) * 128
|
||||
|
||||
return {
|
||||
releaseBoltsCount,
|
||||
boltsCount: releaseBoltsCount,
|
||||
searchRadiusPx: 200,
|
||||
searchRadiusSubtiles: 20,
|
||||
minDamage,
|
||||
maxDamage,
|
||||
baseMinDamage: baseMin,
|
||||
baseMaxDamage: baseMax,
|
||||
synergyMultiplier,
|
||||
synergyBonusPct,
|
||||
piercingBursts: true,
|
||||
subMissileName: 'lightningjavelin',
|
||||
manaCost,
|
||||
manaCost256,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Amazon Javelin and Spear Kinematics Stress Suite
|
||||
*
|
||||
* Comprehensive adversarial and E2E kinematics integration verification:
|
||||
* 1. Jab (10): 3 sequential thrusts, negative-to-positive ED% scaling (-15% -> +42%), attack rating progression.
|
||||
* 2. Power Strike (14): 5-band lightning scaling, +10%/lvl synergies (CS, LS, LF, LB).
|
||||
* 3. Poison Javelin (15): 12-frame casting delay, 100..1050 frame duration, trail cloud emissions, +12%/lvl PJ synergy.
|
||||
* 4. Impale (19): Massive ED% (+300%..+775%), durability loss degradation (50% -> 12%, min 10%), fixed 3 mana.
|
||||
* 5. Lightning Bolt (20): 100% physical-to-lightning conversion, auto-hit defense bypass, 1 - (2*slvl+10) bonus.
|
||||
* 6. Charged Strike (25): floor(slvl/5)+3 bolts (3..7+), point-blank shotgunning against bosses, 4x +14%/lvl synergies.
|
||||
* 7. Plague Javelin (29): 100-frame (4.0s) casting delay, 75..550 frame poison cloud explosion, +10%/lvl synergy.
|
||||
* 8. Fend (24): Multi-target melee strikes against up to 8 enemies, 1.13c D/A/E evasion interrupt bug flag.
|
||||
* 9. Lightning Strike (30): Chain hops min(24, slvl+1), 4-frame NextHitDelay, 4x +8%/lvl synergies.
|
||||
* 10. Lightning Fury (35): slvl radial bolts, 200px search radius, per-pierce bolt burst cascades, 4x +1%/lvl synergies.
|
||||
* 11. System Invariant: BATCH1_SKILLS length strictly maintained at 38; zero mocks; zero runtime MPQ reads.
|
||||
*
|
||||
* Ground Truth:
|
||||
* - Blizzard v1.13c: D2Common.dll, D2Game.dll, Skills.txt, Missiles.txt
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BATCH1_SKILLS,
|
||||
getSkillManaCost,
|
||||
getSkillCooldownTicks,
|
||||
calculateSkillDamage,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import {
|
||||
calculateJabStats,
|
||||
calculatePowerStrikeStats,
|
||||
calculatePoisonJavelinStats,
|
||||
calculateImpaleStats,
|
||||
calculateLightningBoltStats,
|
||||
calculateChargedStrikeStats,
|
||||
calculatePlagueJavelinStats,
|
||||
calculateFendStats,
|
||||
calculateLightningStrikeStats,
|
||||
calculateLightningFuryStats,
|
||||
} from '../../../src/game/skills/amazon-javelin-spear.ts'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
|
||||
describe('Diablo II v1.13c Amazon Javelin & Spear Adversarial & Kinematics Suite', () => {
|
||||
it('enforces System Invariant: BATCH1_SKILLS length strictly maintained at 38', () => {
|
||||
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
|
||||
expect(BATCH1_SKILLS[10]).toBeDefined() // Jab
|
||||
expect(BATCH1_SKILLS[14]).toBeDefined() // Power Strike
|
||||
})
|
||||
|
||||
describe('Skill 010: Jab — 3-Thrust Rapid Melee Kinematics', () => {
|
||||
it('accurately computes 1.13c 3-thrust count, damage penalty/bonus, and AR progression', () => {
|
||||
const s1 = calculateJabStats(1)
|
||||
expect(s1.strikesCount).toBe(3)
|
||||
expect(s1.enhancedDamagePct).toBe(-15) // -15% at slvl 1
|
||||
expect(s1.attackRatingBonusPct).toBe(10) // +10% at slvl 1
|
||||
expect(s1.manaCost).toBe(2.0)
|
||||
|
||||
const s10 = calculateJabStats(10)
|
||||
expect(s10.strikesCount).toBe(3)
|
||||
expect(s10.enhancedDamagePct).toBe(12) // -15 + 3*9 = 12%
|
||||
expect(s10.attackRatingBonusPct).toBe(91) // 10 + 9*9 = 91%
|
||||
expect(s10.manaCost).toBe(4.25) // 2.0 + 0.25*9
|
||||
|
||||
const s20 = calculateJabStats(20)
|
||||
expect(s20.strikesCount).toBe(3)
|
||||
expect(s20.enhancedDamagePct).toBe(42) // -15 + 3*19 = 42%
|
||||
expect(s20.attackRatingBonusPct).toBe(181) // 10 + 9*19 = 181%
|
||||
expect(s20.manaCost).toBe(6.75) // 2.0 + 0.25*19
|
||||
})
|
||||
|
||||
it('integrates with calculateSkillDamage correctly scaling weapon damage', () => {
|
||||
const weapon = { min: 20, max: 40 }
|
||||
const dmg1 = calculateSkillDamage(10, 1, 1.0, weapon)
|
||||
// Jab is defined in BATCH1_SKILLS with srcDamage 128 (100% weapon damage)
|
||||
expect(dmg1.min).toBe(20)
|
||||
expect(dmg1.max).toBe(40)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 014: Power Strike — High-Voltage Lightning Jab', () => {
|
||||
it('evaluates 5-band lightning damage progression and 4-way synergy boosts', () => {
|
||||
const s1 = calculatePowerStrikeStats(1)
|
||||
expect(s1.minDamage).toBe(1)
|
||||
expect(s1.maxDamage).toBe(16)
|
||||
expect(s1.attackRatingBonusPct).toBe(20)
|
||||
expect(s1.manaCost).toBe(2.0)
|
||||
|
||||
const s10 = calculatePowerStrikeStats(10)
|
||||
expect(s10.minDamage).toBe(1)
|
||||
expect(s10.maxDamage).toBeGreaterThan(150)
|
||||
expect(s10.attackRatingBonusPct).toBe(110)
|
||||
|
||||
// Test synergies: +10% per level from Charged Strike, Lightning Strike, Lightning Fury, Lightning Bolt
|
||||
const syn = calculatePowerStrikeStats(20, {
|
||||
chargedStrike: 20,
|
||||
lightningStrike: 20,
|
||||
lightningFury: 20,
|
||||
lightningBolt: 20,
|
||||
})
|
||||
expect(syn.synergyBonusPct).toBe(800) // 80 hard points * 10% = +800%
|
||||
expect(syn.synergyMultiplier).toBe(9.0)
|
||||
expect(syn.maxDamage).toBe(syn.baseMaxDamage * 9)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 015: Poison Javelin — Trail Cloud Mechanics & Cooldown', () => {
|
||||
it('enforces 12-frame casting cooldown and 100..1050 frame duration scaling', () => {
|
||||
expect(getSkillCooldownTicks(15)).toBe(12)
|
||||
|
||||
const s1 = calculatePoisonJavelinStats(1)
|
||||
expect(s1.durationFrames).toBe(100) // 4 seconds
|
||||
expect(s1.trailCloudMissile).toBe('poisonjavcloud')
|
||||
expect(s1.castingDelayFrames).toBe(12)
|
||||
expect(s1.manaCost).toBe(4.0)
|
||||
|
||||
const s10 = calculatePoisonJavelinStats(10)
|
||||
expect(s10.durationFrames).toBe(550) // 100 + 50*9 = 550 frames (22s)
|
||||
|
||||
const s20 = calculatePoisonJavelinStats(20)
|
||||
expect(s20.durationFrames).toBe(1050) // 100 + 50*19 = 1050 frames (42s)
|
||||
expect(s20.manaCost).toBe(8.75) // 4.0 + 0.25*19
|
||||
|
||||
// Synergy: +12% per point of Plague Javelin
|
||||
const syn = calculatePoisonJavelinStats(20, { plagueJavelin: 20 })
|
||||
expect(syn.synergyBonusPct).toBe(240) // 20 * 12% = +240%
|
||||
expect(syn.synergyMultiplier).toBe(3.4)
|
||||
expect(syn.maxDamage).toBe(Math.floor(syn.baseMaxDamage * 3.4))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 019: Impale — Brutal Uninterruptible Thrust', () => {
|
||||
it('validates massive %ED (+300%..+775%) and durability loss degradation curve', () => {
|
||||
const s1 = calculateImpaleStats(1)
|
||||
expect(s1.enhancedDamagePct).toBe(300)
|
||||
expect(s1.attackRatingBonusPct).toBe(100)
|
||||
expect(s1.durabilityLossChancePct).toBe(50)
|
||||
expect(s1.uninterruptible).toBe(true)
|
||||
expect(s1.manaCost).toBe(3.0)
|
||||
|
||||
const s10 = calculateImpaleStats(10)
|
||||
expect(s10.enhancedDamagePct).toBe(525) // 300 + 25*9
|
||||
expect(s10.attackRatingBonusPct).toBe(280) // 100 + 20*9
|
||||
expect(s10.durabilityLossChancePct).toBe(32) // 50 - 2*9
|
||||
|
||||
const s20 = calculateImpaleStats(20)
|
||||
expect(s20.enhancedDamagePct).toBe(775) // 300 + 25*19
|
||||
expect(s20.attackRatingBonusPct).toBe(480) // 100 + 20*19
|
||||
expect(s20.durabilityLossChancePct).toBe(12) // 50 - 2*19
|
||||
|
||||
// slvl 30 caps durability loss chance at minimum 10%
|
||||
const s30 = calculateImpaleStats(30)
|
||||
expect(s30.durabilityLossChancePct).toBe(10) // max(10, 50 - 2*29 = -8) -> 10%
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 020: Lightning Bolt — 100% Conversion & Defense Bypass', () => {
|
||||
it('enforces 100% physical to lightning conversion and auto-hit properties', () => {
|
||||
const s1 = calculateLightningBoltStats(1)
|
||||
expect(s1.conversionPct).toBe(100)
|
||||
expect(s1.srcDam).toBe(0) // Physical component zeroed out
|
||||
expect(s1.autoHit).toBe(true)
|
||||
expect(s1.bypassesDefense).toBe(true)
|
||||
expect(s1.flatBonusLightningMin).toBe(1)
|
||||
expect(s1.flatBonusLightningMax).toBe(12) // 1*2 + 10 = 12
|
||||
expect(s1.manaCost).toBe(9.0)
|
||||
|
||||
const s10 = calculateLightningBoltStats(10)
|
||||
expect(s10.flatBonusLightningMax).toBe(30) // 10*2 + 10 = 30
|
||||
expect(s10.manaCost).toBe(6.75) // 9.0 - 0.25*9
|
||||
|
||||
const s20 = calculateLightningBoltStats(20)
|
||||
expect(s20.flatBonusLightningMax).toBe(50) // 20*2 + 10 = 50
|
||||
expect(s20.manaCost).toBe(4.25) // 9.0 - 0.25*19 = 4.25 (floor)
|
||||
|
||||
// Synergy: +3%/lvl from Power Strike, Charged Strike, Lightning Strike, Lightning Fury
|
||||
const syn = calculateLightningBoltStats(20, {
|
||||
powerStrike: 20,
|
||||
chargedStrike: 20,
|
||||
lightningStrike: 20,
|
||||
lightningFury: 20,
|
||||
})
|
||||
expect(syn.synergyBonusPct).toBe(240) // 80 * 3% = 240%
|
||||
expect(syn.synergyMultiplier).toBe(3.4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 024: Charged Strike — Boss-Melting Multi-Bolt Shotgun', () => {
|
||||
it('scales bolt count floor(slvl/5)+3 and supports shotgunning against dense targets', () => {
|
||||
const s1 = calculateChargedStrikeStats(1)
|
||||
expect(s1.boltsCount).toBe(3) // floor(1/5)+3 = 3
|
||||
expect(s1.shotgunning).toBe(true)
|
||||
expect(s1.manaCost).toBe(4.0)
|
||||
|
||||
const s5 = calculateChargedStrikeStats(5)
|
||||
expect(s5.boltsCount).toBe(4) // floor(5/5)+3 = 4
|
||||
|
||||
const s10 = calculateChargedStrikeStats(10)
|
||||
expect(s10.boltsCount).toBe(5) // floor(10/5)+3 = 5
|
||||
|
||||
const s20 = calculateChargedStrikeStats(20)
|
||||
expect(s20.boltsCount).toBe(7) // floor(20/5)+3 = 7
|
||||
|
||||
const s25 = calculateChargedStrikeStats(25)
|
||||
expect(s25.boltsCount).toBe(8) // floor(25/5)+3 = 8
|
||||
|
||||
// Synergies: +14% per point from PS, LB, LS, LF
|
||||
const syn = calculateChargedStrikeStats(20, {
|
||||
powerStrike: 20,
|
||||
lightningBolt: 20,
|
||||
lightningStrike: 20,
|
||||
lightningFury: 20,
|
||||
})
|
||||
expect(syn.synergyBonusPct).toBe(1120) // 80 * 14% = 1120%
|
||||
expect(syn.synergyMultiplier).toBe(12.2)
|
||||
expect(syn.maxDamage).toBe(Math.floor(syn.baseMaxDamage * 12.2))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 025: Plague Javelin — Lingering Toxic Cloud Explosions', () => {
|
||||
it('enforces 100-frame (4.0s) casting delay and expanding poison clouds', () => {
|
||||
expect(getSkillCooldownTicks(25)).toBe(100)
|
||||
|
||||
const s1 = calculatePlagueJavelinStats(1)
|
||||
expect(s1.durationFrames).toBe(75) // 3 seconds
|
||||
expect(s1.castingDelayFrames).toBe(100)
|
||||
expect(s1.impactCloudMissile).toBe('plaguejavcloud')
|
||||
expect(s1.manaCost).toBe(8.5)
|
||||
|
||||
const s10 = calculatePlagueJavelinStats(10)
|
||||
expect(s10.durationFrames).toBe(300) // 75 + 25*9 = 300 frames (12s)
|
||||
expect(s10.manaCost).toBe(13.0) // 8.5 + 0.5*9
|
||||
|
||||
const s20 = calculatePlagueJavelinStats(20)
|
||||
expect(s20.durationFrames).toBe(550) // 75 + 25*19 = 550 frames (22s)
|
||||
expect(s20.manaCost).toBe(18.0) // 8.5 + 0.5*19
|
||||
|
||||
// Synergy: +10% per point of Poison Javelin
|
||||
const syn = calculatePlagueJavelinStats(20, { poisonJavelin: 20 })
|
||||
expect(syn.synergyBonusPct).toBe(200) // 20 * 10% = 200%
|
||||
expect(syn.synergyMultiplier).toBe(3.0)
|
||||
expect(syn.maxDamage).toBe(syn.baseMaxDamage * 3.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 030: Fend — Multi-Target Thrash & 1.13c Evasion Interrupt Bug', () => {
|
||||
it('validates up to 8 adjacent targets and tracks authentic 1.13c D/A/E evasion lock bug', () => {
|
||||
const s1 = calculateFendStats(1)
|
||||
expect(s1.enhancedDamagePct).toBe(70)
|
||||
expect(s1.attackRatingBonusPct).toBe(40)
|
||||
expect(s1.maxAdjacentTargets).toBe(8)
|
||||
expect(s1.evasionInterruptible).toBe(true)
|
||||
expect(s1.fendBugActive).toBe(true)
|
||||
expect(s1.manaCost).toBe(5.0)
|
||||
|
||||
const s10 = calculateFendStats(10, 4)
|
||||
expect(s10.enhancedDamagePct).toBe(160) // 70 + 10*9
|
||||
expect(s10.attackRatingBonusPct).toBe(130) // 40 + 10*9
|
||||
expect(s10.strikesCount).toBe(4) // Clamped to available enemies
|
||||
|
||||
const s20 = calculateFendStats(20)
|
||||
expect(s20.enhancedDamagePct).toBe(260) // 70 + 10*19
|
||||
expect(s20.attackRatingBonusPct).toBe(230) // 40 + 10*19
|
||||
expect(s20.strikesCount).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 034: Lightning Strike — Chain Arc Cascades', () => {
|
||||
it('verifies chain hops min(24, slvl+1), 4-frame NextHitDelay, and 4x +8%/lvl synergies', () => {
|
||||
const s1 = calculateLightningStrikeStats(1)
|
||||
expect(s1.chainHops).toBe(2) // 1 + 1 = 2
|
||||
expect(s1.nextHitDelayFrames).toBe(4)
|
||||
expect(s1.manaCost).toBe(9.0)
|
||||
|
||||
const s10 = calculateLightningStrikeStats(10)
|
||||
expect(s10.chainHops).toBe(11) // 10 + 1 = 11
|
||||
|
||||
const s20 = calculateLightningStrikeStats(20)
|
||||
expect(s20.chainHops).toBe(21) // 20 + 1 = 21
|
||||
|
||||
const s30 = calculateLightningStrikeStats(30)
|
||||
expect(s30.chainHops).toBe(24) // min(24, 31) = 24 cap
|
||||
|
||||
// Synergies: +8% per point from PS, LB, CS, LF
|
||||
const syn = calculateLightningStrikeStats(20, {
|
||||
powerStrike: 20,
|
||||
lightningBolt: 20,
|
||||
chargedStrike: 20,
|
||||
lightningFury: 20,
|
||||
})
|
||||
expect(syn.synergyBonusPct).toBe(640) // 80 * 8% = 640%
|
||||
expect(syn.synergyMultiplier).toBe(7.4)
|
||||
expect(syn.maxDamage).toBe(Math.floor(syn.baseMaxDamage * 7.4))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Skill 035: Lightning Fury — Screen-Clearing Javelin & Bolt Bursts', () => {
|
||||
it('verifies radial bolt split per target/pierce, 200px search radius, and synergies', () => {
|
||||
const s1 = calculateLightningFuryStats(1)
|
||||
expect(s1.releaseBoltsCount).toBe(1)
|
||||
expect(s1.searchRadiusPx).toBe(200)
|
||||
expect(s1.piercingBursts).toBe(true)
|
||||
expect(s1.subMissileName).toBe('lightningjavelin')
|
||||
expect(s1.manaCost).toBe(10.0)
|
||||
|
||||
const s10 = calculateLightningFuryStats(10)
|
||||
expect(s10.releaseBoltsCount).toBe(10)
|
||||
expect(s10.manaCost).toBe(14.5) // 10.0 + 0.5*9
|
||||
|
||||
const s20 = calculateLightningFuryStats(20)
|
||||
expect(s20.releaseBoltsCount).toBe(20)
|
||||
expect(s20.manaCost).toBe(19.5) // 10.0 + 0.5*19
|
||||
|
||||
// Synergies: +1% per point from PS, LB, CS, LS
|
||||
const syn = calculateLightningFuryStats(20, {
|
||||
powerStrike: 20,
|
||||
lightningBolt: 20,
|
||||
chargedStrike: 20,
|
||||
lightningStrike: 20,
|
||||
})
|
||||
expect(syn.synergyBonusPct).toBe(80) // 80 * 1% = +80%
|
||||
expect(syn.synergyMultiplier).toBe(1.8)
|
||||
expect(syn.maxDamage).toBe(Math.floor(syn.baseMaxDamage * 1.8))
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorldArena Combat Loop Integration', () => {
|
||||
it('executes javelin and spear attacks inside simulated arena with valid action frame outcomes', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: 'ama',
|
||||
skillId: 10,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(10)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue