diff --git a/src/game/skills.ts b/src/game/skills.ts index 7e5e9fe..f25db4d 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -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' || diff --git a/src/game/skills/amazon-javelin-spear.ts b/src/game/skills/amazon-javelin-spear.ts new file mode 100644 index 0000000..5f666d4 --- /dev/null +++ b/src/game/skills/amazon-javelin-spear.ts @@ -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, + } +} diff --git a/tests/skills/ama/adv-ama-javelin-spear-stress.test.ts b/tests/skills/ama/adv-ama-javelin-spear-stress.test.ts new file mode 100644 index 0000000..3d7cffa --- /dev/null +++ b/tests/skills/ama/adv-ama-javelin-spear-stress.test.ts @@ -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) + }) + }) +})