diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index afcf424..62bd95a 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -657,6 +657,8 @@ export interface ActiveMissile { spiralTheta?: number | undefined readonly volleyId?: string | undefined readonly canTriggerProcs?: boolean | undefined + isSlowed?: boolean | undefined + slowMultiplier?: number | undefined } export interface MissileTerrainCollision { @@ -717,6 +719,8 @@ export class MissileEngine { angleRad?: number volleyId?: string canTriggerProcs?: boolean + isSlowed?: boolean + slowMultiplier?: number }): ActiveMissile | null { const rec = typeof params.missileNameOrId === 'number' @@ -934,6 +938,8 @@ export class MissileEngine { originX: params.startX, originY: params.startY, spiralTheta: angleRad, + ...(params.isSlowed !== undefined ? { isSlowed: params.isSlowed } : {}), + ...(params.slowMultiplier !== undefined ? { slowMultiplier: params.slowMultiplier } : {}), } this.missiles.push(missile) @@ -1189,8 +1195,10 @@ export class MissileEngine { } } - m.x += m.vx - m.y += m.vy + // Slow Missiles: scale velocity by 1/3 (0.33) if missile is slowed or owner is affected by slowmissiles + const effectiveSpeedMult = m.slowMultiplier ?? (m.isSlowed || m.owner.stateBus.hasState('slowmissiles') ? 0.33 : 1.0) + m.x += m.vx * effectiveSpeedMult + m.y += m.vy * effectiveSpeedMult const effectiveTerrain = collision ?? this.terrain if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) { diff --git a/src/game/engine/stat-list.ts b/src/game/engine/stat-list.ts index 7d0000a..02d31a1 100644 --- a/src/game/engine/stat-list.ts +++ b/src/game/engine/stat-list.ts @@ -193,8 +193,11 @@ export class UnitStatList { } else if (key === 'armorclass') { const pct = this.getModifierBonus('item_armor_percent') + + this.getBaseStat('item_armor_percent') + this.getModifierBonus('skill_armor_percent') + - this.getModifierBonus('armor_percent') + this.getBaseStat('skill_armor_percent') + + this.getModifierBonus('armor_percent') + + this.getBaseStat('armor_percent') if (pct !== 0) { total = Math.trunc((total * (100 + pct)) / 100) } @@ -221,10 +224,12 @@ export class UnitStatList { total += blvlBlessedAim * 5 } else if (key === 'tohit') { const blvlBlessedAim = this.getBaseSkillLevel(108) - const passiveArBonus = blvlBlessedAim * 5 + this.getModifierBonus('passive_ar_bonus_pct') + const passiveArBonus = blvlBlessedAim * 5 + this.getModifierBonus('passive_ar_bonus_pct') + this.getBaseStat('passive_ar_bonus_pct') const pct = this.getModifierBonus('tohit_percent') + + this.getBaseStat('tohit_percent') + this.getModifierBonus('item_tohit_percent') + + this.getBaseStat('item_tohit_percent') + passiveArBonus if (pct !== 0) { total = Math.trunc((total * (100 + pct)) / 100) diff --git a/src/game/engine/summon-manager.ts b/src/game/engine/summon-manager.ts index a180c2b..85df3dc 100644 --- a/src/game/engine/summon-manager.ts +++ b/src/game/engine/summon-manager.ts @@ -30,6 +30,16 @@ import { calculateReviveStats, type IronGolemItemProps, } from './missile-engine.ts' +import { + calculateDecoyStats, + calculateValkyrieStats, + calculateAvoidStats, + calculateCriticalStrikeStats, + calculateDodgeStats, + calculateEvadeStats, + calculatePenetrateStats, + type ValkyrieEquipmentTier, +} from '../skills/amazon-passive.ts' export interface SummonedPetUnit { readonly uid: string @@ -49,8 +59,8 @@ export interface SummonedPetUnit { maxHp: number shotsRemaining: number rageBuffActive: boolean - // M4 Necromancer Summoning Properties - kind?: 'skeleton' | 'skeleton_mage' | 'clay_golem' | 'blood_golem' | 'iron_golem' | 'fire_golem' | 'revive' | undefined + // M4 Necromancer Summoning Properties & M13 Amazon Summon Properties + kind?: 'skeleton' | 'skeleton_mage' | 'clay_golem' | 'blood_golem' | 'iron_golem' | 'fire_golem' | 'revive' | 'dopplezon' | 'valkyrie' | undefined mageElement?: 'fire' | 'cold' | 'lightning' | 'poison' | undefined lifespanTicks?: number | undefined slowPct?: number | undefined @@ -59,6 +69,8 @@ export interface SummonedPetUnit { fireAbsorbPct?: number | undefined deathExplosionDmg?: number | undefined inheritedAuras?: number[] | undefined + isStationary?: boolean | undefined + equipmentTier?: ValkyrieEquipmentTier | undefined } export interface CreatePetOutcome { @@ -249,16 +261,72 @@ export class SummonManager { lifespanTicks = revStats.durationFrames const corpseBaseHp = consumedCorpse?.baseMaxHp ?? 1000 baseHp = Math.floor(corpseBaseHp * revStats.hpMultiplier) + } else if (params.skillId === 28) { + kind = 'dopplezon' + const amazonHp = Math.trunc(params.owner.statList.getMaxHp256() / FIXED_ONE) || 100 + const amazonDef = params.owner.statList.getAccruedStat('armorclass') || 0 + const avoidLvl = params.owner.statList.getBaseSkillLevel(18) + const evadeLvl = params.owner.statList.getBaseSkillLevel(29) + const decoyStats = calculateDecoyStats(params.slvl, amazonHp, amazonDef, { avoidLvl, evadeLvl }) + baseHp = decoyStats.hp + lifespanTicks = decoyStats.durationFrames + } else if (params.skillId === 32) { + kind = 'valkyrie' + const decoyHardPoints = params.owner.statList.getBaseSkillLevel(28) + const csLvl = params.owner.statList.getBaseSkillLevel(9) + const dodgeLvl = params.owner.statList.getBaseSkillLevel(13) + const avoidLvl = params.owner.statList.getBaseSkillLevel(18) + const evadeLvl = params.owner.statList.getBaseSkillLevel(29) + const penetrateLvl = params.owner.statList.getBaseSkillLevel(23) + const valkStats = calculateValkyrieStats(params.slvl, { + decoyHardPoints, + csLvl, + dodgeLvl, + avoidLvl, + evadeLvl, + penetrateLvl, + }) + baseHp = valkStats.finalHp } + let decoyRes = 0 + let valkRes = 0 + let petDef = 0 + let isStationary = false + let equipmentTier: ValkyrieEquipmentTier | undefined + + if (params.skillId === 28) { + isStationary = true + petDef = params.owner.statList.getAccruedStat('armorclass') || 0 + decoyRes = Math.min(85, 4 * params.slvl) + } else if (params.skillId === 32) { + valkRes = Math.min(75, 2 * params.slvl) + const valkStats = calculateValkyrieStats(params.slvl) + equipmentTier = valkStats.equipmentTier + } + + const effectiveRes = decoyRes || valkRes || sumResPct + const petStatList = new UnitStatList(this.registry, { level: params.owner.statList.getAccruedStat('level'), maxhp: baseHp * FIXED_ONE, hitpoints: baseHp * FIXED_ONE, - fireresist: sumResPct, - lightresist: sumResPct, - coldresist: sumResPct, - poisonresist: sumResPct, + armorclass: petDef, + fireresist: effectiveRes, + lightresist: effectiveRes, + coldresist: effectiveRes, + poisonresist: effectiveRes, + ...(params.skillId === 28 ? { + passive_avoid: params.owner.statList.getBaseSkillLevel(18) > 0 ? calculateAvoidStats(params.owner.statList.getBaseSkillLevel(18)).chancePct : 0, + passive_evade: params.owner.statList.getBaseSkillLevel(29) > 0 ? calculateEvadeStats(params.owner.statList.getBaseSkillLevel(29)).chancePct : 0, + } : {}), + ...(params.skillId === 32 ? { + passive_critical_strike: params.owner.statList.getBaseSkillLevel(9) > 0 ? calculateCriticalStrikeStats(params.owner.statList.getBaseSkillLevel(9)).chancePct : 0, + passive_dodge: params.owner.statList.getBaseSkillLevel(13) > 0 ? calculateDodgeStats(params.owner.statList.getBaseSkillLevel(13)).chancePct : 0, + passive_avoid: params.owner.statList.getBaseSkillLevel(18) > 0 ? calculateAvoidStats(params.owner.statList.getBaseSkillLevel(18)).chancePct : 0, + passive_evade: params.owner.statList.getBaseSkillLevel(29) > 0 ? calculateEvadeStats(params.owner.statList.getBaseSkillLevel(29)).chancePct : 0, + item_tohit_percent: params.owner.statList.getBaseSkillLevel(23) > 0 ? calculatePenetrateStats(params.owner.statList.getBaseSkillLevel(23)).attackRatingBonusPct : 0, + } : {}), ...(slowPct ? { clay_golem_slow: slowPct } : {}), ...(thornsReturnPct ? { thorns_return_pct: thornsReturnPct } : {}), ...(fireAbsorbPct ? { fire_golem_absorb: fireAbsorbPct } : {}), @@ -311,6 +379,8 @@ export class SummonManager { fireAbsorbPct, deathExplosionDmg, inheritedAuras, + isStationary, + ...(equipmentTier ? { equipmentTier } : {}), } this.pets.push(pet) @@ -534,8 +604,11 @@ export class SummonManager { } // Standard melee attack for non-totem combat pets (`Skeleton`, `Golem`, `Wolf`, `Grizzly`, `Valkyrie`, `Shadow`) + // Stationary Decoy (dopplezon) cannot attack if ( primaryTarget && + !pet.isStationary && + pet.kind !== 'dopplezon' && pet.petGroup !== 2 && pet.petType !== 'assassintrap' && pet.petType !== 'hydra' && diff --git a/src/game/skills.ts b/src/game/skills.ts index cafca65..7e5e9fe 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -333,6 +333,52 @@ export { type ImmolationArrowStats, type FreezingArrowStats, } from './skills/amazon-bow.ts' +import { + calculateInnerSightStats, + calculateCriticalStrikeStats, + calculateDodgeStats, + calculateSlowMissilesStats, + calculateAvoidStats, + calculatePenetrateStats, + calculateDecoyStats, + calculateEvadeStats, + calculateValkyrieStats, + calculatePierceStats, + type InnerSightStats, + type CriticalStrikeStats, + type DodgeStats, + type SlowMissilesStats, + type AvoidStats, + type PenetrateStats, + type DecoyStats, + type EvadeStats, + type ValkyrieStats, + type ValkyrieEquipmentTier, + type PierceStats, +} from './skills/amazon-passive.ts' +export { + calculateInnerSightStats, + calculateCriticalStrikeStats, + calculateDodgeStats, + calculateSlowMissilesStats, + calculateAvoidStats, + calculatePenetrateStats, + calculateDecoyStats, + calculateEvadeStats, + calculateValkyrieStats, + calculatePierceStats, + type InnerSightStats, + type CriticalStrikeStats, + type DodgeStats, + type SlowMissilesStats, + type AvoidStats, + type PenetrateStats, + type DecoyStats, + type EvadeStats, + type ValkyrieStats, + type ValkyrieEquipmentTier, + type PierceStats, +} from './skills/amazon-passive.ts' /** One castable skill, read from a table row. */ export interface SkillDef { @@ -5603,30 +5649,60 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe if (skillId === 7) { return calculateFireArrowStats(effectiveLevel).manaCost } + if (skillId === 8) { + return calculateInnerSightStats(effectiveLevel).manaCost + } + if (skillId === 9) { + return calculateCriticalStrikeStats(effectiveLevel).manaCost + } if (skillId === 11) { return calculateColdArrowStats(effectiveLevel).manaCost } if (skillId === 12) { return calculateMultipleShotStats(effectiveLevel).manaCost } + if (skillId === 13) { + return calculateDodgeStats(effectiveLevel).manaCost + } if (skillId === 16) { return calculateExplodingArrowStats(effectiveLevel).manaCost } + if (skillId === 17) { + return calculateSlowMissilesStats(effectiveLevel).manaCost + } + if (skillId === 18) { + return calculateAvoidStats(effectiveLevel).manaCost + } if (skillId === 21) { return calculateIceArrowStats(effectiveLevel).manaCost } if (skillId === 22) { return calculateGuidedArrowStats(effectiveLevel).manaCost } + if (skillId === 23) { + return calculatePenetrateStats(effectiveLevel).manaCost + } if (skillId === 26) { return calculateStrafeStats(effectiveLevel).manaCost } if (skillId === 27) { return calculateImmolationArrowStats(effectiveLevel).manaCost } + if (skillId === 28) { + return calculateDecoyStats(effectiveLevel).manaCost + } + if (skillId === 29) { + return calculateEvadeStats(effectiveLevel).manaCost + } if (skillId === 31) { return calculateFreezingArrowStats(effectiveLevel).manaCost } + if (skillId === 32) { + return calculateValkyrieStats(effectiveLevel).manaCost + } + if (skillId === 33) { + return calculatePierceStats(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) @@ -5762,13 +5838,22 @@ export function getSkillCooldownTicks(skillId: number): number { if ( skillId === 6 || skillId === 7 || + skillId === 8 || + skillId === 9 || skillId === 11 || skillId === 12 || + skillId === 13 || skillId === 16 || + skillId === 17 || + skillId === 18 || skillId === 21 || skillId === 22 || + skillId === 23 || skillId === 26 || + skillId === 28 || + skillId === 29 || skillId === 31 || + skillId === 33 || skillId === 38 || skillId === 40 || skillId === 41 || @@ -5795,6 +5880,9 @@ export function getSkillCooldownTicks(skillId: number): number { if (skillId === 27) { return 25 // Immolation Arrow: 25 frames (1.0s) } + if (skillId === 32) { + return 150 // Valkyrie: 150 frames (6.0s) casting delay + } if (skillId === 51) { return 35 } @@ -5908,6 +5996,20 @@ export function calculateSkillDamage( const baseMax = weaponDamage?.max ?? 0 return { min: baseMin + stats.minColdDamage, max: baseMax + stats.maxColdDamage } } + if ( + skillId === 8 || skillId === '8' || skillId === 'innersight' || skillId === 'inner sight' || + skillId === 9 || skillId === '9' || skillId === 'criticalstrike' || skillId === 'critical strike' || + skillId === 13 || skillId === '13' || skillId === 'dodge' || + skillId === 17 || skillId === '17' || skillId === 'slowmissiles' || skillId === 'slow missiles' || + skillId === 18 || skillId === '18' || skillId === 'avoid' || + skillId === 23 || skillId === '23' || skillId === 'penetrate' || + skillId === 28 || skillId === '28' || skillId === 'decoy' || skillId === 'dopplezon' || + skillId === 29 || skillId === '29' || skillId === 'evade' || + skillId === 32 || skillId === '32' || skillId === 'valkyrie' || + skillId === 33 || skillId === '33' || skillId === 'pierce' + ) { + return { min: 0, max: 0 } + } if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') { return calculateChargedBoltDamage(slvl, synergies as any) } diff --git a/src/game/skills/amazon-passive.ts b/src/game/skills/amazon-passive.ts new file mode 100644 index 0000000..e2534f7 --- /dev/null +++ b/src/game/skills/amazon-passive.ts @@ -0,0 +1,428 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Amazon Passive & Magic Skills Tree Module + * + * Dedicated pure calculation and kinematics formulas for the 10 passive & magic skills: + * - Skill 008: Inner Sight + * - Skill 009: Critical Strike + * - Skill 013: Dodge + * - Skill 017: Slow Missiles + * - Skill 018: Avoid + * - Skill 023: Penetrate + * - Skill 028: Decoy (Dopplezon) + * - Skill 029: Evade + * - Skill 032: Valkyrie + * - Skill 033: Pierce + * + * 1.13c Ground Truth: + * - D2Common.dll / D2Game.dll / Patch_D2.mpq: Skills.txt, Missiles.txt, MonStats.txt + * - `computeDiminishingReturns(a, b, lvl)` (`dm12` / `dm(a, b, lvl)`) for CS, Dodge, Avoid, Evade, Pierce + * - Inner Sight: flat defense reduction `-40 - 25 * (slvl - 1)`, duration `700 + 150 * (slvl - 1)` frames, 200px radius, fixed 5 mana + * - Critical Strike: `dm(5, 80, slvl)`, sequential doubling with Deadly Strike (never quadrupling), 0 mana + * - Dodge: melee avoidance while standing/attacking (`dm(10, 65, slvl)`), `GH` animation lock, 0 mana + * - Slow Missiles: 200px radius, duration `300 + 60 * (slvl - 1)` frames, missile velocity reduced to 33%, fixed 5 mana + * - Avoid: ranged missile avoidance while standing/attacking (`dm(15, 75, slvl)`), `GH` animation lock, 0 mana + * - Penetrate: linear Attack Rating bonus `+35% + 10% * (slvl - 1)`, 0 mana + * - Decoy: duration `250 + 125 * (slvl - 1)` frames, HP `Amazon HP * (0.5 + 0.1 * slvl)`, max 85% elemental resistance, + * stationary posture, draws monster threat, inherits Avoid/Evade, mana `max(1, 19 - (slvl - 1))` + * - Evade: avoidance while moving (`dm(10, 65, slvl)`), zero animation lock (uninterrupted motion), 0 mana + * - Valkyrie: base HP `440 * (1 + 0.2 * (slvl - 1))`, +20% life per Decoy hard point, inherits passives at Amazon's slvl, + * 150-frame (6.0s) casting delay, 5 equipment generation tiers, mana `25 + (slvl - 1)` + * - Pierce: diminishing returns `dm(10, 100, slvl)`, max 4 pierces (5 hits), Guided Arrow strict 0% pierce override, 0 mana + */ + +import { computeDiminishingReturns } from '../engine/calc-ast.ts' + +// --- Interfaces for Skill Calculations --- + +export interface InnerSightStats { + readonly flatDefenseReduction: number + readonly durationFrames: number + readonly durationSeconds: number + readonly radiusPx: number + readonly radiusSubtiles: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface CriticalStrikeStats { + readonly chancePct: number + readonly damageMultiplier: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface DodgeStats { + readonly chancePct: number + readonly triggersAnimationLock: boolean + readonly animationCode: 'GH' + readonly appliesTo: 'melee' + readonly condition: 'stationary_or_attacking' + readonly manaCost: number + readonly manaCost256: number +} + +export interface SlowMissilesStats { + readonly durationFrames: number + readonly durationSeconds: number + readonly radiusPx: number + readonly radiusSubtiles: number + readonly velocityMultiplier: number + readonly velocityReductionPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface AvoidStats { + readonly chancePct: number + readonly triggersAnimationLock: boolean + readonly animationCode: 'GH' + readonly appliesTo: 'missile' + readonly condition: 'stationary_or_attacking' + readonly manaCost: number + readonly manaCost256: number +} + +export interface PenetrateStats { + readonly attackRatingBonusPct: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface DecoyStats { + readonly durationFrames: number + readonly durationSeconds: number + readonly hp: number + readonly hpPctOfAmazon: number + readonly allResistancesPct: number + readonly defense: number + readonly inheritedAvoidLvl: number + readonly inheritedEvadeLvl: number + readonly isStationary: boolean + readonly drawsThreat: boolean + readonly manaCost: number + readonly manaCost256: number +} + +export interface EvadeStats { + readonly chancePct: number + readonly triggersAnimationLock: boolean + readonly appliesTo: 'melee_or_missile' + readonly condition: 'moving' + readonly uninterrupted: boolean + readonly manaCost: number + readonly manaCost256: number +} + +export type ValkyrieEquipmentTier = 'tier1_basic' | 'tier2_boots' | 'tier3_gloves' | 'tier4_war_pike' | 'tier5_rare_tiara' + +export interface ValkyrieStats { + readonly baseHp: number + readonly finalHp: number + readonly decoySynergyBonusPct: number + readonly allResistancesPct: number + readonly cooldownFrames: number + readonly cooldownSeconds: number + readonly equipmentTier: ValkyrieEquipmentTier + readonly inheritedCriticalStrikeLvl: number + readonly inheritedDodgeLvl: number + readonly inheritedAvoidLvl: number + readonly inheritedEvadeLvl: number + readonly inheritedPenetrateLvl: number + readonly manaCost: number + readonly manaCost256: number +} + +export interface PierceStats { + readonly chancePct: number + readonly maxPierceCount: number + readonly maxHitsPerMissile: number + readonly isGuidedArrow: boolean + readonly manaCost: number + readonly manaCost256: number +} + +// --- Pure Calculation Functions --- + +/** + * Skill 008: Inner Sight + * Radius: 200px (20 subtiles / 13.33 yards) + * Duration: `700 + 150 * (slvl - 1)` frames (`28.0s + 6.0s/lvl`) + * Flat Defense Reduction: `-40 - 25 * (slvl - 1)` + * Mana: fixed 5 mana (1280 in 24.8 fixed point) + */ +export function calculateInnerSightStats(slvl: number): InnerSightStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const flatDefenseReduction = -40 - 25 * (lvl - 1) + const durationFrames = 700 + 150 * (lvl - 1) + const durationSeconds = durationFrames / 25 + const radiusPx = 200 + const radiusSubtiles = 20 + const manaCost = 5 + const manaCost256 = 1280 + + return { + flatDefenseReduction, + durationFrames, + durationSeconds, + radiusPx, + radiusSubtiles, + manaCost, + manaCost256, + } +} + +/** + * Skill 009: Critical Strike + * Chance %: `computeDiminishingReturns(5, 80, slvl)` + * Multiplies physical damage component only (x2). + * Strictly sequential evaluation with Deadly Strike (never quadrupling). + * Mana: 0 (passive). + */ +export function calculateCriticalStrikeStats(slvl: number): CriticalStrikeStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const chancePct = computeDiminishingReturns(5, 80, lvl) + return { + chancePct, + damageMultiplier: 2.0, + manaCost: 0, + manaCost256: 0, + } +} + +/** + * Skill 013: Dodge + * Chance %: `computeDiminishingReturns(10, 65, slvl)` + * Melee avoidance while standing still or attacking (`!defender.isMoving`). + * Triggers `GH` animation lock. + * Mana: 0 (passive). + */ +export function calculateDodgeStats(slvl: number): DodgeStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const chancePct = computeDiminishingReturns(10, 65, lvl) + return { + chancePct, + triggersAnimationLock: true, + animationCode: 'GH', + appliesTo: 'melee', + condition: 'stationary_or_attacking', + manaCost: 0, + manaCost256: 0, + } +} + +/** + * Skill 017: Slow Missiles + * Radius: 200px (20 subtiles / 13.33 yards) + * Duration: `300 + 60 * (slvl - 1)` frames (`12.0s + 2.4s/lvl`) + * Missile Velocity: scaled down to 33% (67% speed reduction) + * Mana: fixed 5 mana (1280 in 24.8 fixed point) + */ +export function calculateSlowMissilesStats(slvl: number): SlowMissilesStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const durationFrames = 300 + 60 * (lvl - 1) + const durationSeconds = durationFrames / 25 + const radiusPx = 200 + const radiusSubtiles = 20 + const velocityMultiplier = 0.33 + const velocityReductionPct = 67 + const manaCost = 5 + const manaCost256 = 1280 + + return { + durationFrames, + durationSeconds, + radiusPx, + radiusSubtiles, + velocityMultiplier, + velocityReductionPct, + manaCost, + manaCost256, + } +} + +/** + * Skill 018: Avoid + * Chance %: `computeDiminishingReturns(15, 75, slvl)` + * Ranged missile avoidance while standing still or attacking (`!defender.isMoving`). + * Triggers `GH` animation lock. + * Mana: 0 (passive). + */ +export function calculateAvoidStats(slvl: number): AvoidStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const chancePct = computeDiminishingReturns(15, 75, lvl) + return { + chancePct, + triggersAnimationLock: true, + animationCode: 'GH', + appliesTo: 'missile', + condition: 'stationary_or_attacking', + manaCost: 0, + manaCost256: 0, + } +} + +/** + * Skill 023: Penetrate + * Attack Rating Bonus %: `+35% + 10% * (slvl - 1)` + * Directly accrued to player's global `item_tohit_percent` stat. + * Mana: 0 (passive). + */ +export function calculatePenetrateStats(slvl: number): PenetrateStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const attackRatingBonusPct = 35 + 10 * (lvl - 1) + return { + attackRatingBonusPct, + manaCost: 0, + manaCost256: 0, + } +} + +/** + * Skill 028: Decoy (Dopplezon) + * Duration: `250 + 125 * (slvl - 1)` frames (`10.0s + 5.0s/lvl`) + * HP: `Amazon HP * (0.5 + 0.1 * slvl)` + * Resistances: `min(85, 4 * slvl)%` + * Defense: inherits Amazon's defense at instant of casting + * AI Contract: Stationary posture (`NU` mode), cannot move or attack, draws monster threat + * Mana: `max(1, 19 - (slvl - 1))` (19, 18, 17... down to 1) + */ +export function calculateDecoyStats( + slvl: number, + amazonBaseHp: number = 100, + amazonDef: number = 0, + synergies?: { avoidLvl?: number; evadeLvl?: number }, +): DecoyStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const durationFrames = 250 + 125 * (lvl - 1) + const durationSeconds = durationFrames / 25 + const hpPctOfAmazon = Math.round((0.5 + 0.1 * lvl) * 100) + const hp = Math.floor(amazonBaseHp * (0.5 + 0.1 * lvl)) + const allResistancesPct = Math.min(85, 4 * lvl) + const manaCost = Math.max(1, 19 - (lvl - 1)) + const manaCost256 = Math.max(256, (76 - 3 * (lvl - 1)) << 6) // Skills.txt: mana=76, lvlmana=-3, manashift=6 -> 19 - 0.75*(lvl-1) in int or 19 - (lvl-1) + + return { + durationFrames, + durationSeconds, + hp, + hpPctOfAmazon, + allResistancesPct, + defense: amazonDef, + inheritedAvoidLvl: synergies?.avoidLvl ?? 0, + inheritedEvadeLvl: synergies?.evadeLvl ?? 0, + isStationary: true, + drawsThreat: true, + manaCost, + manaCost256, + } +} + +/** + * Skill 029: Evade + * Chance %: `computeDiminishingReturns(10, 65, slvl)` + * Avoidance while moving (`defender.isMoving === true`) against both melee and missile attacks. + * Zero animation lock (Amazon moves uninterrupted, never plays `GH`). + * Mana: 0 (passive). + */ +export function calculateEvadeStats(slvl: number): EvadeStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const chancePct = computeDiminishingReturns(10, 65, lvl) + return { + chancePct, + triggersAnimationLock: false, + appliesTo: 'melee_or_missile', + condition: 'moving', + uninterrupted: true, + manaCost: 0, + manaCost256: 0, + } +} + +/** + * Skill 032: Valkyrie + * Cooldown: 150 frames (6.0s casting delay) + * Mana Cost: `25 + (slvl - 1)` + * Base HP: `440 * (1 + 0.2 * (slvl - 1))` + * Synergy HP: +20% life per hard point in Decoy (`skill('Dopplezon'.blvl) * 20%`) + * Resistances: `min(75, 2 * slvl)%` + * Equipment Generation Tiers: + * - slvl 1: Spear, Full Plate Mail + * - slvl 7: Adds Heavy Boots + * - slvl 11: Adds Gloves + * - slvl 17: Spear upgraded to Rare War Pike + * - slvl 27+: Magical/Rare Crusader Gauntlets, Tiara, Sacred Armor + * Passive Inheritance: Inherits CS, Dodge, Avoid, Evade, Penetrate at Amazon's slvl + */ +export function calculateValkyrieStats( + slvl: number, + synergies?: { + decoyHardPoints?: number + csLvl?: number + dodgeLvl?: number + avoidLvl?: number + evadeLvl?: number + penetrateLvl?: number + }, +): ValkyrieStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const baseHp = Math.floor(440 * (1 + 0.2 * (lvl - 1))) + const decoyHardPoints = synergies?.decoyHardPoints ?? 0 + const decoySynergyBonusPct = decoyHardPoints * 20 + const finalHp = Math.floor(baseHp * (1 + decoySynergyBonusPct / 100)) + const allResistancesPct = Math.min(75, 2 * lvl) + const cooldownFrames = 150 + const cooldownSeconds = 6.0 + const manaCost = 25 + (lvl - 1) + const manaCost256 = (25 + (lvl - 1)) * 256 + + let equipmentTier: ValkyrieEquipmentTier = 'tier1_basic' + if (lvl >= 27) { + equipmentTier = 'tier5_rare_tiara' + } else if (lvl >= 17) { + equipmentTier = 'tier4_war_pike' + } else if (lvl >= 11) { + equipmentTier = 'tier3_gloves' + } else if (lvl >= 7) { + equipmentTier = 'tier2_boots' + } + + return { + baseHp, + finalHp, + decoySynergyBonusPct, + allResistancesPct, + cooldownFrames, + cooldownSeconds, + equipmentTier, + inheritedCriticalStrikeLvl: synergies?.csLvl ?? 0, + inheritedDodgeLvl: synergies?.dodgeLvl ?? 0, + inheritedAvoidLvl: synergies?.avoidLvl ?? 0, + inheritedEvadeLvl: synergies?.evadeLvl ?? 0, + inheritedPenetrateLvl: synergies?.penetrateLvl ?? 0, + manaCost, + manaCost256, + } +} + +/** + * Skill 033: Pierce + * Chance %: `computeDiminishingReturns(10, 100, slvl)` + * Max Pierce Count: 4 pierces (5 hits max) + * Guided Arrow (`skillId === 22`) strict exception: 0% clamp + * Mana: 0 (passive). + */ +export function calculatePierceStats(slvl: number, skillId?: number): PierceStats { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + const isGuidedArrow = skillId === 22 + const rawChance = computeDiminishingReturns(10, 100, lvl) + const chancePct = isGuidedArrow ? 0 : rawChance + + return { + chancePct, + maxPierceCount: 4, + maxHitsPerMissile: 5, + isGuidedArrow, + manaCost: 0, + manaCost256: 0, + } +} diff --git a/src/game/skills/impl/ama/skill-008-inner-sight.ts b/src/game/skills/impl/ama/skill-008-inner-sight.ts index a2827e4..bfccb92 100644 --- a/src/game/skills/impl/ama/skill-008-inner-sight.ts +++ b/src/game/skills/impl/ama/skill-008-inner-sight.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateInnerSightStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-009-critical-strike.ts b/src/game/skills/impl/ama/skill-009-critical-strike.ts index 43aea00..d543608 100644 --- a/src/game/skills/impl/ama/skill-009-critical-strike.ts +++ b/src/game/skills/impl/ama/skill-009-critical-strike.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateCriticalStrikeStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-013-dodge.ts b/src/game/skills/impl/ama/skill-013-dodge.ts index 20d95b1..afc528c 100644 --- a/src/game/skills/impl/ama/skill-013-dodge.ts +++ b/src/game/skills/impl/ama/skill-013-dodge.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateDodgeStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-017-slow-missiles.ts b/src/game/skills/impl/ama/skill-017-slow-missiles.ts index cf38140..7380e39 100644 --- a/src/game/skills/impl/ama/skill-017-slow-missiles.ts +++ b/src/game/skills/impl/ama/skill-017-slow-missiles.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateSlowMissilesStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-018-avoid.ts b/src/game/skills/impl/ama/skill-018-avoid.ts index 62e2ef8..ba371c9 100644 --- a/src/game/skills/impl/ama/skill-018-avoid.ts +++ b/src/game/skills/impl/ama/skill-018-avoid.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateAvoidStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-023-penetrate.ts b/src/game/skills/impl/ama/skill-023-penetrate.ts index 6b07932..9a451b2 100644 --- a/src/game/skills/impl/ama/skill-023-penetrate.ts +++ b/src/game/skills/impl/ama/skill-023-penetrate.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculatePenetrateStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-028-dopplezon.ts b/src/game/skills/impl/ama/skill-028-dopplezon.ts index 1c8f65b..6ac7d87 100644 --- a/src/game/skills/impl/ama/skill-028-dopplezon.ts +++ b/src/game/skills/impl/ama/skill-028-dopplezon.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateDecoyStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-029-evade.ts b/src/game/skills/impl/ama/skill-029-evade.ts index e53e361..81fa3a2 100644 --- a/src/game/skills/impl/ama/skill-029-evade.ts +++ b/src/game/skills/impl/ama/skill-029-evade.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateEvadeStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-032-valkyrie.ts b/src/game/skills/impl/ama/skill-032-valkyrie.ts index c4c259b..31cd17c 100644 --- a/src/game/skills/impl/ama/skill-032-valkyrie.ts +++ b/src/game/skills/impl/ama/skill-032-valkyrie.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateValkyrieStats } from '../../amazon-passive.ts' export default skillModule diff --git a/src/game/skills/impl/ama/skill-033-pierce.ts b/src/game/skills/impl/ama/skill-033-pierce.ts index 5b61c06..7365038 100644 --- a/src/game/skills/impl/ama/skill-033-pierce.ts +++ b/src/game/skills/impl/ama/skill-033-pierce.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculatePierceStats } from '../../amazon-passive.ts' export default skillModule diff --git a/tests/skills/ama/adv-ama-passive-magic-stress.test.ts b/tests/skills/ama/adv-ama-passive-magic-stress.test.ts new file mode 100644 index 0000000..858df5e --- /dev/null +++ b/tests/skills/ama/adv-ama-passive-magic-stress.test.ts @@ -0,0 +1,559 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Amazon Passive & Magic Stress & Adversarial Suite + * + * Comprehensive adversarial and E2E kinematics integration verification: + * 1. Defensive Evasion Triad: + * - Dodge: melee avoidance while standing still / attacking; triggers GH animation lock. + * - Avoid: missile avoidance while standing still / attacking; triggers GH animation lock. + * - Evade: avoidance while moving against both melee and missile; zero animation lock (uninterrupted). + * 2. Inner Sight: + * - Flat defense reduction applied directly before percentage modifiers. + * - 200px radius, 700 + 150*(slvl-1) duration, 5 mana. + * - Monster defense reduced, to-hit chance dramatically increased. + * 3. Slow Missiles: + * - 200px radius, 300 + 60*(slvl-1) duration, 5 mana. + * - Hostile missile projectile velocity reduced to 33% (67% speed damper) in 2:1 isometric space. + * 4. Critical Strike & Deadly Strike: + * - Sequential evaluation: CS roll first, DS roll second if CS fails; never quadrupling (max 2x physical). + * - Does not double elemental/magic damage. + * 5. Penetrate: + * - Linear +35% + 10%*(slvl-1) Attack Rating bonus correctly accrued to statList. + * 6. Decoy (Dopplezon): + * - Stationary posture (NU mode), cannot move or attack. + * - Draws threat, scales HP from Amazon life (50% + 10%*slvl), up to 85% elemental resistance. + * - Inherits Avoid and Evade passives from Amazon. + * 7. Valkyrie: + * - 150-frame (6.0s) casting delay. + * - Base HP 440 * (1 + 0.2*(slvl-1)), +20% life per Decoy hard point synergy. + * - Equipment progression tiers across slvl 1, 7, 11, 17, 27+. + * - Inherits Critical Strike, Dodge, Avoid, Evade, Penetrate at Amazon's slvl. + * 8. Pierce & Guided Arrow Invariant: + * - 1..4 pierces (up to 5 targets hit). + * - Guided Arrow (skillId === 22) strict 0% pierce override invariant. + * 9. System Invariant: + * - BATCH1_SKILLS length strictly equals 38; zero mocks (vi.mock strictly forbidden). + * + * 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 { + calculateInnerSightStats, + calculateCriticalStrikeStats, + calculateDodgeStats, + calculateSlowMissilesStats, + calculateAvoidStats, + calculatePenetrateStats, + calculateDecoyStats, + calculateEvadeStats, + calculateValkyrieStats, + calculatePierceStats, +} from '../../../src/game/skills/amazon-passive.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' +import { MissileEngine, ISO_GROUND_ASPECT_RATIO } from '../../../src/game/engine/missile-engine.ts' +import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts' +import { SummonManager } from '../../../src/game/engine/summon-manager.ts' +import { WorldArena } from '../../../src/game/engine/world-arena.ts' +import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { + executeSUnitDmg, + evaluateAvoidanceAndBlock, + computeToHitChance, + type CombatUnitContext, + type SUnitDmgPacket, +} from '../../../src/game/engine/combat-pipeline.ts' +import { evaluateCriticalAndDeadlyStrike, calculateCriticalChance } from '../../../src/game/formulas.ts' + +function createTestCombatUnit( + id: string, + pos: { x: number; y: number }, + registry: any, + opts?: { + hp?: number + isMoving?: boolean + def?: number + ar?: number + level?: number + isBoss?: boolean + } +): CombatUnitContext { + const statList = new UnitStatList(registry, { + level: opts?.level ?? 80, + hitpoints: (opts?.hp ?? 10000) * FIXED_ONE, + maxhp: (opts?.hp ?? 10000) * FIXED_ONE, + armorclass: opts?.def ?? 400, + tohit: opts?.ar ?? 1000, + fireresist: 0, + coldresist: 0, + lightresist: 0, + poisonresist: 0, + damageresist: 0, + magicresist: 0, + x: pos.x, + y: pos.y, + }) + const stateBus = new StateBus(statList, registry) + return { + id, + name: id, + statList, + stateBus, + isMoving: opts?.isMoving ?? false, + isBoss: opts?.isBoss ?? false, + x: pos.x, + y: pos.y, + weaponMinPhys: 50, + weaponMaxPhys: 100, + } +} + +describe('Amazon Passive & Magic Skills Adversarial Stress Suite (1.13c Ground Truth)', () => { + it('enforces System Invariants: BATCH1_SKILLS length strictly equals 38 and zero mocks utilized', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + expect(BATCH1_SKILLS[6]).toBeDefined() // Magic Arrow + expect(BATCH1_SKILLS[7]).toBeDefined() // Fire Arrow + expect(BATCH1_SKILLS[10]).toBeDefined() // Jab + expect(BATCH1_SKILLS[11]).toBeDefined() // Cold Arrow + expect(BATCH1_SKILLS[14]).toBeDefined() // Power Strike + }) + + describe('Defensive Evasion Triad: Dodge, Avoid, Evade & Animation Lock Invariants', () => { + it('verifies Dodge: triggers only against melee while stationary/attacking and applies GH animation lock', async () => { + const registry = await getSharedDataRegistry() + + // Amazon standing still with slvl 20 Dodge (56% chance) + const amazonStationary = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false }) + const dodgeStats20 = calculateDodgeStats(20) + amazonStationary.statList.setBaseSkillLevel(13, 20) + amazonStationary.statList.addStat('passive_dodge', dodgeStats20.chancePct) + + // 1. Melee attack while stationary: roll 30 < 56% -> avoided via dodge + const resMeleeStill = evaluateAvoidanceAndBlock({ + defender: amazonStationary, + attackKind: 'melee', + roll100: 30, + }) + expect(resMeleeStill.avoided).toBe(true) + expect(resMeleeStill.reason).toBe('dodge') + expect(dodgeStats20.triggersAnimationLock).toBe(true) + expect(dodgeStats20.animationCode).toBe('GH') + + // 2. Melee attack while stationary: roll 70 >= 56% -> not avoided + const resMeleeStillFail = evaluateAvoidanceAndBlock({ + defender: amazonStationary, + attackKind: 'melee', + roll100: 70, + }) + expect(resMeleeStillFail.avoided).toBe(false) + expect(resMeleeStillFail.reason).toBe('none') + + // 3. Melee attack while moving: Dodge must NOT trigger when moving + const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true }) + amazonMoving.statList.addStat('passive_dodge', dodgeStats20.chancePct) + const resMeleeMoving = evaluateAvoidanceAndBlock({ + defender: amazonMoving, + attackKind: 'melee', + roll100: 30, + }) + // Dodge does not activate while moving! + expect(resMeleeMoving.reason).not.toBe('dodge') + }) + + it('verifies Avoid: triggers only against missiles while stationary/attacking and applies GH animation lock', async () => { + const registry = await getSharedDataRegistry() + + // Amazon standing still with slvl 20 Avoid (65% chance) + const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false }) + const avoidStats20 = calculateAvoidStats(20) + amazonStill.statList.setBaseSkillLevel(18, 20) + amazonStill.statList.addStat('passive_avoid', avoidStats20.chancePct) + + // 1. Ranged missile while stationary: roll 40 < 65% -> avoided via avoid + const resMissileStill = evaluateAvoidanceAndBlock({ + defender: amazonStill, + attackKind: 'missile', + roll100: 40, + }) + expect(resMissileStill.avoided).toBe(true) + expect(resMissileStill.reason).toBe('avoid') + expect(avoidStats20.triggersAnimationLock).toBe(true) + expect(avoidStats20.animationCode).toBe('GH') + + // 2. Melee attack: Avoid must NOT trigger against melee attacks + const resMelee = evaluateAvoidanceAndBlock({ + defender: amazonStill, + attackKind: 'melee', + roll100: 40, + }) + expect(resMelee.reason).not.toBe('avoid') + + // 3. Missile attack while moving: Avoid must NOT trigger when moving + const amazonMoving = createTestCombatUnit('amazon-run', { x: 0, y: 0 }, registry, { isMoving: true }) + amazonMoving.statList.addStat('passive_avoid', avoidStats20.chancePct) + const resMissileMoving = evaluateAvoidanceAndBlock({ + defender: amazonMoving, + attackKind: 'missile', + roll100: 40, + }) + expect(resMissileMoving.reason).not.toBe('avoid') + }) + + it('verifies Evade: triggers while moving against BOTH melee and missiles with ZERO animation lock', async () => { + const registry = await getSharedDataRegistry() + + // Amazon moving with slvl 20 Evade (56% chance) + const amazonMoving = createTestCombatUnit('amazon-moving', { x: 0, y: 0 }, registry, { isMoving: true }) + const evadeStats20 = calculateEvadeStats(20) + amazonMoving.statList.setBaseSkillLevel(29, 20) + amazonMoving.statList.addStat('passive_evade', evadeStats20.chancePct) + + // 1. Evade against melee while moving: roll 30 < 56% -> avoided via evade + const resMelee = evaluateAvoidanceAndBlock({ + defender: amazonMoving, + attackKind: 'melee', + roll100: 30, + }) + expect(resMelee.avoided).toBe(true) + expect(resMelee.reason).toBe('evade') + // Zero animation lock: uninterrupted motion + expect(evadeStats20.triggersAnimationLock).toBe(false) + expect(evadeStats20.uninterrupted).toBe(true) + + // 2. Evade against missile while moving: roll 30 < 56% -> avoided via evade + const resMissile = evaluateAvoidanceAndBlock({ + defender: amazonMoving, + attackKind: 'missile', + roll100: 30, + }) + expect(resMissile.avoided).toBe(true) + expect(resMissile.reason).toBe('evade') + + // 3. Evade while stationary: must NOT trigger when stationary + const amazonStill = createTestCombatUnit('amazon-still', { x: 0, y: 0 }, registry, { isMoving: false }) + amazonStill.statList.addStat('passive_evade', evadeStats20.chancePct) + const resStill = evaluateAvoidanceAndBlock({ + defender: amazonStill, + attackKind: 'melee', + roll100: 30, + }) + expect(resStill.reason).not.toBe('evade') + }) + }) + + describe('Inner Sight: Flat Defense Reduction & Illumination Radius', () => { + it('applies flat defense reduction directly before percentage defense modifiers', async () => { + const isStats1 = calculateInnerSightStats(1) + expect(isStats1.flatDefenseReduction).toBe(-40) + expect(isStats1.durationFrames).toBe(700) // 28.0s + expect(isStats1.radiusPx).toBe(200) + expect(isStats1.manaCost).toBe(5) + + const isStats20 = calculateInnerSightStats(20) + expect(isStats20.flatDefenseReduction).toBe(-515) // -40 - 25*19 = -515 + expect(isStats20.durationFrames).toBe(3550) // 700 + 150*19 = 3550 frames (142.0s) + + // Verification of defense reduction impact on hit chance: + // Base Monster: 600 Defense, Attacker: 800 AR, both level 80 + const baseHitChance = computeToHitChance({ + attackerAr: 800, + defenderDef: 600, + attackerLvl: 80, + defenderLvl: 80, + }) + // Raw: (2 * 800 * 100 * 80) / ((800 + 600) * 160) = 12800000 / 224000 = 57% + expect(baseHitChance).toBe(57) + + // Afflicted by slvl 20 Inner Sight: 600 - 515 = 85 defense remaining + const debuffedDef = Math.max(0, 600 + isStats20.flatDefenseReduction) + expect(debuffedDef).toBe(85) + const afflictedHitChance = computeToHitChance({ + attackerAr: 800, + defenderDef: debuffedDef, + attackerLvl: 80, + defenderLvl: 80, + }) + // Raw: (2 * 800 * 100 * 80) / ((800 + 85) * 160) = 12800000 / 141600 = 90% + expect(afflictedHitChance).toBe(90) + expect(afflictedHitChance).toBeGreaterThan(baseHitChance) + }) + }) + + describe('Slow Missiles: Projectile Velocity Kinematics Dampening', () => { + it('scales hostile projectile velocity by 33% (67% speed reduction) in 2:1 isometric space', async () => { + const registry = await getSharedDataRegistry() + const missileEngine = new MissileEngine(registry) + + const enemy = createTestCombatUnit('monster-caster', { x: 0, y: 0 }, registry) + const dmgPacket: SUnitDmgPacket = { + skillId: 0, + attackKind: 'missile', + flatPhysMin256: 50 * FIXED_ONE, + flatPhysMax256: 50 * FIXED_ONE, + } + + // 1. Baseline normal projectile + const normalMsl = missileEngine.spawnMissile({ + missileNameOrId: 'arrow', + sourceSkillId: 0, + slvl: 1, + owner: enemy, + startX: 0, + startY: 0, + targetX: 300, + targetY: 0, + dmgPacket, + })! + expect(normalMsl).not.toBeNull() + const baseVx = normalMsl.vx + const baseVy = normalMsl.vy + + // 2. Slowed projectile under Slow Missiles aura + const slowedMsl = missileEngine.spawnMissile({ + missileNameOrId: 'arrow', + sourceSkillId: 0, + slvl: 1, + owner: enemy, + startX: 0, + startY: 0, + targetX: 300, + targetY: 0, + dmgPacket, + isSlowed: true, + slowMultiplier: 0.33, + })! + expect(slowedMsl).not.toBeNull() + + // Step both missiles for 5 ticks + for (let t = 0; t < 5; t++) { + missileEngine.tick(t, [], new Map()) + } + + // Under Slow Missiles, distance traveled must be ~33% of baseline + expect(slowedMsl.x).toBeCloseTo(normalMsl.x * 0.33, 0) + }) + }) + + describe('Critical Strike & Deadly Strike: Sequential Doubling Invariant', () => { + it('rolls Critical Strike before Deadly Strike and strictly never quadruples physical damage', () => { + const csStats20 = calculateCriticalStrikeStats(20) + expect(csStats20.chancePct).toBe(68) + expect(csStats20.damageMultiplier).toBe(2.0) + + // Test sequential doubling with mock PRNG rolls: + // Case 1: CS succeeds (roll 10 < 68%) -> critical_strike, DS skipped + const res1 = evaluateCriticalAndDeadlyStrike(68, 50, () => 0.10) + expect(res1.isCritical).toBe(true) + expect(res1.triggeredBy).toBe('critical_strike') + + // Case 2: CS fails (roll 80 >= 68%), DS succeeds (roll 20 < 50%) -> deadly_strike + let rollCount = 0 + const rolls = [0.80, 0.20] + const res2 = evaluateCriticalAndDeadlyStrike(68, 50, () => rolls[rollCount++] ?? 0.5) + expect(res2.isCritical).toBe(true) + expect(res2.triggeredBy).toBe('deadly_strike') + + // Case 3: Combined chance formula check: P = CS + DS * (1 - CS) + // CS=68%, DS=50% -> 0.68 + 0.50 * 0.32 = 0.68 + 0.16 = 0.84 (84%) + const combined = calculateCriticalChance(68, 50) + expect(combined).toBe(84) + + // Invariant: In combat pipeline, physical damage is doubled ONCE, never multiplied by 4 + const baseDamage = 100 + const finalCritDamage = baseDamage * csStats20.damageMultiplier + expect(finalCritDamage).toBe(200) + expect(finalCritDamage).not.toBe(400) + }) + }) + + describe('Penetrate: Attack Rating Scaling & Stat Accrual', () => { + it('linearly accrues +35% + 10%*(slvl-1) Attack Rating to UnitStatList', () => { + const p1 = calculatePenetrateStats(1) + expect(p1.attackRatingBonusPct).toBe(35) + expect(p1.manaCost).toBe(0) + + const p10 = calculatePenetrateStats(10) + expect(p10.attackRatingBonusPct).toBe(125) + + const p20 = calculatePenetrateStats(20) + expect(p20.attackRatingBonusPct).toBe(225) + + // StatList verification: base AR 1000 with +225% Penetrate -> 1000 * 3.25 = 3250 + const statList = new UnitStatList(undefined, { tohit: 1000 }) + statList.addStat('item_tohit_percent', p20.attackRatingBonusPct) + expect(statList.getAccruedStat('tohit')).toBe(3250) + }) + }) + + describe('Decoy (Dopplezon): Stationary Posture, Threat Drawing & Passive Inheritance', () => { + it('creates stationary companion with life scaling, resistance cap, and inherited passives', async () => { + const registry = await getSharedDataRegistry() + const summonManager = new SummonManager(registry) + + const amazon = createTestCombatUnit('amazon-caster', { x: 100, y: 100 }, registry, { + hp: 1000, + def: 500, + }) + // Allocate Avoid (slvl 10) and Evade (slvl 8) hard points on Amazon + amazon.statList.setBaseSkillLevel(18, 10) + amazon.statList.setBaseSkillLevel(29, 8) + + const outcome = summonManager.createPet({ + owner: amazon, + skillId: 28, + slvl: 10, + x: 150, + y: 120, + }) + + expect(outcome.created).toBe(true) + const decoy = outcome.pet! + expect(decoy).toBeDefined() + expect(decoy.kind).toBe('dopplezon') + expect(decoy.isStationary).toBe(true) + + // Life scaling: 1000 * (0.5 + 0.1 * 10) = 1500 HP + expect(decoy.hp).toBe(1500) + expect(decoy.maxHp).toBe(1500) + + // Defense matches Amazon's defense + expect(decoy.statList.getAccruedStat('armorclass')).toBe(500) + + // Resistances: min(85, 4 * 10) = 40% + expect(decoy.statList.getAccruedStat('fireresist')).toBe(40) + expect(decoy.statList.getAccruedStat('lightresist')).toBe(40) + + // Inherited Avoid & Evade passives + expect(decoy.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct) + expect(decoy.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(8).chancePct) + + // Ticking Decoy in combat: stationary Decoy does NOT attack + const enemy = createTestCombatUnit('enemy-target', { x: 150, y: 120 }, registry) + const missileEngine = new MissileEngine(registry) + const auraScanner = new AuraScanner(registry) + const tickRes = summonManager.tickPets({ + currentTick: 10, + owner: amazon, + enemies: [enemy], + enemyPositions: new Map([['enemy-target', { x: 150, y: 120 }]]), + missileEngine, + auraScanner, + }) + expect(tickRes.petAttacks).toBe(0) + }) + }) + + describe('Valkyrie: 150-Frame Cooldown, Decoy Synergies & Equipment Tier Progression', () => { + it('enforces 150-frame casting delay, +20% life per Decoy point, and 5 equipment generation tiers', async () => { + const registry = await getSharedDataRegistry() + const summonManager = new SummonManager(registry) + + // 1. Casting Delay Invariant: 150 frames (6.0s) + expect(getSkillCooldownTicks(32)).toBe(150) + expect(calculateValkyrieStats(1).cooldownFrames).toBe(150) + expect(calculateValkyrieStats(1).cooldownSeconds).toBe(6.0) + + // 2. Decoy Synergy: +20% HP per hard point in Decoy + const amazon = createTestCombatUnit('amazon-valk-caster', { x: 0, y: 0 }, registry) + amazon.statList.setBaseSkillLevel(28, 5) // 5 Decoy hard points = +100% life + amazon.statList.setBaseSkillLevel(9, 10) // CS slvl 10 + amazon.statList.setBaseSkillLevel(13, 10) // Dodge slvl 10 + amazon.statList.setBaseSkillLevel(18, 10) // Avoid slvl 10 + amazon.statList.setBaseSkillLevel(29, 10) // Evade slvl 10 + amazon.statList.setBaseSkillLevel(23, 10) // Penetrate slvl 10 + + const outcome = summonManager.createPet({ + owner: amazon, + skillId: 32, + slvl: 10, + }) + expect(outcome.created).toBe(true) + const valk = outcome.pet! + expect(valk.kind).toBe('valkyrie') + + // Base HP at slvl 10: 440 * (1 + 0.2*9) = 440 * 2.8 = 1232 + // Decoy Synergy (+100%): 1232 * 2.0 = 2464 HP + expect(valk.hp).toBe(2464) + + // Resistances: min(75, 2 * 10) = 20% + expect(valk.statList.getAccruedStat('fireresist')).toBe(20) + + // Inherited Amazon passives + expect(valk.statList.getAccruedStat('passive_critical_strike')).toBe(calculateCriticalStrikeStats(10).chancePct) + expect(valk.statList.getAccruedStat('passive_dodge')).toBe(calculateDodgeStats(10).chancePct) + expect(valk.statList.getAccruedStat('passive_avoid')).toBe(calculateAvoidStats(10).chancePct) + expect(valk.statList.getAccruedStat('passive_evade')).toBe(calculateEvadeStats(10).chancePct) + expect(valk.statList.getAccruedStat('item_tohit_percent')).toBe(calculatePenetrateStats(10).attackRatingBonusPct) + + // 3. Equipment generation tiers + expect(calculateValkyrieStats(1).equipmentTier).toBe('tier1_basic') + expect(calculateValkyrieStats(7).equipmentTier).toBe('tier2_boots') + expect(calculateValkyrieStats(11).equipmentTier).toBe('tier3_gloves') + expect(calculateValkyrieStats(17).equipmentTier).toBe('tier4_war_pike') + expect(calculateValkyrieStats(27).equipmentTier).toBe('tier5_rare_tiara') + }) + }) + + describe('Pierce: Penetration Limit & Guided Arrow Zero-Pierce Invariant', () => { + it('caps pierce at 4 iterations (5 targets hit max) and strictly clamps Guided Arrow to 0%', async () => { + const registry = await getSharedDataRegistry() + const missileEngine = new MissileEngine(registry) + + // 1. Pierce stats check + const pierceStats20 = calculatePierceStats(20) + expect(pierceStats20.chancePct).toBe(86) + expect(pierceStats20.maxPierceCount).toBe(4) + expect(pierceStats20.maxHitsPerMissile).toBe(5) + + // 2. Guided Arrow strict 0% pierce override + const gaStats = calculatePierceStats(20, 22) + expect(gaStats.isGuidedArrow).toBe(true) + expect(gaStats.chancePct).toBe(0) + + // 3. Runtime verification in MissileEngine: Guided Arrow NEVER pierces + const owner = createTestCombatUnit('amazon-ga', { x: 0, y: 0 }, registry) + const dmgPacket: SUnitDmgPacket = { + skillId: 22, + attackKind: 'missile', + srcDam: 128, + } + const gaMissile = missileEngine.spawnMissile({ + missileNameOrId: 'guidedarrow', + sourceSkillId: 22, + slvl: 20, + owner, + startX: 0, + startY: 0, + targetX: 100, + targetY: 0, + dmgPacket, + pierceChancePct: 100, // Adversarial input: attempt to force 100% pierce + }) + expect(gaMissile!.canPierce).toBe(false) + expect(gaMissile!.pierceChancePct).toBe(0) + + // 4. Normal arrow with Pierce skill penetrates enemies + const normalArrow = missileEngine.spawnMissile({ + missileNameOrId: 'arrow', + sourceSkillId: 0, + slvl: 1, + owner, + startX: 0, + startY: 0, + targetX: 200, + targetY: 0, + dmgPacket, + pierceChancePct: 86, + }) + expect(normalArrow!.canPierce).toBe(true) + expect(normalArrow!.pierceChancePct).toBe(86) + }) + }) +}) diff --git a/tests/skills/ama/skill-008-inner-sight.test.ts b/tests/skills/ama/skill-008-inner-sight.test.ts index 8f99f4a..930e3c4 100644 --- a/tests/skills/ama/skill-008-inner-sight.test.ts +++ b/tests/skills/ama/skill-008-inner-sight.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-008-inner-sight.ts' +import { calculateInnerSightStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #008] Inner Sight (AMA) — 1.13c Parity (Issue #162)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,28 @@ describe('[Skill #008] Inner Sight (AMA) — 1.13c Parity (Issue #162)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateInnerSightStats pure calculations and boundary invariants', () => { + // slvl 1: -40 flat defense, 700 frames (28.0s), 200px radius, 5 mana (1280) + const stats1 = calculateInnerSightStats(1) + expect(stats1.flatDefenseReduction).toBe(-40) + expect(stats1.durationFrames).toBe(700) + expect(stats1.durationSeconds).toBe(28) + expect(stats1.radiusPx).toBe(200) + expect(stats1.radiusSubtiles).toBe(20) + expect(stats1.manaCost).toBe(5) + expect(stats1.manaCost256).toBe(1280) + + // slvl 10: -40 - 25*9 = -265 defense, 700 + 150*9 = 2050 frames (82.0s) + const stats10 = calculateInnerSightStats(10) + expect(stats10.flatDefenseReduction).toBe(-265) + expect(stats10.durationFrames).toBe(2050) + expect(stats10.durationSeconds).toBe(82) + + // slvl 20: -40 - 25*19 = -515 defense, 700 + 150*19 = 3550 frames (142.0s) + const stats20 = calculateInnerSightStats(20) + expect(stats20.flatDefenseReduction).toBe(-515) + expect(stats20.durationFrames).toBe(3550) + expect(stats20.durationSeconds).toBe(142) + }) }) diff --git a/tests/skills/ama/skill-009-critical-strike.test.ts b/tests/skills/ama/skill-009-critical-strike.test.ts index 4b5a18f..2ed9960 100644 --- a/tests/skills/ama/skill-009-critical-strike.test.ts +++ b/tests/skills/ama/skill-009-critical-strike.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-009-critical-strike.ts' +import { calculateCriticalStrikeStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #009] Critical Strike (AMA) — 1.13c Parity (Issue #163)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,21 @@ describe('[Skill #009] Critical Strike (AMA) — 1.13c Parity (Issue #163)', () expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateCriticalStrikeStats pure calculations and boundary invariants', () => { + // slvl 1: dm(5, 80, 1) = floor(110*1*75 / (100*7)) + 5 = floor(8250/700) + 5 = 11 + 5 = 16% + const stats1 = calculateCriticalStrikeStats(1) + expect(stats1.chancePct).toBe(16) + expect(stats1.damageMultiplier).toBe(2.0) + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: dm(5, 80, 10) = floor(110*10*75 / (100*16)) + 5 = floor(82500/1600) + 5 = 51 + 5 = 56% + const stats10 = calculateCriticalStrikeStats(10) + expect(stats10.chancePct).toBe(56) + + // slvl 20: dm(5, 80, 20) = floor(110*20*75 / (100*26)) + 5 = floor(165000/2600) + 5 = 63 + 5 = 68% + const stats20 = calculateCriticalStrikeStats(20) + expect(stats20.chancePct).toBe(68) + }) }) diff --git a/tests/skills/ama/skill-013-dodge.test.ts b/tests/skills/ama/skill-013-dodge.test.ts index 5de75e8..42c766d 100644 --- a/tests/skills/ama/skill-013-dodge.test.ts +++ b/tests/skills/ama/skill-013-dodge.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-013-dodge.ts' +import { calculateDodgeStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #013] Dodge (AMA) — 1.13c Parity (Issue #167)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,24 @@ describe('[Skill #013] Dodge (AMA) — 1.13c Parity (Issue #167)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateDodgeStats pure calculations and boundary invariants', () => { + // slvl 1: dm(10, 65, 1) = floor(110*1*55 / (100*7)) + 10 = floor(6050/700) + 10 = 8 + 10 = 18% + const stats1 = calculateDodgeStats(1) + expect(stats1.chancePct).toBe(18) + expect(stats1.triggersAnimationLock).toBe(true) + expect(stats1.animationCode).toBe('GH') + expect(stats1.appliesTo).toBe('melee') + expect(stats1.condition).toBe('stationary_or_attacking') + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: dm(10, 65, 10) = floor(110*10*55 / (100*16)) + 10 = floor(60500/1600) + 10 = 37 + 10 = 47% + const stats10 = calculateDodgeStats(10) + expect(stats10.chancePct).toBe(47) + + // slvl 20: dm(10, 65, 20) = floor(110*20*55 / (100*26)) + 10 = floor(121000/2600) + 10 = 46 + 10 = 56% + const stats20 = calculateDodgeStats(20) + expect(stats20.chancePct).toBe(56) + }) }) diff --git a/tests/skills/ama/skill-017-slow-missiles.test.ts b/tests/skills/ama/skill-017-slow-missiles.test.ts index e92424b..cec4a01 100644 --- a/tests/skills/ama/skill-017-slow-missiles.test.ts +++ b/tests/skills/ama/skill-017-slow-missiles.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-017-slow-missiles.ts' +import { calculateSlowMissilesStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #017] Slow Missiles (AMA) — 1.13c Parity (Issue #171)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,27 @@ describe('[Skill #017] Slow Missiles (AMA) — 1.13c Parity (Issue #171)', () => expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateSlowMissilesStats pure calculations and boundary invariants', () => { + // slvl 1: 300 frames (12.0s), 200px radius, 0.33 vel mult (67% reduction), 5 mana (1280) + const stats1 = calculateSlowMissilesStats(1) + expect(stats1.durationFrames).toBe(300) + expect(stats1.durationSeconds).toBe(12) + expect(stats1.radiusPx).toBe(200) + expect(stats1.radiusSubtiles).toBe(20) + expect(stats1.velocityMultiplier).toBe(0.33) + expect(stats1.velocityReductionPct).toBe(67) + expect(stats1.manaCost).toBe(5) + expect(stats1.manaCost256).toBe(1280) + + // slvl 10: 300 + 60*9 = 840 frames (33.6s) + const stats10 = calculateSlowMissilesStats(10) + expect(stats10.durationFrames).toBe(840) + expect(stats10.durationSeconds).toBe(33.6) + + // slvl 20: 300 + 60*19 = 1440 frames (57.6s) + const stats20 = calculateSlowMissilesStats(20) + expect(stats20.durationFrames).toBe(1440) + expect(stats20.durationSeconds).toBe(57.6) + }) }) diff --git a/tests/skills/ama/skill-018-avoid.test.ts b/tests/skills/ama/skill-018-avoid.test.ts index 9fa8773..b7cb7bd 100644 --- a/tests/skills/ama/skill-018-avoid.test.ts +++ b/tests/skills/ama/skill-018-avoid.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-018-avoid.ts' +import { calculateAvoidStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #018] Avoid (AMA) — 1.13c Parity (Issue #172)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,24 @@ describe('[Skill #018] Avoid (AMA) — 1.13c Parity (Issue #172)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateAvoidStats pure calculations and boundary invariants', () => { + // slvl 1: dm(15, 75, 1) = floor(110*1*60 / (100*7)) + 15 = floor(6600/700) + 15 = 9 + 15 = 24% + const stats1 = calculateAvoidStats(1) + expect(stats1.chancePct).toBe(24) + expect(stats1.triggersAnimationLock).toBe(true) + expect(stats1.animationCode).toBe('GH') + expect(stats1.appliesTo).toBe('missile') + expect(stats1.condition).toBe('stationary_or_attacking') + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: dm(15, 75, 10) = floor(110*10*60 / (100*16)) + 15 = floor(66000/1600) + 15 = 41 + 15 = 56% + const stats10 = calculateAvoidStats(10) + expect(stats10.chancePct).toBe(56) + + // slvl 20: dm(15, 75, 20) = floor(110*20*60 / (100*26)) + 15 = floor(132000/2600) + 15 = 50 + 15 = 65% + const stats20 = calculateAvoidStats(20) + expect(stats20.chancePct).toBe(65) + }) }) diff --git a/tests/skills/ama/skill-023-penetrate.test.ts b/tests/skills/ama/skill-023-penetrate.test.ts index efcd386..f985bff 100644 --- a/tests/skills/ama/skill-023-penetrate.test.ts +++ b/tests/skills/ama/skill-023-penetrate.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-023-penetrate.ts' +import { calculatePenetrateStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #023] Penetrate (AMA) — 1.13c Parity (Issue #177)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,20 @@ describe('[Skill #023] Penetrate (AMA) — 1.13c Parity (Issue #177)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculatePenetrateStats pure calculations and boundary invariants', () => { + // slvl 1: 35 + 10*0 = +35% Attack Rating + const stats1 = calculatePenetrateStats(1) + expect(stats1.attackRatingBonusPct).toBe(35) + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: 35 + 10*9 = +125% Attack Rating + const stats10 = calculatePenetrateStats(10) + expect(stats10.attackRatingBonusPct).toBe(125) + + // slvl 20: 35 + 10*19 = +225% Attack Rating + const stats20 = calculatePenetrateStats(20) + expect(stats20.attackRatingBonusPct).toBe(225) + }) }) diff --git a/tests/skills/ama/skill-028-dopplezon.test.ts b/tests/skills/ama/skill-028-dopplezon.test.ts index db19fca..a3cf652 100644 --- a/tests/skills/ama/skill-028-dopplezon.test.ts +++ b/tests/skills/ama/skill-028-dopplezon.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-028-dopplezon.ts' +import { calculateDecoyStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #028] Dopplezon (AMA) — 1.13c Parity (Issue #182)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,39 @@ describe('[Skill #028] Dopplezon (AMA) — 1.13c Parity (Issue #182)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateDecoyStats pure calculations and boundary invariants', () => { + // slvl 1: 250 frames (10.0s), 60% of Amazon life, 4% res, defense = amazonDef, stationary, draws threat, 19 mana (4864) + const stats1 = calculateDecoyStats(1, 500, 150, { avoidLvl: 5, evadeLvl: 3 }) + expect(stats1.durationFrames).toBe(250) + expect(stats1.durationSeconds).toBe(10) + expect(stats1.hp).toBe(300) // 500 * 0.6 + expect(stats1.hpPctOfAmazon).toBe(60) + expect(stats1.allResistancesPct).toBe(4) + expect(stats1.defense).toBe(150) + expect(stats1.isStationary).toBe(true) + expect(stats1.drawsThreat).toBe(true) + expect(stats1.inheritedAvoidLvl).toBe(5) + expect(stats1.inheritedEvadeLvl).toBe(3) + expect(stats1.manaCost).toBe(19) + expect(stats1.manaCost256).toBe(4864) + + // slvl 10: 250 + 125*9 = 1375 frames (55.0s), 150% Amazon life, 40% res, 10 mana (2560) or 12 int mana + const stats10 = calculateDecoyStats(10, 500, 200) + expect(stats10.durationFrames).toBe(1375) + expect(stats10.durationSeconds).toBe(55) + expect(stats10.hp).toBe(750) // 500 * 1.5 + expect(stats10.allResistancesPct).toBe(40) + + // slvl 20: 250 + 125*19 = 2625 frames (105.0s), 250% Amazon life, capped at 80% res (min(85, 4*20)=80), min mana 1 + const stats20 = calculateDecoyStats(20, 500, 300) + expect(stats20.durationFrames).toBe(2625) + expect(stats20.durationSeconds).toBe(105) + expect(stats20.hp).toBe(1250) // 500 * 2.5 + expect(stats20.allResistancesPct).toBe(80) + + // slvl 25: 4*25 = 100% -> capped at 85% + const stats25 = calculateDecoyStats(25, 500, 300) + expect(stats25.allResistancesPct).toBe(85) + }) }) diff --git a/tests/skills/ama/skill-029-evade.test.ts b/tests/skills/ama/skill-029-evade.test.ts index 1ebd33b..2764f84 100644 --- a/tests/skills/ama/skill-029-evade.test.ts +++ b/tests/skills/ama/skill-029-evade.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-029-evade.ts' +import { calculateEvadeStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #029] Evade (AMA) — 1.13c Parity (Issue #183)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,24 @@ describe('[Skill #029] Evade (AMA) — 1.13c Parity (Issue #183)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateEvadeStats pure calculations and boundary invariants', () => { + // slvl 1: dm(10, 65, 1) = 18%, triggersAnimationLock = false, condition = moving, uninterrupted = true + const stats1 = calculateEvadeStats(1) + expect(stats1.chancePct).toBe(18) + expect(stats1.triggersAnimationLock).toBe(false) + expect(stats1.appliesTo).toBe('melee_or_missile') + expect(stats1.condition).toBe('moving') + expect(stats1.uninterrupted).toBe(true) + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: dm(10, 65, 10) = 47% + const stats10 = calculateEvadeStats(10) + expect(stats10.chancePct).toBe(47) + + // slvl 20: dm(10, 65, 20) = 56% + const stats20 = calculateEvadeStats(20) + expect(stats20.chancePct).toBe(56) + }) }) diff --git a/tests/skills/ama/skill-032-valkyrie.test.ts b/tests/skills/ama/skill-032-valkyrie.test.ts index 2620806..99eac7a 100644 --- a/tests/skills/ama/skill-032-valkyrie.test.ts +++ b/tests/skills/ama/skill-032-valkyrie.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-032-valkyrie.ts' +import { calculateValkyrieStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #032] Valkyrie (AMA) — 1.13c Parity (Issue #186)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,53 @@ describe('[Skill #032] Valkyrie (AMA) — 1.13c Parity (Issue #186)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculateValkyrieStats pure calculations, synergies, and equipment tiers', () => { + // slvl 1: base HP 440, cooldown 150 frames (6.0s), mana 25 (6400), tier 1 basic + const stats1 = calculateValkyrieStats(1, { + decoyHardPoints: 0, + csLvl: 1, + dodgeLvl: 1, + avoidLvl: 1, + evadeLvl: 1, + penetrateLvl: 1, + }) + expect(stats1.baseHp).toBe(440) + expect(stats1.finalHp).toBe(440) + expect(stats1.decoySynergyBonusPct).toBe(0) + expect(stats1.allResistancesPct).toBe(2) + expect(stats1.cooldownFrames).toBe(150) + expect(stats1.cooldownSeconds).toBe(6.0) + expect(stats1.equipmentTier).toBe('tier1_basic') + expect(stats1.inheritedCriticalStrikeLvl).toBe(1) + expect(stats1.manaCost).toBe(25) + expect(stats1.manaCost256).toBe(6400) + + // Decoy Synergy: +20% HP per hard point (e.g. 5 Decoy hard points -> +100% life -> 880 HP) + const stats1WithDecoy = calculateValkyrieStats(1, { decoyHardPoints: 5 }) + expect(stats1WithDecoy.decoySynergyBonusPct).toBe(100) + expect(stats1WithDecoy.finalHp).toBe(880) + + // slvl 7: Boots tier + const stats7 = calculateValkyrieStats(7) + expect(stats7.equipmentTier).toBe('tier2_boots') + + // slvl 11: Gloves tier + const stats11 = calculateValkyrieStats(11) + expect(stats11.equipmentTier).toBe('tier3_gloves') + + // slvl 17: Rare War Pike tier + const stats17 = calculateValkyrieStats(17) + expect(stats17.equipmentTier).toBe('tier4_war_pike') + + // slvl 27+: Tiara & Sacred Armor tier + const stats27 = calculateValkyrieStats(27) + expect(stats27.equipmentTier).toBe('tier5_rare_tiara') + + // slvl 20: 440 * (1 + 0.2*19) = 440 * 4.8 = 2112 HP, 40% res, 44 mana + const stats20 = calculateValkyrieStats(20) + expect(stats20.baseHp).toBe(2112) + expect(stats20.allResistancesPct).toBe(40) + expect(stats20.manaCost).toBe(44) + }) }) diff --git a/tests/skills/ama/skill-033-pierce.test.ts b/tests/skills/ama/skill-033-pierce.test.ts index 267b887..7528976 100644 --- a/tests/skills/ama/skill-033-pierce.test.ts +++ b/tests/skills/ama/skill-033-pierce.test.ts @@ -7,6 +7,7 @@ import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts import { UnitStatList } from '../../../src/game/engine/stat-list.ts' import { WorldArena } from '../../../src/game/engine/world-arena.ts' import { skillModule } from '../../../src/game/skills/impl/ama/skill-033-pierce.ts' +import { calculatePierceStats } from '../../../src/game/skills/amazon-passive.ts' describe('[Skill #033] Pierce (AMA) — 1.13c Parity (Issue #187)', () => { it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => { @@ -106,4 +107,32 @@ describe('[Skill #033] Pierce (AMA) — 1.13c Parity (Issue #187)', () => { expect(dbg.actionFrameTriggered).toBe(true) expect(outcome?.executed).toBe(true) }) + + it('validates 1.13c calculatePierceStats pure calculations, caps, and Guided Arrow 0% invariant', () => { + // slvl 1: dm(10, 100, 1) = floor(110*1*90 / (100*7)) + 10 = floor(9900/700) + 10 = 14 + 10 = 24% + const stats1 = calculatePierceStats(1) + expect(stats1.chancePct).toBe(24) + expect(stats1.maxPierceCount).toBe(4) + expect(stats1.maxHitsPerMissile).toBe(5) + expect(stats1.isGuidedArrow).toBe(false) + expect(stats1.manaCost).toBe(0) + expect(stats1.manaCost256).toBe(0) + + // slvl 10: dm(10, 100, 10) = floor(110*10*90 / (100*16)) + 10 = floor(99000/1600) + 10 = 61 + 10 = 71% + const stats10 = calculatePierceStats(10) + expect(stats10.chancePct).toBe(71) + + // slvl 20: dm(10, 100, 20) = floor(110*20*90 / (100*26)) + 10 = floor(198000/2600) + 10 = 76 + 10 = 86% + const stats20 = calculatePierceStats(20) + expect(stats20.chancePct).toBe(86) + + // Guided Arrow (skillId === 22) strict zero-pierce invariant: chance MUST be 0% regardless of slvl! + const gaPierceLvl1 = calculatePierceStats(1, 22) + expect(gaPierceLvl1.isGuidedArrow).toBe(true) + expect(gaPierceLvl1.chancePct).toBe(0) + + const gaPierceLvl20 = calculatePierceStats(20, 22) + expect(gaPierceLvl20.isGuidedArrow).toBe(true) + expect(gaPierceLvl20.chancePct).toBe(0) + }) })