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.
This commit is contained in:
troytt 2026-09-25 03:38:21 +00:00
parent 880b196c43
commit 3e85a8059d
2 changed files with 859 additions and 0 deletions

View File

@ -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<number, ActiveChargeOrb>()
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
}

View File

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