diablo2-web/tests/skills/ass/adv-ass-martial-stress.test.ts

289 lines
13 KiB
TypeScript

/**
* 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)
})
})
})