feat(ama): implement Milestone M13 Amazon Passive and Magic skills tree (#460)
This commit is contained in:
parent
52b0bbaf2b
commit
1e5b02f0d6
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' &&
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateInnerSightStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateCriticalStrikeStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateDodgeStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateSlowMissilesStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateAvoidStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculatePenetrateStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateDecoyStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateEvadeStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateValkyrieStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculatePierceStats } from '../../amazon-passive.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue