feat(ama): implement Milestone M13 Amazon Passive and Magic skills tree (#460)

This commit is contained in:
troytt 2026-09-24 20:10:40 +00:00
parent 52b0bbaf2b
commit 1e5b02f0d6
26 changed files with 1457 additions and 10 deletions

View File

@ -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)) {

View File

@ -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)

View File

@ -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' &&

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateInnerSightStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateCriticalStrikeStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateDodgeStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateSlowMissilesStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateAvoidStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculatePenetrateStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateDecoyStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateEvadeStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculateValkyrieStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
},
}
export { calculatePierceStats } from '../../amazon-passive.ts'
export default skillModule

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})