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:
parent
880b196c43
commit
3e85a8059d
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue