feat(skills): implement Assassin Shadow Disciplines tree with 1.13c parity (Milestone M28)
- Implement Claw Mastery scaling (AR, ED, diminishing returns Critical Strike). - Implement ShadowBuffTracker for Burst of Speed vs Fade mutual cancellation, duration, and Venom coexistence. - Implement resolveVenomPoisonClamp for strict 10-frame (0.4s) duration clamp and bitrate summing. - Implement resolveWeaponBlock with dual-claw requirement and 0% block while running. - Implement Cloak of Shadows defense debuff/buff, duration, and recast lockout. - Implement Psychic Hammer 50/50 phys/magic damage and target-rank knockback scaling. - Implement Mind Blast physical damage, 4-yard AOE stun, and conversion chance with boss/unique immunity. - Implement ShadowMinionManager for PetType limit = 1, mutual replacement, Warrior mirroring, and Master autonomous slvl scaling. - Add adv-ass-shadow-stress.test.ts verifying all 7 adversarial test categories (21 tests passed). - All 10 Shadow Disciplines unit tests (#252, #253, #258, #263, #264, #267, #268, #273, #278, #279) and typecheck pass.
This commit is contained in:
parent
3e85a8059d
commit
8d15aac80f
|
|
@ -0,0 +1,703 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Assassin Shadow Disciplines Module
|
||||||
|
*
|
||||||
|
* Dedicated pure calculations, mutual exclusivity tracking (Burst of Speed vs Fade),
|
||||||
|
* Venom 10-frame bitrate clamp, Weapon Block dual-claw mechanics, Cloak of Shadows blind/defense,
|
||||||
|
* Mind Blast stun/conversion, and Shadow Warrior / Master minion orchestration for the 10
|
||||||
|
* Assassin Shadow Disciplines Tree Skills:
|
||||||
|
* - Skill 252: Claw Mastery (ReqLevel 1)
|
||||||
|
* - Skill 253: Psychic Hammer (ReqLevel 1)
|
||||||
|
* - Skill 258: Burst of Speed / Quickness (ReqLevel 6)
|
||||||
|
* - Skill 263: Weapon Block (ReqLevel 12)
|
||||||
|
* - Skill 264: Cloak of Shadows (ReqLevel 12)
|
||||||
|
* - Skill 267: Fade (ReqLevel 18)
|
||||||
|
* - Skill 268: Shadow Warrior (ReqLevel 18)
|
||||||
|
* - Skill 273: Mind Blast (ReqLevel 24)
|
||||||
|
* - Skill 278: Venom (ReqLevel 30)
|
||||||
|
* - Skill 279: Shadow Master (ReqLevel 30)
|
||||||
|
*
|
||||||
|
* 1.13c Ground Truth:
|
||||||
|
* - Skills.txt, Missiles.txt, MonStats.txt, SkillDesc.txt (Patch_D2.mpq).
|
||||||
|
* - Burst of Speed vs Fade:
|
||||||
|
* Mutual exclusivity: Activating Burst of Speed cancels Fade. Activating Fade cancels Burst of Speed.
|
||||||
|
* - Venom Mechanics:
|
||||||
|
* Poison duration is strictly clamped to 10 frames (0.4 seconds).
|
||||||
|
* Combines with other poison sources by summing bitrates over the 10-frame duration.
|
||||||
|
* - Weapon Block Mechanics:
|
||||||
|
* Requires two claws (h2h in main-hand and off-hand).
|
||||||
|
* Full block chance when standing, attacking, or casting.
|
||||||
|
* Drops to 0% block chance while running (in contrast to shields which drop to 1/3).
|
||||||
|
* Can block both physical and magical/elemental projectile attacks.
|
||||||
|
* - Cloak of Shadows Mechanics:
|
||||||
|
* Cannot be recast until its active duration has fully elapsed.
|
||||||
|
* Suppresses normal monster AI (blinds them, restricts vision to melee).
|
||||||
|
* Lowers monster defense by -min(15 + 3*(slvl-1), 95)% and increases Assassin defense by +(10 + 3*(slvl-1))%.
|
||||||
|
* - Mind Blast Mechanics:
|
||||||
|
* Deals physical damage and stuns all targets in 4-yard radius for 50 + 5*(slvl-1) frames.
|
||||||
|
* Normal monsters and minions have a chance to be converted for 150 + rand(0, 100) frames.
|
||||||
|
* Champions, Uniques, SuperUniques, and Act Bosses are completely immune to conversion.
|
||||||
|
* - Shadow Warrior & Shadow Master:
|
||||||
|
* Share the 'shadowwarrior' PetType with PetMax = 1 (summoning one despawns the other).
|
||||||
|
* Shadow Warrior mirrors the player's active left- and right-hand skills.
|
||||||
|
* Shadow Master has autonomous AI using all Assassin skills, with equipment quality scaling with slvl.
|
||||||
|
* - Zero runtime MPQ reading.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
compute3BandDuration,
|
||||||
|
compute5BandScaling,
|
||||||
|
computeDiminishingReturns,
|
||||||
|
computeLinearScaling,
|
||||||
|
} from '../engine/calc-ast.ts'
|
||||||
|
|
||||||
|
// --- Skill IDs ---
|
||||||
|
|
||||||
|
export const ASSASSIN_SHADOW_SKILL_IDS = {
|
||||||
|
CLAW_MASTERY: 252,
|
||||||
|
PSYCHIC_HAMMER: 253,
|
||||||
|
BURST_OF_SPEED: 258,
|
||||||
|
WEAPON_BLOCK: 263,
|
||||||
|
CLOAK_OF_SHADOWS: 264,
|
||||||
|
FADE: 267,
|
||||||
|
SHADOW_WARRIOR: 268,
|
||||||
|
MIND_BLAST: 273,
|
||||||
|
VENOM: 278,
|
||||||
|
SHADOW_MASTER: 279,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// --- Numerical Formulas (1.13c Ground Truth) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 252: Claw Mastery (Passive)
|
||||||
|
* - AR Bonus%: ln12 = 30 + 10 * (slvl - 1)
|
||||||
|
* - ED Bonus%: ln34 = 35 + 4 * (slvl - 1)
|
||||||
|
* - Critical Strike%: dm56 = computeDiminishingReturns(0, 25, slvl)
|
||||||
|
*/
|
||||||
|
export function computeClawMasteryAR(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(30, 10, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeClawMasteryED(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(35, 4, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeClawMasteryCriticalStrike(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(0, 25, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 253: Psychic Hammer
|
||||||
|
* - Physical Damage: MinDam=2, Lev=[2, 3, 4, 5, 6]; MaxDam=6, Lev=[3, 4, 5, 6, 7]
|
||||||
|
* - Magic Damage: EMin=2, Lev=[2, 3, 4, 5, 6]; EMax=6, Lev=[3, 4, 5, 6, 7]
|
||||||
|
* - Knockback Chance: Normal=100%, Unique=dm(50, 100), Boss/Player=dm(25, 99)
|
||||||
|
*/
|
||||||
|
export interface PsychicHammerDamage {
|
||||||
|
readonly minPhys: number
|
||||||
|
readonly maxPhys: number
|
||||||
|
readonly minMagic: number
|
||||||
|
readonly maxMagic: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePsychicHammerDamage(slvl: number): PsychicHammerDamage {
|
||||||
|
if (slvl <= 0) return { minPhys: 0, maxPhys: 0, minMagic: 0, maxMagic: 0 }
|
||||||
|
const minPhys = compute5BandScaling(slvl, 2, 2, 3, 4, 5, 6)
|
||||||
|
const maxPhys = compute5BandScaling(slvl, 6, 3, 4, 5, 6, 7)
|
||||||
|
const minMagic = compute5BandScaling(slvl, 2, 2, 3, 4, 5, 6)
|
||||||
|
const maxMagic = compute5BandScaling(slvl, 6, 3, 4, 5, 6, 7)
|
||||||
|
return { minPhys, maxPhys, minMagic, maxMagic }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePsychicHammerKnockbackChance(
|
||||||
|
slvl: number,
|
||||||
|
targetType: 'normal' | 'champion' | 'unique' | 'boss' | 'player',
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
if (targetType === 'normal') return 100
|
||||||
|
if (targetType === 'champion' || targetType === 'unique') {
|
||||||
|
return computeDiminishingReturns(50, 100, slvl)
|
||||||
|
}
|
||||||
|
// boss or player
|
||||||
|
return computeDiminishingReturns(25, 99, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 258: Burst of Speed / Quickness
|
||||||
|
* - Duration: ln56 = 3000 + 300 * (slvl - 1) frames (120s + 12s/lvl)
|
||||||
|
* - FRW Bonus%: dm12 = computeDiminishingReturns(15, 70, slvl)
|
||||||
|
* - IAS Bonus%: dm34 = computeDiminishingReturns(15, 60, slvl)
|
||||||
|
*/
|
||||||
|
export function computeBurstOfSpeedDuration(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(3000, 300, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBurstOfSpeedFRW(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(15, 70, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBurstOfSpeedIAS(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(15, 60, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 263: Weapon Block (Passive)
|
||||||
|
* - Block Chance%: dm12 = computeDiminishingReturns(20, 65, slvl)
|
||||||
|
* - Requires dual-claw equipped
|
||||||
|
*/
|
||||||
|
export function computeWeaponBlockChance(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(20, 65, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 264: Cloak of Shadows
|
||||||
|
* - Radius: 30 subtiles (20 yards)
|
||||||
|
* - Duration: ln34 = 200 + 25 * (slvl - 1) frames (8.0s + 1.0s/lvl)
|
||||||
|
* - Monster Defense Decrease%: min(15 + 3 * (slvl - 1), 95)%
|
||||||
|
* - Assassin Defense Increase%: 10 + 3 * (slvl - 1)%
|
||||||
|
*/
|
||||||
|
export function computeCloakOfShadowsDuration(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(200, 25, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCloakOfShadowsDefenseReduction(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
const raw = computeLinearScaling(15, 3, slvl)
|
||||||
|
return Math.min(raw, 95)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCloakOfShadowsDefenseBonus(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(10, 3, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 267: Fade
|
||||||
|
* - Duration: ln56 = 3000 + 300 * (slvl - 1) frames (120s + 12s/lvl)
|
||||||
|
* - All Elemental Resistances%: dm12 = computeDiminishingReturns(10, 75, slvl)
|
||||||
|
* - Curse Length Reduction%: dm34 = computeDiminishingReturns(40, 90, slvl)
|
||||||
|
* - Hidden Physical Damage Reduction%: 1% per slvl (ln78 = 1 + 1 * (slvl - 1) = slvl)
|
||||||
|
*/
|
||||||
|
export function computeFadeDuration(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(3000, 300, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFadeResistances(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(10, 75, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFadeCurseReduction(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(40, 90, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFadePhysicalDamageReduction(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return slvl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 273: Mind Blast
|
||||||
|
* - Physical Damage: MinDam=10, Lev=[2, 5, 8, 8, 8]; MaxDam=20, Lev=[2, 5, 8, 8, 8]
|
||||||
|
* - Stun Duration: 50 + 5 * (slvl - 1) frames (compute3BandDuration(slvl, 50, 5, 5, 5))
|
||||||
|
* - Conversion Chance%: dm56 = computeDiminishingReturns(15, 40, slvl)
|
||||||
|
* - Conversion Base Duration: 150 frames (6.0s), with random variance up to +100 frames (total 6..10s)
|
||||||
|
*/
|
||||||
|
export interface MindBlastDamage {
|
||||||
|
readonly minPhys: number
|
||||||
|
readonly maxPhys: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMindBlastDamage(slvl: number): MindBlastDamage {
|
||||||
|
if (slvl <= 0) return { minPhys: 0, maxPhys: 0 }
|
||||||
|
const minPhys = compute5BandScaling(slvl, 10, 2, 5, 8, 8, 8)
|
||||||
|
const maxPhys = compute5BandScaling(slvl, 20, 2, 5, 8, 8, 8)
|
||||||
|
return { minPhys, maxPhys }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMindBlastStunDuration(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return compute3BandDuration(slvl, 50, 5, 5, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMindBlastConversionChance(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeDiminishingReturns(15, 40, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 278: Venom
|
||||||
|
* - Buff Duration: ln12 = 3000 + 100 * (slvl - 1) frames (120s + 4s/lvl)
|
||||||
|
* - Poison Attack Duration: Strictly 10 frames (0.4s)
|
||||||
|
* - Poison Damage (HitShift 6 = *64 in 256ths):
|
||||||
|
* EMin = 24, Lev=[6, 8, 10, 12, 14]
|
||||||
|
* EMax = 32, Lev=[6, 8, 10, 12, 14]
|
||||||
|
*/
|
||||||
|
export function computeVenomDuration(slvl: number): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
return computeLinearScaling(3000, 100, slvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VenomPoisonDamage {
|
||||||
|
readonly minDamage: number
|
||||||
|
readonly maxDamage: number
|
||||||
|
readonly durationFrames: number
|
||||||
|
readonly minBitrate: number
|
||||||
|
readonly maxBitrate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeVenomPoisonDamage(slvl: number): VenomPoisonDamage {
|
||||||
|
if (slvl <= 0) {
|
||||||
|
return { minDamage: 0, maxDamage: 0, durationFrames: 10, minBitrate: 0, maxBitrate: 0 }
|
||||||
|
}
|
||||||
|
const minBase = compute5BandScaling(slvl, 24, 6, 8, 10, 12, 14)
|
||||||
|
const maxBase = compute5BandScaling(slvl, 32, 6, 8, 10, 12, 14)
|
||||||
|
// Total damage values in HP
|
||||||
|
// In D2 1.13c, venom deals this total damage over exactly 10 frames
|
||||||
|
// The bitrate is (totalDamage << 8) / duration or in 256ths:
|
||||||
|
// Skills.txt HitShift=6 implies base values are in 64ths: (base * 64) / 10
|
||||||
|
const durationFrames = 10
|
||||||
|
const minBitrate = (minBase * 64) / durationFrames
|
||||||
|
const maxBitrate = (maxBase * 64) / durationFrames
|
||||||
|
|
||||||
|
return {
|
||||||
|
minDamage: minBase,
|
||||||
|
maxDamage: maxBase,
|
||||||
|
durationFrames,
|
||||||
|
minBitrate,
|
||||||
|
maxBitrate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skill 268 & 279: Shadow Warrior & Shadow Master Stats
|
||||||
|
*/
|
||||||
|
export interface ShadowMinionStats {
|
||||||
|
readonly hpBonusPct: number
|
||||||
|
readonly toHitBonus: number
|
||||||
|
readonly strBonus: number
|
||||||
|
readonly dexBonus: number
|
||||||
|
readonly allResistPct: number
|
||||||
|
readonly defenseBonusPct: number
|
||||||
|
readonly itemQualityLevel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeShadowWarriorStats(slvl: number): ShadowMinionStats {
|
||||||
|
if (slvl <= 0) {
|
||||||
|
return {
|
||||||
|
hpBonusPct: 0,
|
||||||
|
toHitBonus: 0,
|
||||||
|
strBonus: 0,
|
||||||
|
dexBonus: 0,
|
||||||
|
allResistPct: 0,
|
||||||
|
defenseBonusPct: 0,
|
||||||
|
itemQualityLevel: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hpBonusPct: slvl * 15,
|
||||||
|
toHitBonus: slvl * 40,
|
||||||
|
strBonus: slvl * 10,
|
||||||
|
dexBonus: slvl * 10,
|
||||||
|
allResistPct: Math.min(slvl * 4, 75),
|
||||||
|
defenseBonusPct: slvl * 12,
|
||||||
|
itemQualityLevel: 18 + (slvl - 1) * 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeShadowMasterStats(slvl: number): ShadowMinionStats {
|
||||||
|
if (slvl <= 0) {
|
||||||
|
return {
|
||||||
|
hpBonusPct: 0,
|
||||||
|
toHitBonus: 0,
|
||||||
|
strBonus: 0,
|
||||||
|
dexBonus: 0,
|
||||||
|
allResistPct: 0,
|
||||||
|
defenseBonusPct: 0,
|
||||||
|
itemQualityLevel: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hpBonusPct: slvl * 15,
|
||||||
|
toHitBonus: slvl * 40,
|
||||||
|
strBonus: slvl * 10,
|
||||||
|
dexBonus: slvl * 10,
|
||||||
|
allResistPct: computeDiminishingReturns(5, 90, slvl),
|
||||||
|
defenseBonusPct: 0,
|
||||||
|
itemQualityLevel: 24 + (slvl - 1) * 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shadow Buff State Tracker (Mutual Exclusivity & Cooldowns) ---
|
||||||
|
|
||||||
|
export interface ActiveShadowBuff {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly name: string
|
||||||
|
readonly level: number
|
||||||
|
remainingFrames: number
|
||||||
|
readonly params: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShadowBuffTracker {
|
||||||
|
private activeBoS: ActiveShadowBuff | null = null
|
||||||
|
private activeFade: ActiveShadowBuff | null = null
|
||||||
|
private activeVenom: ActiveShadowBuff | null = null
|
||||||
|
private activeCloak: ActiveShadowBuff | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Burst of Speed (Quickness).
|
||||||
|
* 1.13c Invariant: Cancels Fade if active.
|
||||||
|
*/
|
||||||
|
applyBurstOfSpeed(slvl: number): { applied: boolean; cancelledFade: boolean } {
|
||||||
|
let cancelledFade = false
|
||||||
|
if (this.activeFade) {
|
||||||
|
this.activeFade = null
|
||||||
|
cancelledFade = true
|
||||||
|
}
|
||||||
|
const duration = computeBurstOfSpeedDuration(slvl)
|
||||||
|
this.activeBoS = {
|
||||||
|
skillId: ASSASSIN_SHADOW_SKILL_IDS.BURST_OF_SPEED,
|
||||||
|
name: 'Burst of Speed',
|
||||||
|
level: slvl,
|
||||||
|
remainingFrames: duration,
|
||||||
|
params: {
|
||||||
|
frwPct: computeBurstOfSpeedFRW(slvl),
|
||||||
|
iasPct: computeBurstOfSpeedIAS(slvl),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { applied: true, cancelledFade }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Fade.
|
||||||
|
* 1.13c Invariant: Cancels Burst of Speed if active.
|
||||||
|
*/
|
||||||
|
applyFade(slvl: number): { applied: boolean; cancelledBoS: boolean } {
|
||||||
|
let cancelledBoS = false
|
||||||
|
if (this.activeBoS) {
|
||||||
|
this.activeBoS = null
|
||||||
|
cancelledBoS = true
|
||||||
|
}
|
||||||
|
const duration = computeFadeDuration(slvl)
|
||||||
|
this.activeFade = {
|
||||||
|
skillId: ASSASSIN_SHADOW_SKILL_IDS.FADE,
|
||||||
|
name: 'Fade',
|
||||||
|
level: slvl,
|
||||||
|
remainingFrames: duration,
|
||||||
|
params: {
|
||||||
|
allResistPct: computeFadeResistances(slvl),
|
||||||
|
curseReductionPct: computeFadeCurseReduction(slvl),
|
||||||
|
damageResistPct: computeFadePhysicalDamageReduction(slvl),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { applied: true, cancelledBoS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Venom.
|
||||||
|
* Coexists with Burst of Speed or Fade.
|
||||||
|
*/
|
||||||
|
applyVenom(slvl: number): void {
|
||||||
|
const duration = computeVenomDuration(slvl)
|
||||||
|
const poison = computeVenomPoisonDamage(slvl)
|
||||||
|
this.activeVenom = {
|
||||||
|
skillId: ASSASSIN_SHADOW_SKILL_IDS.VENOM,
|
||||||
|
name: 'Venom',
|
||||||
|
level: slvl,
|
||||||
|
remainingFrames: duration,
|
||||||
|
params: {
|
||||||
|
minDamage: poison.minDamage,
|
||||||
|
maxDamage: poison.maxDamage,
|
||||||
|
durationFrames: poison.durationFrames,
|
||||||
|
minBitrate: poison.minBitrate,
|
||||||
|
maxBitrate: poison.maxBitrate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply Cloak of Shadows.
|
||||||
|
* 1.13c Invariant: Cannot be cast if already active!
|
||||||
|
*/
|
||||||
|
applyCloakOfShadows(slvl: number): { success: boolean; reason?: string | undefined } {
|
||||||
|
if (this.activeCloak && this.activeCloak.remainingFrames > 0) {
|
||||||
|
return { success: false, reason: 'Cloak of Shadows is already active (recast locked)' }
|
||||||
|
}
|
||||||
|
const duration = computeCloakOfShadowsDuration(slvl)
|
||||||
|
this.activeCloak = {
|
||||||
|
skillId: ASSASSIN_SHADOW_SKILL_IDS.CLOAK_OF_SHADOWS,
|
||||||
|
name: 'Cloak of Shadows',
|
||||||
|
level: slvl,
|
||||||
|
remainingFrames: duration,
|
||||||
|
params: {
|
||||||
|
monsterDefReductionPct: computeCloakOfShadowsDefenseReduction(slvl),
|
||||||
|
assassinDefBonusPct: computeCloakOfShadowsDefenseBonus(slvl),
|
||||||
|
radiusSubtiles: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
getBurstOfSpeed(): ActiveShadowBuff | null {
|
||||||
|
return this.activeBoS
|
||||||
|
}
|
||||||
|
|
||||||
|
getFade(): ActiveShadowBuff | null {
|
||||||
|
return this.activeFade
|
||||||
|
}
|
||||||
|
|
||||||
|
getVenom(): ActiveShadowBuff | null {
|
||||||
|
return this.activeVenom
|
||||||
|
}
|
||||||
|
|
||||||
|
getCloakOfShadows(): ActiveShadowBuff | null {
|
||||||
|
return this.activeCloak
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance simulation time by a number of frames.
|
||||||
|
*/
|
||||||
|
updateFrames(deltaFrames: number): void {
|
||||||
|
if (this.activeBoS) {
|
||||||
|
this.activeBoS.remainingFrames -= deltaFrames
|
||||||
|
if (this.activeBoS.remainingFrames <= 0) this.activeBoS = null
|
||||||
|
}
|
||||||
|
if (this.activeFade) {
|
||||||
|
this.activeFade.remainingFrames -= deltaFrames
|
||||||
|
if (this.activeFade.remainingFrames <= 0) this.activeFade = null
|
||||||
|
}
|
||||||
|
if (this.activeVenom) {
|
||||||
|
this.activeVenom.remainingFrames -= deltaFrames
|
||||||
|
if (this.activeVenom.remainingFrames <= 0) this.activeVenom = null
|
||||||
|
}
|
||||||
|
if (this.activeCloak) {
|
||||||
|
this.activeCloak.remainingFrames -= deltaFrames
|
||||||
|
if (this.activeCloak.remainingFrames <= 0) this.activeCloak = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Venom Poison Clamp Engine ---
|
||||||
|
|
||||||
|
export interface PoisonSource {
|
||||||
|
readonly damage: number
|
||||||
|
readonly durationFrames: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClampedPoisonResult {
|
||||||
|
readonly finalDurationFrames: number
|
||||||
|
readonly totalDamageDealt: number
|
||||||
|
readonly effectiveBitrate: number
|
||||||
|
readonly sourcesCombinedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Venom Bitrate Clamping:
|
||||||
|
* When Venom is active on an attack:
|
||||||
|
* 1. The total duration of the poison attack is clamped to 10 frames (0.4s).
|
||||||
|
* 2. Each additional poison source's bitrate is calculated: bitrate = (damage << 8) / duration.
|
||||||
|
* 3. The total bitrate = venomBitrate + sum(otherBitrates).
|
||||||
|
* 4. The total damage dealt over the 10 frames = (totalBitrate * 10) >> 8.
|
||||||
|
*/
|
||||||
|
export function resolveVenomPoisonClamp(
|
||||||
|
venomLevel: number,
|
||||||
|
additionalSources: readonly PoisonSource[],
|
||||||
|
): ClampedPoisonResult {
|
||||||
|
const venom = computeVenomPoisonDamage(venomLevel)
|
||||||
|
const venomBitrate256 = (venom.minDamage << 8) / 10
|
||||||
|
|
||||||
|
let totalBitrate256 = venomBitrate256
|
||||||
|
for (const src of additionalSources) {
|
||||||
|
if (src.durationFrames > 0 && src.damage > 0) {
|
||||||
|
const srcBitrate256 = (src.damage << 8) / src.durationFrames
|
||||||
|
totalBitrate256 += srcBitrate256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDurationFrames = 10
|
||||||
|
const totalDamageDealt = (totalBitrate256 * finalDurationFrames) >> 8
|
||||||
|
|
||||||
|
return {
|
||||||
|
finalDurationFrames,
|
||||||
|
totalDamageDealt,
|
||||||
|
effectiveBitrate: totalBitrate256,
|
||||||
|
sourcesCombinedCount: additionalSources.length + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Weapon Block Mechanics ---
|
||||||
|
|
||||||
|
export type PlayerMovementMode = 'standing' | 'walking' | 'attacking' | 'casting' | 'running'
|
||||||
|
|
||||||
|
export interface WeaponBlockEvaluation {
|
||||||
|
readonly canBlock: boolean
|
||||||
|
readonly baseChancePct: number
|
||||||
|
readonly effectiveChancePct: number
|
||||||
|
readonly reason?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Weapon Block Resolution:
|
||||||
|
* - Requires dual-claw wielding (two claws equipped).
|
||||||
|
* - Full block chance applied when standing, walking, attacking, or casting.
|
||||||
|
* - Unlike shields (which drop to 1/3 block chance with 75% cap), Weapon Block drops to 0% when running!
|
||||||
|
* - Weapon Block can block elemental/magical spells and ranged projectiles.
|
||||||
|
*/
|
||||||
|
export function resolveWeaponBlock(
|
||||||
|
slvl: number,
|
||||||
|
hasDualClaws: boolean,
|
||||||
|
mode: PlayerMovementMode,
|
||||||
|
): WeaponBlockEvaluation {
|
||||||
|
if (!hasDualClaws) {
|
||||||
|
return {
|
||||||
|
canBlock: false,
|
||||||
|
baseChancePct: 0,
|
||||||
|
effectiveChancePct: 0,
|
||||||
|
reason: 'Requires dual claws to activate Weapon Block',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseChance = computeWeaponBlockChance(slvl)
|
||||||
|
if (mode === 'running') {
|
||||||
|
return {
|
||||||
|
canBlock: false,
|
||||||
|
baseChancePct: baseChance,
|
||||||
|
effectiveChancePct: 0,
|
||||||
|
reason: 'Weapon Block is disabled (0%) while running',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
canBlock: true,
|
||||||
|
baseChancePct: baseChance,
|
||||||
|
effectiveChancePct: baseChance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mind Blast Stun & Conversion Resolution ---
|
||||||
|
|
||||||
|
export interface MindBlastResolution {
|
||||||
|
readonly damageDealt: number
|
||||||
|
readonly stunApplied: boolean
|
||||||
|
readonly stunDurationFrames: number
|
||||||
|
readonly converted: boolean
|
||||||
|
readonly conversionDurationFrames: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Mind Blast Resolution against a specific target.
|
||||||
|
*/
|
||||||
|
export function resolveMindBlastTarget(
|
||||||
|
slvl: number,
|
||||||
|
target: {
|
||||||
|
readonly isPlayer?: boolean | undefined
|
||||||
|
readonly isBoss?: boolean | undefined
|
||||||
|
readonly isUnique?: boolean | undefined
|
||||||
|
readonly isChampion?: boolean | undefined
|
||||||
|
readonly roll?: number | undefined // 0..99
|
||||||
|
readonly durationRoll?: number | undefined // 0..100
|
||||||
|
},
|
||||||
|
): MindBlastResolution {
|
||||||
|
const dmg = computeMindBlastDamage(slvl)
|
||||||
|
const stunDuration = computeMindBlastStunDuration(slvl)
|
||||||
|
|
||||||
|
// Immunity check for conversion:
|
||||||
|
// Players, Act Bosses, Uniques, Champions cannot be converted
|
||||||
|
const cannotConvert = Boolean(
|
||||||
|
target.isPlayer || target.isBoss || target.isUnique || target.isChampion,
|
||||||
|
)
|
||||||
|
|
||||||
|
const convChance = computeMindBlastConversionChance(slvl)
|
||||||
|
const roll = target.roll ?? 50
|
||||||
|
const converted = !cannotConvert && roll < convChance
|
||||||
|
|
||||||
|
const durationRand = target.durationRoll ?? 50
|
||||||
|
const conversionDuration = converted ? 150 + durationRand : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
damageDealt: dmg.minPhys,
|
||||||
|
stunApplied: true,
|
||||||
|
stunDurationFrames: stunDuration,
|
||||||
|
converted,
|
||||||
|
conversionDurationFrames: conversionDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Shadow Minion Orchestrator (Warrior & Master) ---
|
||||||
|
|
||||||
|
export type ShadowType = 'warrior' | 'master'
|
||||||
|
|
||||||
|
export interface ActiveShadowMinion {
|
||||||
|
readonly type: ShadowType
|
||||||
|
readonly slvl: number
|
||||||
|
readonly stats: ShadowMinionStats
|
||||||
|
readonly summonedFrame: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShadowMinionManager {
|
||||||
|
private activeShadow: ActiveShadowMinion | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summon a Shadow Warrior or Shadow Master.
|
||||||
|
* 1.13c Invariant: PetType 'shadowwarrior' limit is 1.
|
||||||
|
* Summoning one replaces the other immediately.
|
||||||
|
*/
|
||||||
|
summonShadow(type: ShadowType, slvl: number, currentFrame: number): {
|
||||||
|
shadow: ActiveShadowMinion
|
||||||
|
despawnedPrevious: boolean
|
||||||
|
} {
|
||||||
|
const despawnedPrevious = this.activeShadow !== null
|
||||||
|
const stats =
|
||||||
|
type === 'warrior'
|
||||||
|
? computeShadowWarriorStats(slvl)
|
||||||
|
: computeShadowMasterStats(slvl)
|
||||||
|
|
||||||
|
this.activeShadow = {
|
||||||
|
type,
|
||||||
|
slvl,
|
||||||
|
stats,
|
||||||
|
summonedFrame: currentFrame,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shadow: this.activeShadow,
|
||||||
|
despawnedPrevious,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveShadow(): ActiveShadowMinion | null {
|
||||||
|
return this.activeShadow
|
||||||
|
}
|
||||||
|
|
||||||
|
despawnShadow(): boolean {
|
||||||
|
if (this.activeShadow) {
|
||||||
|
this.activeShadow = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate effective skill level when Shadow Warrior casts a mirrored skill.
|
||||||
|
* Formula: floor(warriorLevel / 3) + floor(playerSkillLevel / 2)
|
||||||
|
*/
|
||||||
|
getShadowWarriorCastingLevel(warriorLevel: number, playerSkillLevel: number): number {
|
||||||
|
return Math.floor(warriorLevel / 3) + Math.floor(playerSkillLevel / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate effective skill level when Shadow Master casts an autonomous skill.
|
||||||
|
* Formula: floor(masterLevel / 2)
|
||||||
|
*/
|
||||||
|
getShadowMasterCastingLevel(masterLevel: number): number {
|
||||||
|
return Math.floor(masterLevel / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Assassin Shadow Disciplines Adversarial & Stress Suite
|
||||||
|
*
|
||||||
|
* Verifies 1.13c ground truth parity for all 10 Assassin Shadow Disciplines skills:
|
||||||
|
* 1. Claw Mastery: AR%, ED%, and diminishing returns Critical Strike%
|
||||||
|
* 2. Burst of Speed vs Fade: Mutual exclusivity, duration, FRW/IAS, Resists, hidden physical DR
|
||||||
|
* 3. Weapon Block: Dual-claw requirement, standing/attacking/casting vs running (0% block)
|
||||||
|
* 4. Cloak of Shadows: Active duration, defense debuff/buff, recast lockout invariant
|
||||||
|
* 5. Psychic Hammer: 50/50 phys/magic split, monster type knockback scaling
|
||||||
|
* 6. Mind Blast: AOE stun duration, conversion chance, champion/unique/boss immunity
|
||||||
|
* 7. Venom: 10-frame bitrate clamp engine and combined poison scaling
|
||||||
|
* 8. Shadow Minion Orchestrator: PetType limit = 1, mutual replacement, Warrior mirroring, Master autonomous slvl
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
ASSASSIN_SHADOW_SKILL_IDS,
|
||||||
|
computeClawMasteryAR,
|
||||||
|
computeClawMasteryCriticalStrike,
|
||||||
|
computeClawMasteryED,
|
||||||
|
computeBurstOfSpeedDuration,
|
||||||
|
computeBurstOfSpeedFRW,
|
||||||
|
computeBurstOfSpeedIAS,
|
||||||
|
computeCloakOfShadowsDefenseBonus,
|
||||||
|
computeCloakOfShadowsDefenseReduction,
|
||||||
|
computeCloakOfShadowsDuration,
|
||||||
|
computeFadeCurseReduction,
|
||||||
|
computeFadeDuration,
|
||||||
|
computeFadePhysicalDamageReduction,
|
||||||
|
computeFadeResistances,
|
||||||
|
computeMindBlastConversionChance,
|
||||||
|
computeMindBlastDamage,
|
||||||
|
computeMindBlastStunDuration,
|
||||||
|
computePsychicHammerDamage,
|
||||||
|
computePsychicHammerKnockbackChance,
|
||||||
|
computeShadowMasterStats,
|
||||||
|
computeShadowWarriorStats,
|
||||||
|
computeVenomDuration,
|
||||||
|
computeVenomPoisonDamage,
|
||||||
|
computeWeaponBlockChance,
|
||||||
|
resolveMindBlastTarget,
|
||||||
|
resolveVenomPoisonClamp,
|
||||||
|
resolveWeaponBlock,
|
||||||
|
ShadowBuffTracker,
|
||||||
|
ShadowMinionManager,
|
||||||
|
} from '../../../src/game/skills/assassin-shadow.ts'
|
||||||
|
|
||||||
|
describe('Assassin Shadow Disciplines Ground Truth & Adversarial Suite (Milestone M28)', () => {
|
||||||
|
describe('1. Claw Mastery Scaling & Critical Strike Mechanics', () => {
|
||||||
|
it('evaluates exact Attack Rating and Enhanced Damage progression', () => {
|
||||||
|
// AR: 30 + 10 * (slvl - 1)%
|
||||||
|
expect(computeClawMasteryAR(1)).toBe(30)
|
||||||
|
expect(computeClawMasteryAR(10)).toBe(120)
|
||||||
|
expect(computeClawMasteryAR(20)).toBe(220)
|
||||||
|
|
||||||
|
// ED: 35 + 4 * (slvl - 1)%
|
||||||
|
expect(computeClawMasteryED(1)).toBe(35)
|
||||||
|
expect(computeClawMasteryED(10)).toBe(71)
|
||||||
|
expect(computeClawMasteryED(20)).toBe(111)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates diminishing returns Critical Strike percentage', () => {
|
||||||
|
// dm(0, 25, slvl)
|
||||||
|
expect(computeClawMasteryCriticalStrike(1)).toBe(3)
|
||||||
|
expect(computeClawMasteryCriticalStrike(10)).toBe(17)
|
||||||
|
expect(computeClawMasteryCriticalStrike(20)).toBe(21)
|
||||||
|
expect(computeClawMasteryCriticalStrike(30)).toBe(22)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('2. Burst of Speed vs Fade Mutual Exclusivity Engine', () => {
|
||||||
|
it('evaluates Burst of Speed duration, FRW, and IAS scaling', () => {
|
||||||
|
// Duration: 3000 + 300*(slvl-1) frames
|
||||||
|
expect(computeBurstOfSpeedDuration(1)).toBe(3000)
|
||||||
|
expect(computeBurstOfSpeedDuration(10)).toBe(5700)
|
||||||
|
expect(computeBurstOfSpeedDuration(20)).toBe(8700)
|
||||||
|
|
||||||
|
// FRW: dm(15, 70, slvl)
|
||||||
|
expect(computeBurstOfSpeedFRW(1)).toBe(23)
|
||||||
|
expect(computeBurstOfSpeedFRW(10)).toBe(52)
|
||||||
|
expect(computeBurstOfSpeedFRW(20)).toBe(61)
|
||||||
|
|
||||||
|
// IAS: dm(15, 60, slvl)
|
||||||
|
expect(computeBurstOfSpeedIAS(1)).toBe(22)
|
||||||
|
expect(computeBurstOfSpeedIAS(10)).toBe(45)
|
||||||
|
expect(computeBurstOfSpeedIAS(20)).toBe(53)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Fade duration, All Resists, Curse Reduction, and hidden Physical DR', () => {
|
||||||
|
// Duration: 3000 + 300*(slvl-1) frames
|
||||||
|
expect(computeFadeDuration(1)).toBe(3000)
|
||||||
|
expect(computeFadeDuration(10)).toBe(5700)
|
||||||
|
expect(computeFadeDuration(20)).toBe(8700)
|
||||||
|
|
||||||
|
// All Resists: dm(10, 75, slvl)
|
||||||
|
expect(computeFadeResistances(1)).toBe(20)
|
||||||
|
expect(computeFadeResistances(10)).toBe(54)
|
||||||
|
expect(computeFadeResistances(20)).toBe(65)
|
||||||
|
|
||||||
|
// Curse Length Reduction: dm(40, 90, slvl)
|
||||||
|
expect(computeFadeCurseReduction(1)).toBe(47)
|
||||||
|
expect(computeFadeCurseReduction(10)).toBe(74)
|
||||||
|
expect(computeFadeCurseReduction(20)).toBe(82)
|
||||||
|
|
||||||
|
// Hidden Physical Damage Reduction: 1% per slvl
|
||||||
|
expect(computeFadePhysicalDamageReduction(1)).toBe(1)
|
||||||
|
expect(computeFadePhysicalDamageReduction(10)).toBe(10)
|
||||||
|
expect(computeFadePhysicalDamageReduction(20)).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces strict mutual cancellation between Burst of Speed and Fade', () => {
|
||||||
|
const tracker = new ShadowBuffTracker()
|
||||||
|
|
||||||
|
// Activate Burst of Speed
|
||||||
|
const r1 = tracker.applyBurstOfSpeed(10)
|
||||||
|
expect(r1.applied).toBe(true)
|
||||||
|
expect(r1.cancelledFade).toBe(false)
|
||||||
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
||||||
|
expect(tracker.getFade()).toBeNull()
|
||||||
|
|
||||||
|
// Activating Fade cancels Burst of Speed
|
||||||
|
const r2 = tracker.applyFade(15)
|
||||||
|
expect(r2.applied).toBe(true)
|
||||||
|
expect(r2.cancelledBoS).toBe(true)
|
||||||
|
expect(tracker.getBurstOfSpeed()).toBeNull()
|
||||||
|
expect(tracker.getFade()).not.toBeNull()
|
||||||
|
expect(tracker.getFade()?.level).toBe(15)
|
||||||
|
|
||||||
|
// Activating Burst of Speed again cancels Fade
|
||||||
|
const r3 = tracker.applyBurstOfSpeed(20)
|
||||||
|
expect(r3.applied).toBe(true)
|
||||||
|
expect(r3.cancelledFade).toBe(true)
|
||||||
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
||||||
|
expect(tracker.getFade()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('permits Venom to coexist with Burst of Speed or Fade', () => {
|
||||||
|
const tracker = new ShadowBuffTracker()
|
||||||
|
|
||||||
|
tracker.applyBurstOfSpeed(10)
|
||||||
|
tracker.applyVenom(20)
|
||||||
|
expect(tracker.getBurstOfSpeed()).not.toBeNull()
|
||||||
|
expect(tracker.getVenom()).not.toBeNull()
|
||||||
|
|
||||||
|
// Switch to Fade; Venom remains active!
|
||||||
|
tracker.applyFade(10)
|
||||||
|
expect(tracker.getBurstOfSpeed()).toBeNull()
|
||||||
|
expect(tracker.getFade()).not.toBeNull()
|
||||||
|
expect(tracker.getVenom()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('3. Weapon Block Dual-Claw Mechanics & Movement Sensitivity', () => {
|
||||||
|
it('evaluates diminishing returns block chance scaling', () => {
|
||||||
|
// dm(20, 65, slvl)
|
||||||
|
expect(computeWeaponBlockChance(1)).toBe(27)
|
||||||
|
expect(computeWeaponBlockChance(10)).toBe(50)
|
||||||
|
expect(computeWeaponBlockChance(20)).toBe(58)
|
||||||
|
expect(computeWeaponBlockChance(30)).toBe(61)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strictly requires dual claws to activate block', () => {
|
||||||
|
// Single claw or non-claw weapon
|
||||||
|
const noDual = resolveWeaponBlock(20, false, 'standing')
|
||||||
|
expect(noDual.canBlock).toBe(false)
|
||||||
|
expect(noDual.effectiveChancePct).toBe(0)
|
||||||
|
|
||||||
|
// Dual claws equipped
|
||||||
|
const dual = resolveWeaponBlock(20, true, 'standing')
|
||||||
|
expect(dual.canBlock).toBe(true)
|
||||||
|
expect(dual.effectiveChancePct).toBe(58)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces 0% block chance while running (1.13c ground truth invariant)', () => {
|
||||||
|
expect(resolveWeaponBlock(20, true, 'standing').effectiveChancePct).toBe(58)
|
||||||
|
expect(resolveWeaponBlock(20, true, 'walking').effectiveChancePct).toBe(58)
|
||||||
|
expect(resolveWeaponBlock(20, true, 'attacking').effectiveChancePct).toBe(58)
|
||||||
|
expect(resolveWeaponBlock(20, true, 'casting').effectiveChancePct).toBe(58)
|
||||||
|
|
||||||
|
// Running drops block chance to exactly 0%!
|
||||||
|
const runningBlock = resolveWeaponBlock(20, true, 'running')
|
||||||
|
expect(runningBlock.canBlock).toBe(false)
|
||||||
|
expect(runningBlock.effectiveChancePct).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('4. Cloak of Shadows Cooldown & Combat Suppression', () => {
|
||||||
|
it('evaluates duration and defense formulas', () => {
|
||||||
|
// Duration: 200 + 25 * (slvl - 1) frames
|
||||||
|
expect(computeCloakOfShadowsDuration(1)).toBe(200)
|
||||||
|
expect(computeCloakOfShadowsDuration(10)).toBe(425)
|
||||||
|
expect(computeCloakOfShadowsDuration(20)).toBe(675)
|
||||||
|
|
||||||
|
// Monster Defense Reduction: min(15 + 3 * (slvl - 1), 95)%
|
||||||
|
expect(computeCloakOfShadowsDefenseReduction(1)).toBe(15)
|
||||||
|
expect(computeCloakOfShadowsDefenseReduction(10)).toBe(42)
|
||||||
|
expect(computeCloakOfShadowsDefenseReduction(20)).toBe(72)
|
||||||
|
expect(computeCloakOfShadowsDefenseReduction(30)).toBe(95)
|
||||||
|
|
||||||
|
// Assassin Defense Bonus: 10 + 3 * (slvl - 1)%
|
||||||
|
expect(computeCloakOfShadowsDefenseBonus(1)).toBe(10)
|
||||||
|
expect(computeCloakOfShadowsDefenseBonus(10)).toBe(37)
|
||||||
|
expect(computeCloakOfShadowsDefenseBonus(20)).toBe(67)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces recast lockout while Cloak of Shadows is active', () => {
|
||||||
|
const tracker = new ShadowBuffTracker()
|
||||||
|
|
||||||
|
const firstCast = tracker.applyCloakOfShadows(1)
|
||||||
|
expect(firstCast.success).toBe(true)
|
||||||
|
expect(tracker.getCloakOfShadows()).not.toBeNull()
|
||||||
|
|
||||||
|
// Immediate recast must fail
|
||||||
|
const secondCast = tracker.applyCloakOfShadows(1)
|
||||||
|
expect(secondCast.success).toBe(false)
|
||||||
|
expect(secondCast.reason).toContain('recast locked')
|
||||||
|
|
||||||
|
// Advance frames until duration expires (200 frames)
|
||||||
|
tracker.updateFrames(199)
|
||||||
|
expect(tracker.applyCloakOfShadows(1).success).toBe(false)
|
||||||
|
|
||||||
|
tracker.updateFrames(1)
|
||||||
|
expect(tracker.getCloakOfShadows()).toBeNull()
|
||||||
|
|
||||||
|
// Now recast succeeds
|
||||||
|
expect(tracker.applyCloakOfShadows(1).success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('5. Psychic Hammer & Mind Blast Combat & Crowd Control', () => {
|
||||||
|
it('evaluates Psychic Hammer 50/50 physical and magic damage split', () => {
|
||||||
|
const d1 = computePsychicHammerDamage(1)
|
||||||
|
expect(d1.minPhys).toBe(2)
|
||||||
|
expect(d1.maxPhys).toBe(6)
|
||||||
|
expect(d1.minMagic).toBe(2)
|
||||||
|
expect(d1.maxMagic).toBe(6)
|
||||||
|
|
||||||
|
const d10 = computePsychicHammerDamage(10)
|
||||||
|
expect(d10.minPhys).toBe(22)
|
||||||
|
expect(d10.maxPhys).toBe(35)
|
||||||
|
expect(d10.minMagic).toBe(22)
|
||||||
|
expect(d10.maxMagic).toBe(35)
|
||||||
|
|
||||||
|
const d20 = computePsychicHammerDamage(20)
|
||||||
|
expect(d20.minPhys).toBe(56)
|
||||||
|
expect(d20.maxPhys).toBe(79)
|
||||||
|
expect(d20.minMagic).toBe(56)
|
||||||
|
expect(d20.maxMagic).toBe(79)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Psychic Hammer knockback chance per monster rank', () => {
|
||||||
|
expect(computePsychicHammerKnockbackChance(10, 'normal')).toBe(100)
|
||||||
|
expect(computePsychicHammerKnockbackChance(1, 'unique')).toBe(57)
|
||||||
|
expect(computePsychicHammerKnockbackChance(20, 'unique')).toBe(92)
|
||||||
|
expect(computePsychicHammerKnockbackChance(1, 'boss')).toBe(36)
|
||||||
|
expect(computePsychicHammerKnockbackChance(20, 'boss')).toBe(87)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Mind Blast physical damage and stun duration', () => {
|
||||||
|
const d1 = computeMindBlastDamage(1)
|
||||||
|
expect(d1.minPhys).toBe(10)
|
||||||
|
expect(d1.maxPhys).toBe(20)
|
||||||
|
|
||||||
|
const d20 = computeMindBlastDamage(20)
|
||||||
|
expect(d20.minPhys).toBe(96)
|
||||||
|
expect(d20.maxPhys).toBe(106)
|
||||||
|
|
||||||
|
// Stun duration: 50 + 5 * (slvl - 1) frames
|
||||||
|
expect(computeMindBlastStunDuration(1)).toBe(50)
|
||||||
|
expect(computeMindBlastStunDuration(10)).toBe(95)
|
||||||
|
expect(computeMindBlastStunDuration(20)).toBe(145)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Mind Blast conversion chance and strictly enforces boss/unique immunities', () => {
|
||||||
|
// Conversion chance: dm(15, 40, slvl)
|
||||||
|
expect(computeMindBlastConversionChance(1)).toBe(18)
|
||||||
|
expect(computeMindBlastConversionChance(10)).toBe(32)
|
||||||
|
expect(computeMindBlastConversionChance(20)).toBe(36)
|
||||||
|
|
||||||
|
// Normal monster with roll < chance converts
|
||||||
|
const resNormal = resolveMindBlastTarget(20, { roll: 20 })
|
||||||
|
expect(resNormal.stunApplied).toBe(true)
|
||||||
|
expect(resNormal.converted).toBe(true)
|
||||||
|
expect(resNormal.conversionDurationFrames).toBeGreaterThanOrEqual(150)
|
||||||
|
|
||||||
|
// Boss, Unique, Champion, and Player are IMMUNE to conversion even with roll = 0!
|
||||||
|
const resBoss = resolveMindBlastTarget(20, { isBoss: true, roll: 0 })
|
||||||
|
expect(resBoss.stunApplied).toBe(true)
|
||||||
|
expect(resBoss.converted).toBe(false)
|
||||||
|
expect(resBoss.conversionDurationFrames).toBe(0)
|
||||||
|
|
||||||
|
const resUnique = resolveMindBlastTarget(20, { isUnique: true, roll: 0 })
|
||||||
|
expect(resUnique.stunApplied).toBe(true)
|
||||||
|
expect(resUnique.converted).toBe(false)
|
||||||
|
|
||||||
|
const resPlayer = resolveMindBlastTarget(20, { isPlayer: true, roll: 0 })
|
||||||
|
expect(resPlayer.stunApplied).toBe(true)
|
||||||
|
expect(resPlayer.converted).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('6. Venom 10-Frame Bitrate Clamp Engine', () => {
|
||||||
|
it('evaluates Venom buff duration and base poison damage over 10 frames', () => {
|
||||||
|
// Duration: 3000 + 100 * (slvl - 1) frames
|
||||||
|
expect(computeVenomDuration(1)).toBe(3000)
|
||||||
|
expect(computeVenomDuration(10)).toBe(3900)
|
||||||
|
expect(computeVenomDuration(20)).toBe(4900)
|
||||||
|
|
||||||
|
// Damage values over 10 frames:
|
||||||
|
const v1 = computeVenomPoisonDamage(1)
|
||||||
|
expect(v1.durationFrames).toBe(10)
|
||||||
|
expect(v1.minDamage).toBe(24)
|
||||||
|
expect(v1.maxDamage).toBe(32)
|
||||||
|
|
||||||
|
const v10 = computeVenomPoisonDamage(10)
|
||||||
|
expect(v10.durationFrames).toBe(10)
|
||||||
|
expect(v10.minDamage).toBe(82)
|
||||||
|
expect(v10.maxDamage).toBe(90)
|
||||||
|
|
||||||
|
const v20 = computeVenomPoisonDamage(20)
|
||||||
|
expect(v20.durationFrames).toBe(10)
|
||||||
|
expect(v20.minDamage).toBe(170)
|
||||||
|
expect(v20.maxDamage).toBe(178)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps external poison duration to 10 frames and aggregates bitrates', () => {
|
||||||
|
// E.g. Venom slvl 20 (170 dmg / 10 frames = 17 dmg/frame)
|
||||||
|
// + Charm with 100 poison damage over 100 frames (1 dmg/frame)
|
||||||
|
const clamped = resolveVenomPoisonClamp(20, [
|
||||||
|
{ damage: 100, durationFrames: 100 },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(clamped.finalDurationFrames).toBe(10)
|
||||||
|
expect(clamped.sourcesCombinedCount).toBe(2)
|
||||||
|
// Total damage over 10 frames should be:
|
||||||
|
// 170 + (1 dmg/frame * 10 frames) = 180 dmg!
|
||||||
|
expect(clamped.totalDamageDealt).toBe(180)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('7. Shadow Minion (Warrior & Master) Orchestration', () => {
|
||||||
|
it('evaluates Shadow Warrior stats scaling', () => {
|
||||||
|
const w10 = computeShadowWarriorStats(10)
|
||||||
|
expect(w10.hpBonusPct).toBe(150) // +15% per lvl
|
||||||
|
expect(w10.toHitBonus).toBe(400) // +40 AR per lvl
|
||||||
|
expect(w10.strBonus).toBe(100) // +10 str per lvl
|
||||||
|
expect(w10.dexBonus).toBe(100) // +10 dex per lvl
|
||||||
|
expect(w10.defenseBonusPct).toBe(120) // +12% def per lvl
|
||||||
|
expect(w10.allResistPct).toBe(40) // min(slvl*4, 75)%
|
||||||
|
expect(w10.itemQualityLevel).toBe(36) // 18 + 2 * (10 - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Shadow Master stats scaling and diminishing returns resists', () => {
|
||||||
|
const m20 = computeShadowMasterStats(20)
|
||||||
|
expect(m20.hpBonusPct).toBe(300)
|
||||||
|
expect(m20.toHitBonus).toBe(800)
|
||||||
|
expect(m20.strBonus).toBe(200)
|
||||||
|
expect(m20.dexBonus).toBe(200)
|
||||||
|
// dm(5, 90, 20)
|
||||||
|
expect(m20.allResistPct).toBe(76)
|
||||||
|
expect(m20.itemQualityLevel).toBe(81) // 24 + 3 * (20 - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces PetType limit = 1 and mutual replacement between Warrior and Master', () => {
|
||||||
|
const manager = new ShadowMinionManager()
|
||||||
|
|
||||||
|
// Summon Shadow Warrior
|
||||||
|
const s1 = manager.summonShadow('warrior', 10, 1000)
|
||||||
|
expect(s1.despawnedPrevious).toBe(false)
|
||||||
|
expect(manager.getActiveShadow()?.type).toBe('warrior')
|
||||||
|
|
||||||
|
// Summon Shadow Master: automatically despawns Shadow Warrior!
|
||||||
|
const s2 = manager.summonShadow('master', 20, 1050)
|
||||||
|
expect(s2.despawnedPrevious).toBe(true)
|
||||||
|
expect(manager.getActiveShadow()?.type).toBe('master')
|
||||||
|
expect(manager.getActiveShadow()?.slvl).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates Shadow Warrior skill-mirroring and Shadow Master autonomous casting slvls', () => {
|
||||||
|
const manager = new ShadowMinionManager()
|
||||||
|
|
||||||
|
// Shadow Warrior mirroring: floor(warriorLevel / 3) + floor(playerSkillLevel / 2)
|
||||||
|
// Warrior slvl 15, Player Lightning Sentry slvl 20 -> floor(15/3) + floor(20/2) = 5 + 10 = 15
|
||||||
|
expect(manager.getShadowWarriorCastingLevel(15, 20)).toBe(15)
|
||||||
|
// Warrior slvl 20, Player Fists of Fire slvl 1 -> floor(20/3) + floor(1/2) = 6 + 0 = 6
|
||||||
|
expect(manager.getShadowWarriorCastingLevel(20, 1)).toBe(6)
|
||||||
|
|
||||||
|
// Shadow Master autonomous casting: floor(masterLevel / 2)
|
||||||
|
expect(manager.getShadowMasterCastingLevel(1)).toBe(0)
|
||||||
|
expect(manager.getShadowMasterCastingLevel(10)).toBe(5)
|
||||||
|
expect(manager.getShadowMasterCastingLevel(20)).toBe(10)
|
||||||
|
expect(manager.getShadowMasterCastingLevel(30)).toBe(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue