From 8d15aac80fe5cfa3c864fd2c2db4833ede11ba2f Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 25 Sep 2026 03:45:44 +0000 Subject: [PATCH] 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. --- src/game/skills/assassin-shadow.ts | 703 ++++++++++++++++++ .../skills/ass/adv-ass-shadow-stress.test.ts | 395 ++++++++++ 2 files changed, 1098 insertions(+) create mode 100644 src/game/skills/assassin-shadow.ts create mode 100644 tests/skills/ass/adv-ass-shadow-stress.test.ts diff --git a/src/game/skills/assassin-shadow.ts b/src/game/skills/assassin-shadow.ts new file mode 100644 index 0000000..1cbc548 --- /dev/null +++ b/src/game/skills/assassin-shadow.ts @@ -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 +} + +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) + } +} diff --git a/tests/skills/ass/adv-ass-shadow-stress.test.ts b/tests/skills/ass/adv-ass-shadow-stress.test.ts new file mode 100644 index 0000000..6c52382 --- /dev/null +++ b/tests/skills/ass/adv-ass-shadow-stress.test.ts @@ -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) + }) + }) +})