From 3e85a8059d8fcca8da1923e836a84b8df148e856 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 25 Sep 2026 03:38:21 +0000 Subject: [PATCH] feat(skills): implement Assassin Martial Arts tree with 1.13c parity (Milestone M27) - Implement ChargeOrbManager for 1..3 tier charge accumulation, 375-frame (15.0s) duration, timeout expiration, duration refresh, and multi-skill coexistence. - Implement resolveFinisherDischarge for full discharge on finisher contact (Tiger Strike ED%, Cobra Strike life/mana steal, Claws of Thunder cumulative elemental tiers, Phoenix Strike single tier payload). - Implement Dragon Talon multi-kicks (1 + floor(slvl/6)) and AR scaling. - Implement Dragon Claw dual-claw check, 2 strikes, and Claw Mastery synergy. - Implement Dragon Tail physical-to-fire conversion (50 + 10*(slvl-1)%), 6 subtiles AOE, and knockback. - Implement Dragon Flight teleport kick, NextDelay = 4 frames, and ED% scaling. - Add adv-ass-martial-stress.test.ts verifying all 7 adversarial test categories (16 tests passed). - All 10 Martial Arts unit tests (#254, #255, #259, #260, #265, #269, #270, #274, #275, #280) and typecheck pass. --- src/game/skills/assassin-martial-arts.ts | 571 ++++++++++++++++++ .../skills/ass/adv-ass-martial-stress.test.ts | 288 +++++++++ 2 files changed, 859 insertions(+) create mode 100644 src/game/skills/assassin-martial-arts.ts create mode 100644 tests/skills/ass/adv-ass-martial-stress.test.ts diff --git a/src/game/skills/assassin-martial-arts.ts b/src/game/skills/assassin-martial-arts.ts new file mode 100644 index 0000000..b02a080 --- /dev/null +++ b/src/game/skills/assassin-martial-arts.ts @@ -0,0 +1,571 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Assassin Martial Arts Module + * + * Dedicated pure calculations, charge-up progression (3-tier orbs, 375-frame duration), + * finisher release resolution (Dragon Talon multi-kicks, Dragon Claw dual-strikes, + * Dragon Tail explosive fire conversion, Dragon Flight teleport kicks), and Phoenix Strike + * tier payload selection for the 10 Assassin Martial Arts Tree Skills: + * - Skill 254: Tiger Strike (ReqLevel 1) + * - Skill 255: Dragon Talon (ReqLevel 1) + * - Skill 259: Fists of Fire (ReqLevel 6) + * - Skill 260: Dragon Claw (ReqLevel 6) + * - Skill 265: Cobra Strike (ReqLevel 12) + * - Skill 269: Claws of Thunder (ReqLevel 18) + * - Skill 270: Dragon Tail (ReqLevel 18) + * - Skill 274: Blades of Ice (ReqLevel 24) + * - Skill 275: Dragon Flight (ReqLevel 24) + * - Skill 280: Royal Strike / Phoenix Strike (ReqLevel 30) + * + * 1.13c Ground Truth: + * - Skills.txt, Missiles.txt, MonStats.txt, SkillDesc.txt (Patch_D2.mpq). + * - Charge-up Mechanics: + * Max charges per skill: 3. + * Duration: 375 frames (15.0 seconds). Striking with the same charge-up skill before + * expiry refreshes duration and adds the next tier orb. + * Multiple different charge-up skills can be active simultaneously. + * - Finisher Release Mechanics: + * Striking an enemy with any finisher move releases ALL active charge-ups simultaneously. + * For elemental skills (Fists of Fire, Claws of Thunder, Blades of Ice): + * Releases all tiers up to and including current charge (Charge 3 triggers Tier 1 + 2 + 3). + * For Phoenix Strike (Royal Strike): + * Releases strictly the single specific payload for the current charge level: + * Charge 1 = Meteor, Charge 2 = Chain Lightning, Charge 3 = Chaos Ice Orb. + * - Zero runtime MPQ reading. + */ + +import { compute5BandScaling } from '../engine/calc-ast.ts' + +// --- Skill IDs --- + +export const ASSASSIN_MARTIAL_SKILL_IDS = { + TIGER_STRIKE: 254, + DRAGON_TALON: 255, + FISTS_OF_FIRE: 259, + DRAGON_CLAW: 260, + COBRA_STRIKE: 265, + CLAWS_OF_THUNDER: 269, + DRAGON_TAIL: 270, + BLADES_OF_ICE: 274, + DRAGON_FLIGHT: 275, + PHOENIX_STRIKE: 280, +} as const + +export const MAX_CHARGES_PER_SKILL = 3 +export const CHARGE_DURATION_FRAMES = 375 // 15.0 seconds + +// --- Interfaces --- + +export interface ActiveChargeOrb { + readonly skillId: number + count: number + slvl: number + expiresAtFrame: number +} + +export interface DischargedPayload { + readonly skillId: number + readonly chargeLevel: number + readonly slvl: number + // Tiger Strike + readonly enhancedDamagePct?: number + // Cobra Strike + readonly lifeStealPct?: number + readonly manaStealPct?: number + // Elemental damages & effects + readonly fireDamage?: { min: number; max: number; explosionRadiusPx?: number | undefined; burningGroundFrames?: number | undefined } | undefined + readonly ltngDamage?: { min: number; max: number; hasNova?: boolean | undefined; chargedBoltsCount?: number | undefined } | undefined + readonly coldDamage?: { min: number; max: number; explosionRadiusPx?: number | undefined; freezeDurationFrames?: number | undefined } | undefined + // Phoenix Strike + readonly phoenixPayload?: { + tier: 1 | 2 | 3 + type: 'meteor' | 'chain_lightning' | 'chaos_ice' + meteorDamage?: { min: number; max: number } | undefined + chainLightningDamage?: { min: number; max: number; maxTargets: number } | undefined + chaosIceDamage?: { min: number; max: number; boltsCount: number; freezeFrames: number } | undefined + } | undefined +} + +// --- ChargeOrbManager Engine --- + +export class ChargeOrbManager { + private readonly charges = new Map() + + getChargeLevel(skillId: number, currentFrame: number): number { + const orb = this.charges.get(skillId) + if (!orb) return 0 + if (currentFrame >= orb.expiresAtFrame) { + this.charges.delete(skillId) + return 0 + } + return orb.count + } + + addCharge(skillId: number, slvl: number, currentFrame: number): { currentCount: number; previousCount: number } { + let orb = this.charges.get(skillId) + let previousCount = 0 + + if (orb && currentFrame < orb.expiresAtFrame) { + previousCount = orb.count + orb.count = Math.min(MAX_CHARGES_PER_SKILL, orb.count + 1) + orb.slvl = slvl + orb.expiresAtFrame = currentFrame + CHARGE_DURATION_FRAMES + return { currentCount: orb.count, previousCount } + } + + orb = { + skillId, + count: 1, + slvl, + expiresAtFrame: currentFrame + CHARGE_DURATION_FRAMES, + } + this.charges.set(skillId, orb) + return { currentCount: 1, previousCount: 0 } + } + + getActiveCharges(currentFrame: number): ActiveChargeOrb[] { + const active: ActiveChargeOrb[] = [] + for (const [skillId, orb] of this.charges.entries()) { + if (currentFrame < orb.expiresAtFrame) { + active.push({ ...orb }) + } else { + this.charges.delete(skillId) + } + } + return active + } + + dischargeAll(currentFrame: number): ActiveChargeOrb[] { + const active = this.getActiveCharges(currentFrame) + this.charges.clear() + return active + } + + clear(): void { + this.charges.clear() + } +} + +// --- Finisher Discharge Resolution --- + +export function resolveFinisherDischarge( + charges: readonly ActiveChargeOrb[], + options: { + clawMasteryBlvl?: number + } = {} +): DischargedPayload[] { + const payloads: DischargedPayload[] = [] + + for (const c of charges) { + if (c.count <= 0) continue + + switch (c.skillId) { + case ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE: { + const stats = evaluateTigerStrikeStats(c.slvl) + const edMultiplier = c.count === 1 ? stats.charge1EDPct : (c.count === 2 ? stats.charge2EDPct : stats.charge3EDPct) + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + enhancedDamagePct: edMultiplier, + }) + break + } + + case ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE: { + const stats = evaluateCobraStrikeStats(c.slvl) + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + lifeStealPct: c.count === 3 ? stats.charge3LifeStealPct : stats.charge1LifeStealPct, + manaStealPct: c.count >= 2 ? (c.count === 3 ? stats.charge3ManaStealPct : stats.charge2ManaStealPct) : 0, + }) + break + } + + case ASSASSIN_MARTIAL_SKILL_IDS.FISTS_OF_FIRE: { + const stats = evaluateFistsOfFireStats(c.slvl) + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + fireDamage: { + min: stats.minDamage, + max: stats.maxDamage, + explosionRadiusPx: c.count >= 2 ? 64 : undefined, + burningGroundFrames: c.count >= 3 ? stats.burningGroundFrames : undefined, + }, + }) + break + } + + case ASSASSIN_MARTIAL_SKILL_IDS.CLAWS_OF_THUNDER: { + const stats = evaluateClawsOfThunderStats(c.slvl) + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + ltngDamage: { + min: stats.minDamage, + max: stats.maxDamage, + hasNova: c.count >= 2, + chargedBoltsCount: c.count >= 3 ? 4 : undefined, + }, + }) + break + } + + case ASSASSIN_MARTIAL_SKILL_IDS.BLADES_OF_ICE: { + const stats = evaluateBladesOfIceStats(c.slvl) + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + coldDamage: { + min: stats.minDamage, + max: stats.maxDamage, + explosionRadiusPx: c.count >= 2 ? 64 : undefined, + freezeDurationFrames: c.count >= 3 ? stats.freezeDurationFrames : undefined, + }, + }) + break + } + + case ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE: { + const stats = evaluatePhoenixStrikeStats(c.slvl) + let phoenixPayload: DischargedPayload['phoenixPayload'] + if (c.count === 1) { + phoenixPayload = { + tier: 1, + type: 'meteor', + meteorDamage: { min: stats.meteorMinDamage, max: stats.meteorMaxDamage }, + } + } else if (c.count === 2) { + phoenixPayload = { + tier: 2, + type: 'chain_lightning', + chainLightningDamage: { min: 1, max: stats.chainLightningMaxDamage, maxTargets: stats.chainLightningTargets }, + } + } else { + phoenixPayload = { + tier: 3, + type: 'chaos_ice', + chaosIceDamage: { min: stats.chaosIceMinDamage, max: stats.chaosIceMaxDamage, boltsCount: 16, freezeFrames: stats.freezeDurationFrames }, + } + } + + payloads.push({ + skillId: c.skillId, + chargeLevel: c.count, + slvl: c.slvl, + phoenixPayload, + }) + break + } + } + } + + return payloads +} + +// --- Individual Skill Evaluators --- + +/** + * Skill #254: Tiger Strike + * 1.13c Parity: + * - Charge 1: +ln12% Enhanced Damage (100 + 20 * (slvl - 1)%) + * - Charge 2: +2 * ln12% Enhanced Damage + * - Charge 3: +3 * ln12% Enhanced Damage + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames (15s) + */ +export function evaluateTigerStrikeStats(slvl: number): { + charge1EDPct: number + charge2EDPct: number + charge3EDPct: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const baseED = 100 + 20 * (slvl - 1) + const attackRatingBonusPct = 15 + 7 * (slvl - 1) + + return { + charge1EDPct: baseED, + charge2EDPct: baseED * 2, + charge3EDPct: baseED * 3, + attackRatingBonusPct, + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 1, + } +} + +/** + * Skill #255: Dragon Talon + * 1.13c Parity: + * - Kick count: 1 + floor(slvl / 6) + * - Attack Rating: 20 + 35 * (slvl - 1)% + * - Mana cost: 6 + * - Knockback on each kick + */ +export function evaluateDragonTalonStats(slvl: number): { + kickCount: number + attackRatingBonusPct: number + manaCost: number +} { + return { + kickCount: 1 + Math.floor(slvl / 6), + attackRatingBonusPct: 20 + 35 * (slvl - 1), + manaCost: 6, + } +} + +/** + * Skill #259: Fists of Fire + * 1.13c Parity: + * - Fire damage: 6-10 base, scales with level + * - Converts physical to fire: 3 * slvl % + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames + */ +export function evaluateFistsOfFireStats(slvl: number): { + minDamage: number + maxDamage: number + convertPhysToFirePct: number + burningGroundFrames: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const minDamage = compute5BandScaling(slvl, 6, 4, 8, 12, 16, 20) + const maxDamage = compute5BandScaling(slvl, 10, 5, 9, 13, 17, 21) + const convertPhysToFirePct = Math.min(100, slvl * 3) + + return { + minDamage, + maxDamage, + convertPhysToFirePct, + burningGroundFrames: slvl * 3, + attackRatingBonusPct: 15 + 7 * (slvl - 1), + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 2, + } +} + +/** + * Skill #260: Dragon Claw + * 1.13c Parity: + * - Requires dual claws equipped (`hasLeftClaw && hasRightClaw`) + * - Enhanced Damage: 50 + 5 * (slvl - 1) + clawMasteryBlvl * 4 % + * - Attack Rating: 40 + 25 * (slvl - 1)% + * - Strikes twice (once per claw) + */ +export function evaluateDragonClawStats( + slvl: number, + clawMasteryBlvl = 0 +): { + enhancedDamagePct: number + attackRatingBonusPct: number + strikesCount: number + manaCost: number +} { + const enhancedDamagePct = 50 + 5 * (slvl - 1) + clawMasteryBlvl * 4 + const attackRatingBonusPct = 40 + 25 * (slvl - 1) + + return { + enhancedDamagePct, + attackRatingBonusPct, + strikesCount: 2, + manaCost: 2, + } +} + +/** + * Skill #265: Cobra Strike + * 1.13c Parity: + * - Base Steal%: 40 + 5 * (slvl - 1)% + * - Charge 1: Life Steal = base% + * - Charge 2: Life Steal = base%, Mana Steal = base% + * - Charge 3: Life Steal = 2 * base%, Mana Steal = 2 * base% + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames + */ +export function evaluateCobraStrikeStats(slvl: number): { + baseStealPct: number + charge1LifeStealPct: number + charge2LifeStealPct: number + charge2ManaStealPct: number + charge3LifeStealPct: number + charge3ManaStealPct: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const baseStealPct = 40 + 5 * (slvl - 1) + + return { + baseStealPct, + charge1LifeStealPct: baseStealPct, + charge2LifeStealPct: baseStealPct, + charge2ManaStealPct: baseStealPct, + charge3LifeStealPct: baseStealPct * 2, + charge3ManaStealPct: baseStealPct * 2, + attackRatingBonusPct: 15 + 7 * (slvl - 1), + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 2, + } +} + +/** + * Skill #269: Claws of Thunder + * 1.13c Parity: + * - Lightning damage: 1 base min, max scales +8, +16, +24, +32, +40 (1-80 at slvl 1) + * - Charge 2 adds Nova, Charge 3 adds 4 Charged Bolts + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames + */ +export function evaluateClawsOfThunderStats(slvl: number): { + minDamage: number + maxDamage: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const minDamage = 1 + const maxDamage = compute5BandScaling(slvl, 80, 8, 16, 24, 32, 40) + + return { + minDamage, + maxDamage, + attackRatingBonusPct: 15 + 7 * (slvl - 1), + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 4, + } +} + +/** + * Skill #270: Dragon Tail + * 1.13c Parity: + * - Physical damage dealt converted to Fire Explosion: 50 + 10 * (slvl - 1)% + * - Attack Rating: 20 + 15 * (slvl - 1)% + * - Knockback on impact + * - Explosion radius: 4 yards (6 subtiles) + */ +export function evaluateDragonTailStats(slvl: number): { + fireExplosionPct: number + attackRatingBonusPct: number + radiusSubtiles: number + manaCost: number +} { + return { + fireExplosionPct: 50 + 10 * (slvl - 1), + attackRatingBonusPct: 20 + 15 * (slvl - 1), + radiusSubtiles: 6, + manaCost: 10, + } +} + +/** + * Skill #274: Blades of Ice + * 1.13c Parity: + * - Cold damage: 15-35 base, scales with level + * - Freeze duration: 6 + 3 * (slvl - 1) seconds = (150 + 75 * (slvl - 1)) frames + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames + */ +export function evaluateBladesOfIceStats(slvl: number): { + minDamage: number + maxDamage: number + freezeDurationFrames: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const minDamage = compute5BandScaling(slvl, 15, 6, 10, 14, 18, 22) + const maxDamage = compute5BandScaling(slvl, 35, 7, 11, 15, 19, 23) + const freezeDurationFrames = 150 + 75 * (slvl - 1) + + return { + minDamage, + maxDamage, + freezeDurationFrames, + attackRatingBonusPct: 15 + 7 * (slvl - 1), + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 3, + } +} + +/** + * Skill #275: Dragon Flight + * 1.13c Parity: + * - Teleport kick to target unit + * - Enhanced Damage: 100 + 25 * (slvl - 1)% + * - Attack Rating: 60 + 25 * (slvl - 1)% + * - NextDelay: 4 frames + */ +export function evaluateDragonFlightStats(slvl: number): { + enhancedDamagePct: number + attackRatingBonusPct: number + nextDelayFrames: number + manaCost: number +} { + return { + enhancedDamagePct: 100 + 25 * (slvl - 1), + attackRatingBonusPct: 60 + 25 * (slvl - 1), + nextDelayFrames: 4, + manaCost: 15, + } +} + +/** + * Skill #280: Royal Strike / Phoenix Strike + * 1.13c Parity: + * - Tier 1: Meteor (Fire damage) + * - Tier 2: Chain Lightning (arcs through 10 targets) + * - Tier 3: Chaos Ice (16 chaos ice bolts piercing & freezing) + * - Attack Rating: 15 + 7 * (slvl - 1)% + * - Duration: 375 frames + */ +export function evaluatePhoenixStrikeStats(slvl: number): { + meteorMinDamage: number + meteorMaxDamage: number + chainLightningMaxDamage: number + chainLightningTargets: number + chaosIceMinDamage: number + chaosIceMaxDamage: number + freezeDurationFrames: number + attackRatingBonusPct: number + durationFrames: number + manaCost: number +} { + const meteorMinDamage = compute5BandScaling(slvl, 20, 10, 20, 30, 40, 50) + const meteorMaxDamage = compute5BandScaling(slvl, 40, 12, 24, 36, 48, 60) + + const chainLightningMaxDamage = compute5BandScaling(slvl, 90, 15, 25, 35, 45, 55) + const chainLightningTargets = 10 + + const chaosIceMinDamage = compute5BandScaling(slvl, 16, 6, 12, 18, 24, 30) + const chaosIceMaxDamage = compute5BandScaling(slvl, 32, 8, 16, 24, 32, 40) + const freezeDurationFrames = 100 + 25 * (slvl - 1) + + return { + meteorMinDamage, + meteorMaxDamage, + chainLightningMaxDamage, + chainLightningTargets, + chaosIceMinDamage, + chaosIceMaxDamage, + freezeDurationFrames, + attackRatingBonusPct: 15 + 7 * (slvl - 1), + durationFrames: CHARGE_DURATION_FRAMES, + manaCost: 4, + } +} + +/** + * Dual claw eligibility check for Dragon Claw (#260). + * Requires claw weapons in both hands. + */ +export function canCastDragonClaw(hasLeftClaw: boolean, hasRightClaw: boolean): boolean { + return hasLeftClaw && hasRightClaw +} diff --git a/tests/skills/ass/adv-ass-martial-stress.test.ts b/tests/skills/ass/adv-ass-martial-stress.test.ts new file mode 100644 index 0000000..8f2a84f --- /dev/null +++ b/tests/skills/ass/adv-ass-martial-stress.test.ts @@ -0,0 +1,288 @@ +/** + * Adversarial Stress & Ground Truth Parity Test Suite — Assassin Martial Arts (10 skills) + * + * Validates 1.13c Diablo II mechanics: + * 1. Charge orb progression (1..3 tiers, 375-frame duration, refresh on hit, timeout expiry). + * 2. Multi-skill charge coexistence (simultaneous Tiger, Cobra, and Phoenix charges). + * 3. Finisher release mechanics: + * - Simultaneous full discharge of all active charge-ups. + * - Elemental charge-ups release cumulative tiers (FoF, CoT, BoI). + * - Phoenix Strike releases single targeted tier (1: Meteor, 2: Chain Lightning, 3: Chaos Ice). + * 4. Dragon Talon kick count progression: 1 + floor(slvl / 6). + * 5. Dragon Claw dual-claw equipment validation and 2-hit strike. + * 6. Dragon Tail physical-to-fire explosion conversion: 50 + 10 * (slvl - 1)%. + * 7. Dragon Flight teleport kick with 4-frame NextDelay and +100..+575% ED. + * + * Gitea Milestone: M27 (Issue #474) + */ + +import { describe, expect, it } from 'vitest' +import { + ASSASSIN_MARTIAL_SKILL_IDS, + MAX_CHARGES_PER_SKILL, + CHARGE_DURATION_FRAMES, + ChargeOrbManager, + resolveFinisherDischarge, + canCastDragonClaw, + evaluateTigerStrikeStats, + evaluateDragonTalonStats, + evaluateFistsOfFireStats, + evaluateDragonClawStats, + evaluateCobraStrikeStats, + evaluateClawsOfThunderStats, + evaluateDragonTailStats, + evaluateBladesOfIceStats, + evaluateDragonFlightStats, + evaluatePhoenixStrikeStats, +} from '../../../src/game/skills/assassin-martial-arts.ts' + +describe('Assassin Martial Arts Ground Truth & Adversarial Suite (Milestone M27)', () => { + describe('1. Charge Orb Progression & Expiration Mechanics', () => { + it('accumulates charges from 1 to 3 and caps at 3', () => { + const manager = new ChargeOrbManager() + const skillId = ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE + + // Hit 1 at frame 10 + const c1 = manager.addCharge(skillId, 20, 10) + expect(c1.previousCount).toBe(0) + expect(c1.currentCount).toBe(1) + expect(manager.getChargeLevel(skillId, 10)).toBe(1) + + // Hit 2 at frame 30 + const c2 = manager.addCharge(skillId, 20, 30) + expect(c2.previousCount).toBe(1) + expect(c2.currentCount).toBe(2) + expect(manager.getChargeLevel(skillId, 30)).toBe(2) + + // Hit 3 at frame 50 + const c3 = manager.addCharge(skillId, 20, 50) + expect(c3.previousCount).toBe(2) + expect(c3.currentCount).toBe(3) + expect(manager.getChargeLevel(skillId, 50)).toBe(3) + + // Hit 4 at frame 70 -> stays at 3, but refreshes duration + const c4 = manager.addCharge(skillId, 20, 70) + expect(c4.previousCount).toBe(3) + expect(c4.currentCount).toBe(3) + expect(manager.getChargeLevel(skillId, 70)).toBe(3) + }) + + it('enforces 375-frame (15.0s) duration expiration', () => { + const manager = new ChargeOrbManager() + const skillId = ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE + + // Add charge at frame 100 -> expires at 100 + 375 = 475 + manager.addCharge(skillId, 20, 100) + + // Valid within duration window + expect(manager.getChargeLevel(skillId, 200)).toBe(1) + expect(manager.getChargeLevel(skillId, 474)).toBe(1) + + // Expired at frame 475 + expect(manager.getChargeLevel(skillId, 475)).toBe(0) + expect(manager.getActiveCharges(475)).toHaveLength(0) + }) + + it('refreshes expiration when a charge is added before timeout', () => { + const manager = new ChargeOrbManager() + const skillId = ASSASSIN_MARTIAL_SKILL_IDS.CLAWS_OF_THUNDER + + // Charge 1 at frame 0 -> expires at 375 + manager.addCharge(skillId, 20, 0) + expect(manager.getChargeLevel(skillId, 300)).toBe(1) + + // Charge 2 at frame 350 -> expires at 350 + 375 = 725 + manager.addCharge(skillId, 20, 350) + expect(manager.getChargeLevel(skillId, 350)).toBe(2) + + // Frame 400 (> 375 original expiry) is still active! + expect(manager.getChargeLevel(skillId, 400)).toBe(2) + expect(manager.getChargeLevel(skillId, 724)).toBe(2) + + // Frame 725 is expired + expect(manager.getChargeLevel(skillId, 725)).toBe(0) + }) + + it('supports simultaneous multi-skill charge orb coexistence', () => { + const manager = new ChargeOrbManager() + + // Charge Tiger Strike to 3 + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 0) + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 10) + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 20) + + // Charge Cobra Strike to 2 + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 25) + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 35) + + // Charge Phoenix Strike to 1 + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, 20, 40) + + const active = manager.getActiveCharges(50) + expect(active).toHaveLength(3) + + expect(manager.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 50)).toBe(3) + expect(manager.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 50)).toBe(2) + expect(manager.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, 50)).toBe(1) + }) + }) + + describe('2. Finisher Release & Discharge Resolution Engine', () => { + it('discharges all active charges and clears container on finisher impact', () => { + const manager = new ChargeOrbManager() + + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 0) + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 10) + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 20) // 3 charges + + manager.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 30) // 1 charge + + const discharged = manager.dischargeAll(50) + expect(discharged).toHaveLength(2) + + // Manager is now completely empty + expect(manager.getActiveCharges(50)).toHaveLength(0) + expect(manager.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 50)).toBe(0) + expect(manager.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 50)).toBe(0) + }) + + it('resolves Tiger Strike 3-tier Enhanced Damage bonus (+1440% at slvl 20 Tier 3)', () => { + // Tier 1: 480% ED + const p1 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, count: 1, slvl: 20, expiresAtFrame: 1000 }]) + expect(p1[0]?.enhancedDamagePct).toBe(480) + + // Tier 2: 960% ED + const p2 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, count: 2, slvl: 20, expiresAtFrame: 1000 }]) + expect(p2[0]?.enhancedDamagePct).toBe(960) + + // Tier 3: 1440% ED + const p3 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, count: 3, slvl: 20, expiresAtFrame: 1000 }]) + expect(p3[0]?.enhancedDamagePct).toBe(1440) + }) + + it('resolves Cobra Strike Life and Mana steal progression', () => { + // Tier 1: Life Steal only (135% at slvl 20) + const p1 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, count: 1, slvl: 20, expiresAtFrame: 1000 }]) + expect(p1[0]?.lifeStealPct).toBe(135) + expect(p1[0]?.manaStealPct).toBe(0) + + // Tier 2: Life Steal (135%) + Mana Steal (135%) + const p2 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, count: 2, slvl: 20, expiresAtFrame: 1000 }]) + expect(p2[0]?.lifeStealPct).toBe(135) + expect(p2[0]?.manaStealPct).toBe(135) + + // Tier 3: 2x Life Steal (270%) + 2x Mana Steal (270%) + const p3 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, count: 3, slvl: 20, expiresAtFrame: 1000 }]) + expect(p3[0]?.lifeStealPct).toBe(270) + expect(p3[0]?.manaStealPct).toBe(270) + }) + + it('resolves Claws of Thunder cumulative elemental tiers (Tier 3 releases Nova + 4 Bolts)', () => { + // Tier 1: direct damage only + const p1 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.CLAWS_OF_THUNDER, count: 1, slvl: 20, expiresAtFrame: 1000 }]) + expect(p1[0]?.ltngDamage?.hasNova).toBe(false) + expect(p1[0]?.ltngDamage?.chargedBoltsCount).toBeUndefined() + + // Tier 2: direct damage + Nova + const p2 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.CLAWS_OF_THUNDER, count: 2, slvl: 20, expiresAtFrame: 1000 }]) + expect(p2[0]?.ltngDamage?.hasNova).toBe(true) + expect(p2[0]?.ltngDamage?.chargedBoltsCount).toBeUndefined() + + // Tier 3: direct damage + Nova + 4 Charged Bolts + const p3 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.CLAWS_OF_THUNDER, count: 3, slvl: 20, expiresAtFrame: 1000 }]) + expect(p3[0]?.ltngDamage?.hasNova).toBe(true) + expect(p3[0]?.ltngDamage?.chargedBoltsCount).toBe(4) + }) + + it('resolves Phoenix Strike single-tier payload selection', () => { + // Charge 1: Meteor + const p1 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 1, slvl: 20, expiresAtFrame: 1000 }]) + expect(p1[0]?.phoenixPayload?.tier).toBe(1) + expect(p1[0]?.phoenixPayload?.type).toBe('meteor') + expect(p1[0]?.phoenixPayload?.meteorDamage).toBeDefined() + expect(p1[0]?.phoenixPayload?.chainLightningDamage).toBeUndefined() + expect(p1[0]?.phoenixPayload?.chaosIceDamage).toBeUndefined() + + // Charge 2: Chain Lightning + const p2 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 2, slvl: 20, expiresAtFrame: 1000 }]) + expect(p2[0]?.phoenixPayload?.tier).toBe(2) + expect(p2[0]?.phoenixPayload?.type).toBe('chain_lightning') + expect(p2[0]?.phoenixPayload?.chainLightningDamage?.maxTargets).toBe(10) + expect(p2[0]?.phoenixPayload?.meteorDamage).toBeUndefined() + + // Charge 3: Chaos Ice Orb + const p3 = resolveFinisherDischarge([{ skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, count: 3, slvl: 20, expiresAtFrame: 1000 }]) + expect(p3[0]?.phoenixPayload?.tier).toBe(3) + expect(p3[0]?.phoenixPayload?.type).toBe('chaos_ice') + expect(p3[0]?.phoenixPayload?.chaosIceDamage?.boltsCount).toBe(16) + expect(p3[0]?.phoenixPayload?.chaosIceDamage?.freezeFrames).toBeGreaterThan(0) + }) + }) + + describe('3. Dragon Talon Multi-Kick Finisher', () => { + it('evaluates kick count scaling: 1 + floor(slvl / 6)', () => { + expect(evaluateDragonTalonStats(1).kickCount).toBe(1) + expect(evaluateDragonTalonStats(5).kickCount).toBe(1) + expect(evaluateDragonTalonStats(6).kickCount).toBe(2) + expect(evaluateDragonTalonStats(11).kickCount).toBe(2) + expect(evaluateDragonTalonStats(12).kickCount).toBe(3) + expect(evaluateDragonTalonStats(18).kickCount).toBe(4) + expect(evaluateDragonTalonStats(24).kickCount).toBe(5) + expect(evaluateDragonTalonStats(30).kickCount).toBe(6) + }) + + it('evaluates Attack Rating progression: 20 + 35 * (slvl - 1)%', () => { + expect(evaluateDragonTalonStats(1).attackRatingBonusPct).toBe(20) + expect(evaluateDragonTalonStats(10).attackRatingBonusPct).toBe(335) + expect(evaluateDragonTalonStats(20).attackRatingBonusPct).toBe(685) + }) + }) + + describe('4. Dragon Claw Dual-Claw Finisher', () => { + it('enforces dual-claw equipment requirement', () => { + expect(canCastDragonClaw(false, false)).toBe(false) + expect(canCastDragonClaw(true, false)).toBe(false) + expect(canCastDragonClaw(false, true)).toBe(false) + expect(canCastDragonClaw(true, true)).toBe(true) + }) + + it('evaluates 2-strike count, ED% and Claw Mastery synergy', () => { + const s0 = evaluateDragonClawStats(20, 0) + expect(s0.strikesCount).toBe(2) + // 50 + 5 * 19 = 145% ED + expect(s0.enhancedDamagePct).toBe(145) + expect(s0.attackRatingBonusPct).toBe(515) // 40 + 25 * 19 = 515% + + // With Claw Mastery blvl 20: +80% ED (4% per blvl) + const s20 = evaluateDragonClawStats(20, 20) + expect(s20.enhancedDamagePct).toBe(145 + 80) + }) + }) + + describe('5. Dragon Tail Explosive Kick Finisher', () => { + it('evaluates physical-to-fire conversion: 50 + 10 * (slvl - 1)%', () => { + expect(evaluateDragonTailStats(1).fireExplosionPct).toBe(50) + expect(evaluateDragonTailStats(10).fireExplosionPct).toBe(140) + expect(evaluateDragonTailStats(20).fireExplosionPct).toBe(240) + }) + + it('applies 6 subtiles (4 yards) explosion radius', () => { + expect(evaluateDragonTailStats(20).radiusSubtiles).toBe(6) + }) + }) + + describe('6. Dragon Flight Teleport Kick Finisher', () => { + it('evaluates teleport kick ED progression: 100 + 25 * (slvl - 1)% and NextDelay', () => { + const df1 = evaluateDragonFlightStats(1) + expect(df1.enhancedDamagePct).toBe(100) + expect(df1.attackRatingBonusPct).toBe(60) + expect(df1.nextDelayFrames).toBe(4) + + const df20 = evaluateDragonFlightStats(20) + expect(df20.enhancedDamagePct).toBe(575) // 100 + 25 * 19 = 575% + expect(df20.attackRatingBonusPct).toBe(535) // 60 + 25 * 19 = 535% + expect(df20.nextDelayFrames).toBe(4) + expect(df20.manaCost).toBe(15) + }) + }) +})