feat(dru): Milestone M23 Druid Shape Shifting skills, form states, charge orbs, contagion, and stress suite (closes #470)
This commit is contained in:
parent
5eded536ef
commit
0c5f5a88ab
|
|
@ -0,0 +1,614 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Druid Shape Shifting Skills Module
|
||||
*
|
||||
* Dedicated pure calculation, transformation states, charge-up orb progression,
|
||||
* contagious spread, and kinematics formulas for the 10 Druid Shape Shifting Tree Skills:
|
||||
* - Skill 223: Werewolf (ReqLevel 1)
|
||||
* - Skill 224: Lycanthropy (ReqLevel 1)
|
||||
* - Skill 228: Werebear (ReqLevel 6)
|
||||
* - Skill 232: Feral Rage (ReqLevel 12)
|
||||
* - Skill 233: Maul (ReqLevel 12)
|
||||
* - Skill 238: Rabies (ReqLevel 18)
|
||||
* - Skill 239: Fire Claws (ReqLevel 18)
|
||||
* - Skill 242: Hunger (ReqLevel 24)
|
||||
* - Skill 243: Shock Wave (ReqLevel 24)
|
||||
* - Skill 248: Fury (ReqLevel 30)
|
||||
*
|
||||
* 1.13c Ground Truth:
|
||||
* - Skills.txt, Missiles.txt, States.txt, Overlay.txt (Patch_D2.mpq)
|
||||
* - Werewolf: attack rate dm(10, 80, slvl), attack rating, stamina, life, Lycanthropy duration
|
||||
* - Lycanthropy: passive +20s/slvl shift duration, +20% base + 5%/slvl max HP
|
||||
* - Werebear: enhanced physical damage 55% + 8%/slvl, armor bonus 25% + 6%/slvl, max HP 75% + Lycanthropy
|
||||
* - Feral Rage: 3-stage charge orb, speed bonus dm(10, 70, slvl), life leech 4% * slvl
|
||||
* - Maul: 5-stage charge orb, damage bonus 20% * slvl per charge, stun dm(10, 100, slvl)
|
||||
* - Rabies: infectious poison bite spreading to adjacent targets within 4 subtiles
|
||||
* - Fire Claws: massive elemental fire melee strike synergized by 4 elemental spells (+22%/blvl)
|
||||
* - Hunger: dual life & mana leech dm(50, 200, slvl) with -75% damage penalty
|
||||
* - Shock Wave: bear-only stun cone ln12 stun duration (40 frames + 15/slvl)
|
||||
* - Fury: 5-hit uninterrupted feral frenzy sequence (min(2 + slvl - 1, 5) hits)
|
||||
* - Zero runtime MPQ reading
|
||||
*/
|
||||
|
||||
import { compute5BandScaling, computeDiminishingReturns } from '../engine/calc-ast.ts'
|
||||
|
||||
// --- Skill IDs ---
|
||||
|
||||
export const DRUID_SHAPESHIFT_SKILL_IDS = {
|
||||
WEREWOLF: 223,
|
||||
LYCANTHROPY: 224,
|
||||
WEREBEAR: 228,
|
||||
FERAL_RAGE: 232,
|
||||
MAUL: 233,
|
||||
RABIES: 238,
|
||||
FIRE_CLAWS: 239,
|
||||
HUNGER: 242,
|
||||
SHOCK_WAVE: 243,
|
||||
FURY: 248,
|
||||
} as const
|
||||
|
||||
// --- Interfaces for Skill Calculations ---
|
||||
|
||||
export interface WerewolfStats {
|
||||
readonly attackRateBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly staminaBonusPct: number
|
||||
readonly maxHpBonusPct: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly stateName: string
|
||||
}
|
||||
|
||||
export interface LycanthropyStats {
|
||||
readonly durationBonusFrames: number
|
||||
readonly durationBonusSeconds: number
|
||||
readonly maxHpBonusPct: number
|
||||
readonly isPassive: boolean
|
||||
}
|
||||
|
||||
export interface WerebearStats {
|
||||
readonly damageBonusPct: number
|
||||
readonly defenseBonusPct: number
|
||||
readonly maxHpBonusPct: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly cooldownFrames: number
|
||||
readonly cooldownSeconds: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly stateName: string
|
||||
}
|
||||
|
||||
export interface FeralRageStats {
|
||||
readonly damageBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly lifeLeechPct: number
|
||||
readonly velocityBonusPct: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly maxCharges: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly stateName: string
|
||||
readonly overlayName: string
|
||||
}
|
||||
|
||||
export interface MaulStats {
|
||||
readonly damageBonusPerChargePct: number
|
||||
readonly maxDamageBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly stunDurationFrames: number
|
||||
readonly stunDurationSeconds: number
|
||||
readonly durationFrames: number
|
||||
readonly durationSeconds: number
|
||||
readonly maxCharges: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly stateName: string
|
||||
readonly overlayName: string
|
||||
}
|
||||
|
||||
export interface RabiesStats {
|
||||
readonly minPoisonDamage: number
|
||||
readonly maxPoisonDamage: number
|
||||
readonly poisonDurationFrames: number
|
||||
readonly poisonDurationSeconds: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly contagionRadiusSubtiles: number
|
||||
readonly contagionSpreadIntervalFrames: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly overlayName: string
|
||||
}
|
||||
|
||||
export interface FireClawsStats {
|
||||
readonly minFireDamage: number
|
||||
readonly maxFireDamage: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface HungerStats {
|
||||
readonly damagePenaltyPct: number
|
||||
readonly lifeLeechPct: number
|
||||
readonly manaLeechPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
export interface ShockWaveStats {
|
||||
readonly minPhysicalDamage: number
|
||||
readonly maxPhysicalDamage: number
|
||||
readonly stunDurationFrames: number
|
||||
readonly stunDurationSeconds: number
|
||||
readonly synergyBonusPct: number
|
||||
readonly cooldownFrames: number
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
readonly missileName: string
|
||||
}
|
||||
|
||||
export interface FuryStats {
|
||||
readonly damageBonusPct: number
|
||||
readonly toHitBonusPct: number
|
||||
readonly attackCount: number
|
||||
readonly isUninterruptible: boolean
|
||||
readonly manaCost: number
|
||||
readonly manaCost256: number
|
||||
}
|
||||
|
||||
// --- Pure Calculation Functions ---
|
||||
|
||||
/**
|
||||
* Skill 223: Werewolf (Wearwolf)
|
||||
* Ground Truth:
|
||||
* - Attack Rate bonus: dm(10, 80, slvl)
|
||||
* - ToHit: 50 + (slvl - 1) * 15
|
||||
* - Stamina: 25% (constant)
|
||||
* - Max HP: 25% base + Lycanthropy.ln34 (20 + (lycBlvl - 1) * 5)%
|
||||
* - Duration: 1000 frames (40s) + Lycanthropy.ln12 (1000 + (lycBlvl - 1) * 500 frames)
|
||||
* - Mana: 15, Cooldown: 25 frames (1.0s)
|
||||
*/
|
||||
export function calculateWerewolfStats(
|
||||
slvl: number,
|
||||
lycanthropyBlvl = 0,
|
||||
): WerewolfStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const lycPts = Math.max(0, lycanthropyBlvl)
|
||||
|
||||
const attackRateBonusPct = computeDiminishingReturns(10, 80, lvl)
|
||||
const toHitBonusPct = 50 + (lvl - 1) * 15
|
||||
|
||||
const lycHpBonus = lycPts > 0 ? 20 + (lycPts - 1) * 5 : 0
|
||||
const maxHpBonusPct = 25 + lycHpBonus
|
||||
|
||||
const lycDurationBonus = lycPts > 0 ? 1000 + (lycPts - 1) * 500 : 0
|
||||
const durationFrames = 1000 + lycDurationBonus
|
||||
|
||||
return {
|
||||
attackRateBonusPct,
|
||||
toHitBonusPct,
|
||||
staminaBonusPct: 25,
|
||||
maxHpBonusPct,
|
||||
durationFrames,
|
||||
durationSeconds: durationFrames / 25,
|
||||
cooldownFrames: 25,
|
||||
cooldownSeconds: 1.0,
|
||||
manaCost: 15,
|
||||
manaCost256: 3840,
|
||||
stateName: 'wolf',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 224: Lycanthropy (Shape Shifting)
|
||||
* Ground Truth:
|
||||
* - Passive skill
|
||||
* - Duration bonus: 1000 + (slvl - 1) * 500 frames (+20s/lvl)
|
||||
* - Max HP bonus: 20 + (slvl - 1) * 5 % (+20% base, +5%/lvl)
|
||||
*/
|
||||
export function calculateLycanthropyStats(slvl: number): LycanthropyStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const durationBonusFrames = 1000 + (lvl - 1) * 500
|
||||
const maxHpBonusPct = 20 + (lvl - 1) * 5
|
||||
|
||||
return {
|
||||
durationBonusFrames,
|
||||
durationBonusSeconds: durationBonusFrames / 25,
|
||||
maxHpBonusPct,
|
||||
isPassive: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 228: Werebear (Wearbear)
|
||||
* Ground Truth:
|
||||
* - Enhanced Damage: 55 + (slvl - 1) * 8 %
|
||||
* - Armor / Defense: 25 + (slvl - 1) * 6 %
|
||||
* - Max HP: 75% base + Lycanthropy.ln34 (20 + (lycBlvl - 1) * 5)%
|
||||
* - Duration: 1000 frames (40s) + Lycanthropy.ln12 (1000 + (lycBlvl - 1) * 500 frames)
|
||||
* - Mana: 15, Cooldown: 25 frames (1.0s)
|
||||
*/
|
||||
export function calculateWerebearStats(
|
||||
slvl: number,
|
||||
lycanthropyBlvl = 0,
|
||||
): WerebearStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const lycPts = Math.max(0, lycanthropyBlvl)
|
||||
|
||||
const damageBonusPct = 55 + (lvl - 1) * 8
|
||||
const defenseBonusPct = 25 + (lvl - 1) * 6
|
||||
|
||||
const lycHpBonus = lycPts > 0 ? 20 + (lycPts - 1) * 5 : 0
|
||||
const maxHpBonusPct = 75 + lycHpBonus
|
||||
|
||||
const lycDurationBonus = lycPts > 0 ? 1000 + (lycPts - 1) * 500 : 0
|
||||
const durationFrames = 1000 + lycDurationBonus
|
||||
|
||||
return {
|
||||
damageBonusPct,
|
||||
defenseBonusPct,
|
||||
maxHpBonusPct,
|
||||
durationFrames,
|
||||
durationSeconds: durationFrames / 25,
|
||||
cooldownFrames: 25,
|
||||
cooldownSeconds: 1.0,
|
||||
manaCost: 15,
|
||||
manaCost256: 3840,
|
||||
stateName: 'bear',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 232: Feral Rage
|
||||
* Ground Truth:
|
||||
* - Wolf-only attack
|
||||
* - Damage: 50 + (slvl - 1) * 5 %
|
||||
* - ToHit: 20 + (slvl - 1) * 10 %
|
||||
* - Life Leech: 4 * slvl % (accumulates with charge levels 1..3)
|
||||
* - Velocity: dm(10, 70, slvl) %
|
||||
* - Duration: 500 frames (20.0s)
|
||||
* - Mana: 3
|
||||
*/
|
||||
export function calculateFeralRageStats(slvl: number): FeralRageStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const damageBonusPct = 50 + (lvl - 1) * 5
|
||||
const toHitBonusPct = 20 + (lvl - 1) * 10
|
||||
const lifeLeechPct = 4 * lvl
|
||||
const velocityBonusPct = computeDiminishingReturns(10, 70, lvl)
|
||||
|
||||
return {
|
||||
damageBonusPct,
|
||||
toHitBonusPct,
|
||||
lifeLeechPct,
|
||||
velocityBonusPct,
|
||||
durationFrames: 500,
|
||||
durationSeconds: 20.0,
|
||||
maxCharges: 3,
|
||||
manaCost: 3,
|
||||
manaCost256: 768,
|
||||
stateName: 'feralrage',
|
||||
overlayName: 'feralrage1front',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 233: Maul
|
||||
* Ground Truth:
|
||||
* - Bear-only attack
|
||||
* - Damage bonus per charge: slvl * 20 % (up to 5 charges = slvl * 100 %)
|
||||
* - ToHit: 20 + (slvl - 1) * 10 %
|
||||
* - Stun duration: dm(10, 100, slvl) frames
|
||||
* - Duration: 500 frames (20.0s)
|
||||
* - Mana: 3
|
||||
*/
|
||||
export function calculateMaulStats(slvl: number): MaulStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const damageBonusPerChargePct = lvl * 20
|
||||
const maxDamageBonusPct = lvl * 100
|
||||
const toHitBonusPct = 20 + (lvl - 1) * 10
|
||||
const stunDurationFrames = computeDiminishingReturns(10, 100, lvl)
|
||||
|
||||
return {
|
||||
damageBonusPerChargePct,
|
||||
maxDamageBonusPct,
|
||||
toHitBonusPct,
|
||||
stunDurationFrames,
|
||||
stunDurationSeconds: Number((stunDurationFrames / 25).toFixed(2)),
|
||||
durationFrames: 500,
|
||||
durationSeconds: 20.0,
|
||||
maxCharges: 5,
|
||||
manaCost: 3,
|
||||
manaCost256: 768,
|
||||
stateName: 'maul',
|
||||
overlayName: 'maul1front',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 238: Rabies
|
||||
* Ground Truth:
|
||||
* - Wolf-only infectious bite
|
||||
* - Poison damage: eMin=6 [4, 5, 7, 11, 16], eMax=14 [4, 5, 7, 11, 16]
|
||||
* - Poison length: 100 + (slvl - 1) * 10 frames (4s + 0.4s/lvl)
|
||||
* - ToHit: 50 + (slvl - 1) * 7 %
|
||||
* - Synergy: +18% poison damage per blvl in Plague Poppy (Poison Creeper)
|
||||
* - Contagion radius: 4 subtiles, spread cadence: every 4 frames
|
||||
* - Mana: 10
|
||||
*/
|
||||
export function calculateRabiesStats(
|
||||
slvl: number,
|
||||
plaguePoppyBlvl = 0,
|
||||
): RabiesStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const ppPts = Math.max(0, plaguePoppyBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 6, 4, 5, 7, 11, 16)
|
||||
const rawMax = compute5BandScaling(lvl, 14, 4, 5, 7, 11, 16)
|
||||
const synergyBonusPct = ppPts * 18
|
||||
|
||||
const minPoisonDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxPoisonDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
const poisonDurationFrames = 100 + (lvl - 1) * 10
|
||||
|
||||
return {
|
||||
minPoisonDamage,
|
||||
maxPoisonDamage,
|
||||
poisonDurationFrames,
|
||||
poisonDurationSeconds: poisonDurationFrames / 25,
|
||||
toHitBonusPct: 50 + (lvl - 1) * 7,
|
||||
contagionRadiusSubtiles: 4,
|
||||
contagionSpreadIntervalFrames: 4,
|
||||
synergyBonusPct,
|
||||
manaCost: 10,
|
||||
manaCost256: 2560,
|
||||
overlayName: 'rabiesplague',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 239: Fire Claws
|
||||
* Ground Truth:
|
||||
* - Usable in Wolf or Bear form
|
||||
* - Fire damage: eMin=15 [8, 12, 20, 24, 30], eMax=20 [8, 12, 22, 26, 34]
|
||||
* - ToHit: 50 + (slvl - 1) * 15 %
|
||||
* - Synergies: +22% fire damage per blvl in Firestorm, Molten Boulder, Volcano, Eruption
|
||||
* - Mana: 4
|
||||
*/
|
||||
export function calculateFireClawsStats(
|
||||
slvl: number,
|
||||
firestormBlvl = 0,
|
||||
moltenBoulderBlvl = 0,
|
||||
volcanoBlvl = 0,
|
||||
eruptionBlvl = 0,
|
||||
): FireClawsStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const synTotal =
|
||||
Math.max(0, firestormBlvl) +
|
||||
Math.max(0, moltenBoulderBlvl) +
|
||||
Math.max(0, volcanoBlvl) +
|
||||
Math.max(0, eruptionBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 15, 8, 12, 20, 24, 30)
|
||||
const rawMax = compute5BandScaling(lvl, 20, 8, 12, 22, 26, 34)
|
||||
const synergyBonusPct = synTotal * 22
|
||||
|
||||
const minFireDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxFireDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
|
||||
return {
|
||||
minFireDamage,
|
||||
maxFireDamage,
|
||||
toHitBonusPct: 50 + (lvl - 1) * 15,
|
||||
synergyBonusPct,
|
||||
manaCost: 4,
|
||||
manaCost256: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 242: Hunger
|
||||
* Ground Truth:
|
||||
* - Usable in Wolf or Bear form
|
||||
* - Damage penalty: -75% weapon damage
|
||||
* - Life Leech: dm(50, 200, slvl) %
|
||||
* - Mana Leech: dm(50, 200, slvl) %
|
||||
* - ToHit: 50 + (slvl - 1) * 10 %
|
||||
* - Mana: 3
|
||||
*/
|
||||
export function calculateHungerStats(slvl: number): HungerStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const leechPct = computeDiminishingReturns(50, 200, lvl)
|
||||
|
||||
return {
|
||||
damagePenaltyPct: -75,
|
||||
lifeLeechPct: leechPct,
|
||||
manaLeechPct: leechPct,
|
||||
toHitBonusPct: 50 + (lvl - 1) * 10,
|
||||
manaCost: 3,
|
||||
manaCost256: 768,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 243: Shock Wave
|
||||
* Ground Truth:
|
||||
* - Bear-only frontal sonic roar
|
||||
* - Phys damage: minDam=10 [3, 5, 7, 7, 7], maxDam=20 [3, 5, 7, 7, 7]
|
||||
* - Stun duration: ln12 = 40 + (slvl - 1) * 15 frames (1.6s at slvl 1, +0.6s/lvl)
|
||||
* - Synergy: +5% physical damage per blvl in Maul
|
||||
* - Mana: 7
|
||||
*/
|
||||
export function calculateShockWaveStats(
|
||||
slvl: number,
|
||||
maulBlvl = 0,
|
||||
): ShockWaveStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const maulPts = Math.max(0, maulBlvl)
|
||||
|
||||
const rawMin = compute5BandScaling(lvl, 10, 3, 5, 7, 7, 7)
|
||||
const rawMax = compute5BandScaling(lvl, 20, 3, 5, 7, 7, 7)
|
||||
const synergyBonusPct = maulPts * 5
|
||||
|
||||
const minPhysicalDamage = Math.trunc((rawMin * (100 + synergyBonusPct)) / 100)
|
||||
const maxPhysicalDamage = Math.trunc((rawMax * (100 + synergyBonusPct)) / 100)
|
||||
const stunDurationFrames = 40 + (lvl - 1) * 15
|
||||
|
||||
return {
|
||||
minPhysicalDamage,
|
||||
maxPhysicalDamage,
|
||||
stunDurationFrames,
|
||||
stunDurationSeconds: stunDurationFrames / 25,
|
||||
synergyBonusPct,
|
||||
cooldownFrames: 0,
|
||||
manaCost: 7,
|
||||
manaCost256: 1792,
|
||||
missileName: 'shockwave',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill 248: Fury
|
||||
* Ground Truth:
|
||||
* - Wolf-only rapid multi-attack sequence
|
||||
* - Enhanced Damage: 100 + (slvl - 1) * 17 %
|
||||
* - ToHit: 50 + (slvl - 1) * 7 %
|
||||
* - Attack count: min(2 + slvl - 1, 5) hits
|
||||
* - Uninterruptible sequence
|
||||
* - Mana: 4
|
||||
*/
|
||||
export function calculateFuryStats(slvl: number): FuryStats {
|
||||
const lvl = Math.max(1, slvl)
|
||||
const damageBonusPct = 100 + (lvl - 1) * 17
|
||||
const toHitBonusPct = 50 + (lvl - 1) * 7
|
||||
const attackCount = Math.min(2 + lvl - 1, 5)
|
||||
|
||||
return {
|
||||
damageBonusPct,
|
||||
toHitBonusPct,
|
||||
attackCount,
|
||||
isUninterruptible: true,
|
||||
manaCost: 4,
|
||||
manaCost256: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Kinematics & Mechanics Simulation Helpers ---
|
||||
|
||||
/**
|
||||
* Validates whether a given skill can be executed in the current Druid form.
|
||||
*/
|
||||
export function canCastShapeShiftSkill(
|
||||
currentForm: 'human' | 'wolf' | 'bear',
|
||||
skillId: number,
|
||||
): boolean {
|
||||
switch (skillId) {
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF:
|
||||
// Can cast if human (to transform into wolf) or wolf (to revert to human)
|
||||
return currentForm === 'human' || currentForm === 'wolf'
|
||||
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR:
|
||||
// Can cast if human (to transform into bear) or bear (to revert to human)
|
||||
return currentForm === 'human' || currentForm === 'bear'
|
||||
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.FERAL_RAGE:
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.RABIES:
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.FURY:
|
||||
// Strictly Werewolf only
|
||||
return currentForm === 'wolf'
|
||||
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.MAUL:
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.SHOCK_WAVE:
|
||||
// Strictly Werebear only
|
||||
return currentForm === 'bear'
|
||||
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.FIRE_CLAWS:
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.HUNGER:
|
||||
// Can be used in either beast form, but not human
|
||||
return currentForm === 'wolf' || currentForm === 'bear'
|
||||
|
||||
case DRUID_SHAPESHIFT_SKILL_IDS.LYCANTHROPY:
|
||||
// Passive
|
||||
return true
|
||||
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance charge-up orb counter on successful melee connection (Maul, Feral Rage).
|
||||
*/
|
||||
export function advanceChargeOrb(currentCharge: number, maxCharge: number): number {
|
||||
return Math.min(Math.max(0, currentCharge) + 1, maxCharge)
|
||||
}
|
||||
|
||||
export interface ContagionTarget {
|
||||
readonly id: string
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate Rabies contagious spread between adjacent infected and healthy targets.
|
||||
* Spread occurs within contagionRadiusSubtiles (default 4 subtiles = ~128px in world units).
|
||||
*/
|
||||
export function simulateRabiesContagion(
|
||||
infectedTargets: readonly ContagionTarget[],
|
||||
healthyTargets: readonly ContagionTarget[],
|
||||
radiusPx = 128,
|
||||
): string[] {
|
||||
const newlyInfectedIds: string[] = []
|
||||
|
||||
for (const healthy of healthyTargets) {
|
||||
let infected = false
|
||||
for (const source of infectedTargets) {
|
||||
const dx = healthy.x - source.x
|
||||
const dy = (healthy.y - source.y) * 2 // 2:1 isometric space
|
||||
const distSq = dx * dx + dy * dy
|
||||
if (distSq <= radiusPx * radiusPx) {
|
||||
infected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (infected) {
|
||||
newlyInfectedIds.push(healthy.id)
|
||||
}
|
||||
}
|
||||
|
||||
return newlyInfectedIds
|
||||
}
|
||||
|
||||
export interface FuryStrikeDistribution {
|
||||
readonly targetId: string
|
||||
readonly hitIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute Fury multi-hit strike sequence across nearby target enemies.
|
||||
* If 1 target, all strikes focus on that target. If multiple targets, alternates between them.
|
||||
*/
|
||||
export function distributeFuryStrikes(
|
||||
targetIds: readonly string[],
|
||||
totalHits: number,
|
||||
): FuryStrikeDistribution[] {
|
||||
if (targetIds.length === 0 || totalHits <= 0) return []
|
||||
|
||||
const strikes: FuryStrikeDistribution[] = []
|
||||
for (let i = 0; i < totalHits; i++) {
|
||||
const targetId = targetIds[i % targetIds.length]!
|
||||
strikes.push({
|
||||
targetId,
|
||||
hitIndex: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return strikes
|
||||
}
|
||||
|
|
@ -0,0 +1,588 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calculateWerewolfStats,
|
||||
calculateLycanthropyStats,
|
||||
calculateWerebearStats,
|
||||
calculateFeralRageStats,
|
||||
calculateMaulStats,
|
||||
calculateRabiesStats,
|
||||
calculateFireClawsStats,
|
||||
calculateHungerStats,
|
||||
calculateShockWaveStats,
|
||||
calculateFuryStats,
|
||||
canCastShapeShiftSkill,
|
||||
advanceChargeOrb,
|
||||
simulateRabiesContagion,
|
||||
distributeFuryStrikes,
|
||||
DRUID_SHAPESHIFT_SKILL_IDS,
|
||||
} from '../../../src/game/skills/druid-shapeshift.ts'
|
||||
import { BATCH1_SKILLS } from '../../../src/game/skills.ts'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { evaluateSkill113c } from '../../../src/game/skills/registry.ts'
|
||||
|
||||
describe('Milestone M23 — Druid Shape Shifting Skills Parity & Adversarial Stress Suite', () => {
|
||||
// =========================================================================
|
||||
// Dimension 1: Exact 1.13c Numerical Scaling & Formulas
|
||||
// =========================================================================
|
||||
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
|
||||
it('1.1 Werewolf (223): Evaluates attack rate diminishing returns, stamina, and Lycanthropy integration', () => {
|
||||
// slvl 1 without Lycanthropy
|
||||
const w1 = calculateWerewolfStats(1, 0)
|
||||
expect(w1.attackRateBonusPct).toBe(21) // 10 + floor(110*1*70 / 700) = 21%
|
||||
expect(w1.toHitBonusPct).toBe(50)
|
||||
expect(w1.staminaBonusPct).toBe(25)
|
||||
expect(w1.maxHpBonusPct).toBe(25) // 25 base
|
||||
expect(w1.durationFrames).toBe(1000) // 40s base
|
||||
expect(w1.durationSeconds).toBe(40.0)
|
||||
expect(w1.cooldownFrames).toBe(25)
|
||||
expect(w1.manaCost).toBe(15)
|
||||
expect(w1.manaCost256).toBe(3840)
|
||||
|
||||
// slvl 10 with slvl 10 Lycanthropy
|
||||
// attack rate: 10 + floor(110*10*70 / 1600) = 10 + 48 = 58%
|
||||
// toHit: 50 + 9*15 = 185%
|
||||
// lycHp: 20 + 9*5 = 65% -> total HP = 25 + 65 = 90%
|
||||
// lycDuration: 1000 + 9*500 = 5500 -> total duration = 1000 + 5500 = 6500 frames (260s)
|
||||
const w10 = calculateWerewolfStats(10, 10)
|
||||
expect(w10.attackRateBonusPct).toBe(58)
|
||||
expect(w10.toHitBonusPct).toBe(185)
|
||||
expect(w10.maxHpBonusPct).toBe(90)
|
||||
expect(w10.durationFrames).toBe(6500)
|
||||
expect(w10.durationSeconds).toBe(260.0)
|
||||
|
||||
// slvl 20 with slvl 20 Lycanthropy
|
||||
// attack rate: 10 + floor(110*20*70 / 2600) = 10 + 59 = 69%
|
||||
// toHit: 50 + 19*15 = 335%
|
||||
// lycHp: 20 + 19*5 = 115% -> total HP = 25 + 115 = 140%
|
||||
// lycDuration: 1000 + 19*500 = 10500 -> total duration = 1000 + 10500 = 11500 frames (460s)
|
||||
const w20 = calculateWerewolfStats(20, 20)
|
||||
expect(w20.attackRateBonusPct).toBe(69)
|
||||
expect(w20.toHitBonusPct).toBe(335)
|
||||
expect(w20.maxHpBonusPct).toBe(140)
|
||||
expect(w20.durationFrames).toBe(11500)
|
||||
expect(w20.durationSeconds).toBe(460.0)
|
||||
})
|
||||
|
||||
it('1.2 Lycanthropy (224): Evaluates passive form duration bonus and maximum HP progression', () => {
|
||||
// slvl 1
|
||||
const lyc1 = calculateLycanthropyStats(1)
|
||||
expect(lyc1.durationBonusFrames).toBe(1000) // +40s
|
||||
expect(lyc1.durationBonusSeconds).toBe(40.0)
|
||||
expect(lyc1.maxHpBonusPct).toBe(20)
|
||||
expect(lyc1.isPassive).toBe(true)
|
||||
|
||||
// slvl 10
|
||||
const lyc10 = calculateLycanthropyStats(10)
|
||||
expect(lyc10.durationBonusFrames).toBe(1000 + 9 * 500) // 5500 frames (+220s)
|
||||
expect(lyc10.durationBonusSeconds).toBe(220.0)
|
||||
expect(lyc10.maxHpBonusPct).toBe(20 + 9 * 5) // 65%
|
||||
|
||||
// slvl 20
|
||||
const lyc20 = calculateLycanthropyStats(20)
|
||||
expect(lyc20.durationBonusFrames).toBe(1000 + 19 * 500) // 10500 frames (+420s)
|
||||
expect(lyc20.durationBonusSeconds).toBe(420.0)
|
||||
expect(lyc20.maxHpBonusPct).toBe(20 + 19 * 5) // 115%
|
||||
})
|
||||
|
||||
it('1.3 Werebear (228): Evaluates massive defense, enhanced damage, and life scaling', () => {
|
||||
// slvl 1 without Lycanthropy
|
||||
const wb1 = calculateWerebearStats(1, 0)
|
||||
expect(wb1.damageBonusPct).toBe(55)
|
||||
expect(wb1.defenseBonusPct).toBe(25)
|
||||
expect(wb1.maxHpBonusPct).toBe(75) // 75 base
|
||||
expect(wb1.durationFrames).toBe(1000)
|
||||
expect(wb1.durationSeconds).toBe(40.0)
|
||||
expect(wb1.cooldownFrames).toBe(25)
|
||||
expect(wb1.manaCost).toBe(15)
|
||||
|
||||
// slvl 10 with slvl 10 Lycanthropy
|
||||
// damage: 55 + 9*8 = 127%
|
||||
// defense: 25 + 9*6 = 79%
|
||||
// hp: 75 + (20 + 9*5) = 75 + 65 = 140%
|
||||
const wb10 = calculateWerebearStats(10, 10)
|
||||
expect(wb10.damageBonusPct).toBe(127)
|
||||
expect(wb10.defenseBonusPct).toBe(79)
|
||||
expect(wb10.maxHpBonusPct).toBe(140)
|
||||
expect(wb10.durationFrames).toBe(6500)
|
||||
|
||||
// slvl 20 with slvl 20 Lycanthropy
|
||||
// damage: 55 + 19*8 = 207%
|
||||
// defense: 25 + 19*6 = 139%
|
||||
// hp: 75 + (20 + 19*5) = 75 + 115 = 190%
|
||||
const wb20 = calculateWerebearStats(20, 20)
|
||||
expect(wb20.damageBonusPct).toBe(207)
|
||||
expect(wb20.defenseBonusPct).toBe(139)
|
||||
expect(wb20.maxHpBonusPct).toBe(190)
|
||||
expect(wb20.durationFrames).toBe(11500)
|
||||
})
|
||||
|
||||
it('1.4 Feral Rage (232): Evaluates damage, attack rating, speed bonus, and life leech', () => {
|
||||
// slvl 1
|
||||
const fr1 = calculateFeralRageStats(1)
|
||||
expect(fr1.damageBonusPct).toBe(50)
|
||||
expect(fr1.toHitBonusPct).toBe(20)
|
||||
expect(fr1.lifeLeechPct).toBe(4) // 4 * 1
|
||||
expect(fr1.velocityBonusPct).toBe(19) // 10 + floor(110*1*60 / 700) = 19%
|
||||
expect(fr1.durationFrames).toBe(500) // 20.0s
|
||||
expect(fr1.maxCharges).toBe(3)
|
||||
expect(fr1.manaCost).toBe(3)
|
||||
expect(fr1.manaCost256).toBe(768)
|
||||
|
||||
// slvl 10
|
||||
// damage: 50 + 9*5 = 95%
|
||||
// toHit: 20 + 9*10 = 110%
|
||||
// life leech: 4 * 10 = 40%
|
||||
// velocity: 10 + floor(110*10*60 / 1600) = 10 + 41 = 51%
|
||||
const fr10 = calculateFeralRageStats(10)
|
||||
expect(fr10.damageBonusPct).toBe(95)
|
||||
expect(fr10.toHitBonusPct).toBe(110)
|
||||
expect(fr10.lifeLeechPct).toBe(40)
|
||||
expect(fr10.velocityBonusPct).toBe(51)
|
||||
|
||||
// slvl 20
|
||||
// damage: 50 + 19*5 = 145%
|
||||
// toHit: 20 + 19*10 = 210%
|
||||
// life leech: 4 * 20 = 80%
|
||||
// velocity: 10 + floor(110*20*60 / 2600) = 10 + 50 = 60%
|
||||
const fr20 = calculateFeralRageStats(20)
|
||||
expect(fr20.damageBonusPct).toBe(145)
|
||||
expect(fr20.toHitBonusPct).toBe(210)
|
||||
expect(fr20.lifeLeechPct).toBe(80)
|
||||
expect(fr20.velocityBonusPct).toBe(60)
|
||||
})
|
||||
|
||||
it('1.5 Maul (233): Evaluates 5-stage charge damage and stun scaling', () => {
|
||||
// slvl 1
|
||||
const m1 = calculateMaulStats(1)
|
||||
expect(m1.damageBonusPerChargePct).toBe(20) // 1 * 20
|
||||
expect(m1.maxDamageBonusPct).toBe(100) // 5 charges = 100%
|
||||
expect(m1.toHitBonusPct).toBe(20)
|
||||
expect(m1.stunDurationFrames).toBe(24) // 10 + floor(110*1*90 / 700) = 24 frames (~0.96s)
|
||||
expect(m1.maxCharges).toBe(5)
|
||||
expect(m1.manaCost).toBe(3)
|
||||
|
||||
// slvl 10
|
||||
// damage/charge: 10 * 20 = 200%, max 5 charges = 1000%
|
||||
// toHit: 20 + 9*10 = 110%
|
||||
// stun: 10 + floor(110*10*90 / 1600) = 10 + 61 = 71 frames (2.84s)
|
||||
const m10 = calculateMaulStats(10)
|
||||
expect(m10.damageBonusPerChargePct).toBe(200)
|
||||
expect(m10.maxDamageBonusPct).toBe(1000)
|
||||
expect(m10.toHitBonusPct).toBe(110)
|
||||
expect(m10.stunDurationFrames).toBe(71)
|
||||
|
||||
// slvl 20
|
||||
// damage/charge: 20 * 20 = 400%, max 5 charges = 2000%
|
||||
// toHit: 20 + 19*10 = 210%
|
||||
// stun: 10 + floor(110*20*90 / 2600) = 10 + 76 = 86 frames (3.44s)
|
||||
const m20 = calculateMaulStats(20)
|
||||
expect(m20.damageBonusPerChargePct).toBe(400)
|
||||
expect(m20.maxDamageBonusPct).toBe(2000)
|
||||
expect(m20.toHitBonusPct).toBe(210)
|
||||
expect(m20.stunDurationFrames).toBe(86)
|
||||
})
|
||||
|
||||
it('1.6 Rabies (238): Evaluates poison progression, duration, and Poison Creeper synergy', () => {
|
||||
// slvl 1
|
||||
const r1 = calculateRabiesStats(1, 0)
|
||||
expect(r1.minPoisonDamage).toBe(6)
|
||||
expect(r1.maxPoisonDamage).toBe(14)
|
||||
expect(r1.poisonDurationFrames).toBe(100) // 4.0s
|
||||
expect(r1.toHitBonusPct).toBe(50)
|
||||
expect(r1.contagionRadiusSubtiles).toBe(4)
|
||||
expect(r1.contagionSpreadIntervalFrames).toBe(4)
|
||||
expect(r1.manaCost).toBe(10)
|
||||
|
||||
// slvl 10: min: 6 + 7*4 + 2*5 = 44, max: 14 + 7*4 + 2*5 = 52
|
||||
// duration: 100 + 9*10 = 190 frames (7.6s)
|
||||
// toHit: 50 + 9*7 = 113%
|
||||
const r10 = calculateRabiesStats(10, 0)
|
||||
expect(r10.minPoisonDamage).toBe(44)
|
||||
expect(r10.maxPoisonDamage).toBe(52)
|
||||
expect(r10.poisonDurationFrames).toBe(190)
|
||||
expect(r10.toHitBonusPct).toBe(113)
|
||||
|
||||
// slvl 20: min: 44 + 6*5 + 4*7 = 102, max: 52 + 6*5 + 4*7 = 110
|
||||
// duration: 100 + 19*10 = 290 frames (11.6s)
|
||||
// toHit: 50 + 19*7 = 183%
|
||||
const r20 = calculateRabiesStats(20, 0)
|
||||
expect(r20.minPoisonDamage).toBe(102)
|
||||
expect(r20.maxPoisonDamage).toBe(110)
|
||||
expect(r20.poisonDurationFrames).toBe(290)
|
||||
expect(r20.toHitBonusPct).toBe(183)
|
||||
|
||||
// Synergy: 20 points in Poison Creeper (+18%/blvl = +360%)
|
||||
const r20Syn = calculateRabiesStats(20, 20)
|
||||
expect(r20Syn.synergyBonusPct).toBe(360)
|
||||
expect(r20Syn.minPoisonDamage).toBe(Math.trunc((102 * 460) / 100)) // 469
|
||||
expect(r20Syn.maxPoisonDamage).toBe(Math.trunc((110 * 460) / 100)) // 506
|
||||
})
|
||||
|
||||
it('1.7 Fire Claws (239): Evaluates elemental fire melee damage and 4-way synergy scaling', () => {
|
||||
// slvl 1
|
||||
const fc1 = calculateFireClawsStats(1, 0, 0, 0, 0)
|
||||
expect(fc1.minFireDamage).toBe(15)
|
||||
expect(fc1.maxFireDamage).toBe(20)
|
||||
expect(fc1.toHitBonusPct).toBe(50)
|
||||
expect(fc1.manaCost).toBe(4)
|
||||
|
||||
// slvl 10: min: 15 + 7*8 + 2*12 = 95, max: 20 + 7*8 + 2*12 = 100
|
||||
const fc10 = calculateFireClawsStats(10, 0, 0, 0, 0)
|
||||
expect(fc10.minFireDamage).toBe(95)
|
||||
expect(fc10.maxFireDamage).toBe(100)
|
||||
|
||||
// slvl 20: min: 95 + 6*12 + 4*20 = 247, max: 100 + 6*12 + 4*22 = 260
|
||||
const fc20 = calculateFireClawsStats(20, 0, 0, 0, 0)
|
||||
expect(fc20.minFireDamage).toBe(247)
|
||||
expect(fc20.maxFireDamage).toBe(260)
|
||||
|
||||
// Synergies: 20 in Firestorm, 20 in Molten Boulder, 20 in Volcano, 20 in Fissure
|
||||
// total points = 80 -> synergy = 80 * 22% = +1760%
|
||||
const fc20Syn = calculateFireClawsStats(20, 20, 20, 20, 20)
|
||||
expect(fc20Syn.synergyBonusPct).toBe(1760)
|
||||
expect(fc20Syn.minFireDamage).toBe(Math.trunc((247 * 1860) / 100)) // 4594
|
||||
expect(fc20Syn.maxFireDamage).toBe(Math.trunc((260 * 1860) / 100)) // 4836
|
||||
})
|
||||
|
||||
it('1.8 Hunger (242): Evaluates -75% damage penalty and dual life & mana leech', () => {
|
||||
// slvl 1
|
||||
// leech: 50 + floor(110*1*150 / 700) = 50 + 23 = 73%
|
||||
const h1 = calculateHungerStats(1)
|
||||
expect(h1.damagePenaltyPct).toBe(-75)
|
||||
expect(h1.lifeLeechPct).toBe(73)
|
||||
expect(h1.manaLeechPct).toBe(73)
|
||||
expect(h1.toHitBonusPct).toBe(50)
|
||||
expect(h1.manaCost).toBe(3)
|
||||
|
||||
// slvl 10: 50 + floor(110*10*150 / 1600) = 50 + 103 = 153%
|
||||
const h10 = calculateHungerStats(10)
|
||||
expect(h10.lifeLeechPct).toBe(153)
|
||||
expect(h10.manaLeechPct).toBe(153)
|
||||
expect(h10.toHitBonusPct).toBe(140)
|
||||
|
||||
// slvl 20: 50 + floor(110*20*150 / 2600) = 50 + 126 = 176%
|
||||
const h20 = calculateHungerStats(20)
|
||||
expect(h20.lifeLeechPct).toBe(176)
|
||||
expect(h20.manaLeechPct).toBe(176)
|
||||
expect(h20.toHitBonusPct).toBe(240)
|
||||
})
|
||||
|
||||
it('1.9 Shock Wave (243): Evaluates bear frontal sonic stun cone and Maul synergy', () => {
|
||||
// slvl 1
|
||||
const sw1 = calculateShockWaveStats(1, 0)
|
||||
expect(sw1.minPhysicalDamage).toBe(10)
|
||||
expect(sw1.maxPhysicalDamage).toBe(20)
|
||||
expect(sw1.stunDurationFrames).toBe(40) // 1.6s
|
||||
expect(sw1.stunDurationSeconds).toBe(1.6)
|
||||
expect(sw1.manaCost).toBe(7)
|
||||
expect(sw1.missileName).toBe('shockwave')
|
||||
|
||||
// slvl 10: min: 10 + 7*3 + 2*5 = 41, max: 20 + 7*3 + 2*5 = 51
|
||||
// stun: 40 + 9*15 = 175 frames (7.0s)
|
||||
const sw10 = calculateShockWaveStats(10, 0)
|
||||
expect(sw10.minPhysicalDamage).toBe(41)
|
||||
expect(sw10.maxPhysicalDamage).toBe(51)
|
||||
expect(sw10.stunDurationFrames).toBe(175)
|
||||
expect(sw10.stunDurationSeconds).toBe(7.0)
|
||||
|
||||
// slvl 20: min: 41 + 6*5 + 4*7 = 99, max: 51 + 6*5 + 4*7 = 109
|
||||
// stun: 40 + 19*15 = 325 frames (13.0s)
|
||||
const sw20 = calculateShockWaveStats(20, 0)
|
||||
expect(sw20.minPhysicalDamage).toBe(99)
|
||||
expect(sw20.maxPhysicalDamage).toBe(109)
|
||||
expect(sw20.stunDurationFrames).toBe(325)
|
||||
expect(sw20.stunDurationSeconds).toBe(13.0)
|
||||
|
||||
// Synergy: 20 in Maul (+5%/lvl = +100%)
|
||||
const sw20Syn = calculateShockWaveStats(20, 20)
|
||||
expect(sw20Syn.synergyBonusPct).toBe(100)
|
||||
expect(sw20Syn.minPhysicalDamage).toBe(99 * 2) // 198
|
||||
expect(sw20Syn.maxPhysicalDamage).toBe(109 * 2) // 218
|
||||
})
|
||||
|
||||
it('1.10 Fury (248): Evaluates 5-hit uninterrupted feral frenzy sequence', () => {
|
||||
// slvl 1: min(2 + 0, 5) = 2 hits
|
||||
const f1 = calculateFuryStats(1)
|
||||
expect(f1.damageBonusPct).toBe(100)
|
||||
expect(f1.toHitBonusPct).toBe(50)
|
||||
expect(f1.attackCount).toBe(2)
|
||||
expect(f1.isUninterruptible).toBe(true)
|
||||
expect(f1.manaCost).toBe(4)
|
||||
|
||||
// slvl 2: min(2 + 1, 5) = 3 hits
|
||||
const f2 = calculateFuryStats(2)
|
||||
expect(f2.attackCount).toBe(3)
|
||||
|
||||
// slvl 3: min(2 + 2, 5) = 4 hits
|
||||
const f3 = calculateFuryStats(3)
|
||||
expect(f3.attackCount).toBe(4)
|
||||
|
||||
// slvl 4+: min(2 + 3, 5) = 5 hits (capped)
|
||||
const f4 = calculateFuryStats(4)
|
||||
expect(f4.attackCount).toBe(5)
|
||||
|
||||
// slvl 10: 100 + 9*17 = 253%, 50 + 9*7 = 113%
|
||||
const f10 = calculateFuryStats(10)
|
||||
expect(f10.damageBonusPct).toBe(253)
|
||||
expect(f10.toHitBonusPct).toBe(113)
|
||||
expect(f10.attackCount).toBe(5)
|
||||
|
||||
// slvl 20: 100 + 19*17 = 423%, 50 + 19*7 = 183%
|
||||
const f20 = calculateFuryStats(20)
|
||||
expect(f20.damageBonusPct).toBe(423)
|
||||
expect(f20.toHitBonusPct).toBe(183)
|
||||
expect(f20.attackCount).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 2: Beast Form Restrictions & State Validation Matrix
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Beast Form Restrictions & State Validation Matrix', () => {
|
||||
it('2.1 Enforces Wolf-only skills: Feral Rage, Rabies, Fury', () => {
|
||||
const wolfSkills = [
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.FERAL_RAGE,
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.RABIES,
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.FURY,
|
||||
]
|
||||
|
||||
for (const skillId of wolfSkills) {
|
||||
expect(canCastShapeShiftSkill('wolf', skillId)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('human', skillId)).toBe(false)
|
||||
expect(canCastShapeShiftSkill('bear', skillId)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('2.2 Enforces Bear-only skills: Maul, Shock Wave', () => {
|
||||
const bearSkills = [
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.MAUL,
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.SHOCK_WAVE,
|
||||
]
|
||||
|
||||
for (const skillId of bearSkills) {
|
||||
expect(canCastShapeShiftSkill('bear', skillId)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('human', skillId)).toBe(false)
|
||||
expect(canCastShapeShiftSkill('wolf', skillId)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('2.3 Enforces dual-form beast skills: Fire Claws and Hunger', () => {
|
||||
const dualSkills = [
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.FIRE_CLAWS,
|
||||
DRUID_SHAPESHIFT_SKILL_IDS.HUNGER,
|
||||
]
|
||||
|
||||
for (const skillId of dualSkills) {
|
||||
expect(canCastShapeShiftSkill('wolf', skillId)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('bear', skillId)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('human', skillId)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('2.4 Enforces transformation and reversion capabilities', () => {
|
||||
// Werewolf: can cast in human (to become wolf) or wolf (to revert to human), but not bear
|
||||
expect(canCastShapeShiftSkill('human', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('wolf', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('bear', DRUID_SHAPESHIFT_SKILL_IDS.WEREWOLF)).toBe(false)
|
||||
|
||||
// Werebear: can cast in human (to become bear) or bear (to revert to human), but not wolf
|
||||
expect(canCastShapeShiftSkill('human', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('bear', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(true)
|
||||
expect(canCastShapeShiftSkill('wolf', DRUID_SHAPESHIFT_SKILL_IDS.WEREBEAR)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 3: Kinematics, Contagion & Multi-Hit Mechanics
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Kinematics, Contagion & Multi-Hit Mechanics', () => {
|
||||
it('3.1 Rabies: Contagion spreads to nearby targets within 4 subtiles and ignores distant targets', () => {
|
||||
const infected = [{ id: 'target_0', x: 100, y: 100 }]
|
||||
const healthy = [
|
||||
{ id: 'target_close_1', x: 120, y: 105 }, // ~22px away (inside 128px radius)
|
||||
{ id: 'target_close_2', x: 80, y: 95 }, // ~22px away (inside 128px radius)
|
||||
{ id: 'target_far', x: 400, y: 400 }, // ~424px away (outside 128px radius)
|
||||
]
|
||||
|
||||
const newlyInfected = simulateRabiesContagion(infected, healthy, 128)
|
||||
expect(newlyInfected).toContain('target_close_1')
|
||||
expect(newlyInfected).toContain('target_close_2')
|
||||
expect(newlyInfected).not.toContain('target_far')
|
||||
})
|
||||
|
||||
it('3.2 Maul & Feral Rage: Charge-up orb increments up to exact maximum charges', () => {
|
||||
// Feral Rage caps at 3
|
||||
let frCharge = 0
|
||||
frCharge = advanceChargeOrb(frCharge, 3)
|
||||
expect(frCharge).toBe(1)
|
||||
frCharge = advanceChargeOrb(frCharge, 3)
|
||||
expect(frCharge).toBe(2)
|
||||
frCharge = advanceChargeOrb(frCharge, 3)
|
||||
expect(frCharge).toBe(3)
|
||||
frCharge = advanceChargeOrb(frCharge, 3)
|
||||
expect(frCharge).toBe(3) // Capped at 3
|
||||
|
||||
// Maul caps at 5
|
||||
let mCharge = 0
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mCharge = advanceChargeOrb(mCharge, 5)
|
||||
}
|
||||
expect(mCharge).toBe(5) // Capped at 5
|
||||
})
|
||||
|
||||
it('3.3 Fury: Distributes 5-hit sequence evenly across multiple targets or focuses single target', () => {
|
||||
// Single target
|
||||
const singleStrikes = distributeFuryStrikes(['boss_1'], 5)
|
||||
expect(singleStrikes.length).toBe(5)
|
||||
expect(singleStrikes.every((s) => s.targetId === 'boss_1')).toBe(true)
|
||||
expect(singleStrikes.map((s) => s.hitIndex)).toEqual([1, 2, 3, 4, 5])
|
||||
|
||||
// 2 targets (alternating)
|
||||
const dualStrikes = distributeFuryStrikes(['enemy_a', 'enemy_b'], 5)
|
||||
expect(dualStrikes.length).toBe(5)
|
||||
expect(dualStrikes.map((s) => s.targetId)).toEqual([
|
||||
'enemy_a',
|
||||
'enemy_b',
|
||||
'enemy_a',
|
||||
'enemy_b',
|
||||
'enemy_a',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Invariants, DataRegistry, and SkillModule Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Invariants, DataRegistry, and SkillModule Integration', () => {
|
||||
it('4.1 BATCH1_SKILLS length invariant = 38 is strictly preserved', () => {
|
||||
const batch1Keys = Object.keys(BATCH1_SKILLS)
|
||||
expect(batch1Keys.length).toBe(38)
|
||||
})
|
||||
|
||||
it('4.2 All 10 Druid Shape Shifting skills are properly defined in SkillModule architecture', async () => {
|
||||
const shapeShiftIds = [223, 224, 228, 232, 233, 238, 239, 242, 243, 248]
|
||||
const { skillModule: m223 } = await import('../../../src/game/skills/impl/dru/skill-223-wearwolf.ts')
|
||||
const { skillModule: m224 } = await import('../../../src/game/skills/impl/dru/skill-224-shape-shifting.ts')
|
||||
const { skillModule: m228 } = await import('../../../src/game/skills/impl/dru/skill-228-wearbear.ts')
|
||||
const { skillModule: m232 } = await import('../../../src/game/skills/impl/dru/skill-232-feral-rage.ts')
|
||||
const { skillModule: m233 } = await import('../../../src/game/skills/impl/dru/skill-233-maul.ts')
|
||||
const { skillModule: m238 } = await import('../../../src/game/skills/impl/dru/skill-238-rabies.ts')
|
||||
const { skillModule: m239 } = await import('../../../src/game/skills/impl/dru/skill-239-fire-claws.ts')
|
||||
const { skillModule: m242 } = await import('../../../src/game/skills/impl/dru/skill-242-hunger.ts')
|
||||
const { skillModule: m243 } = await import('../../../src/game/skills/impl/dru/skill-243-shock-wave.ts')
|
||||
const { skillModule: m248 } = await import('../../../src/game/skills/impl/dru/skill-248-fury.ts')
|
||||
|
||||
const modules = [m223, m224, m228, m232, m233, m238, m239, m242, m243, m248]
|
||||
for (let i = 0; i < shapeShiftIds.length; i++) {
|
||||
const id = shapeShiftIds[i]
|
||||
const mod = modules[i]
|
||||
expect(mod).toBeDefined()
|
||||
expect(mod.skillId).toBe(id)
|
||||
expect(mod.charClass).toBe('dru')
|
||||
}
|
||||
})
|
||||
|
||||
it('4.3 DataRegistry loads all 10 Druid Shape Shifting skills from Patch_D2.mpq with exact attributes', async () => {
|
||||
const reg = await getSharedDataRegistry('samples/d2')
|
||||
|
||||
// Werewolf
|
||||
const ww = reg.getSkillById(223)
|
||||
expect(ww).toBeDefined()
|
||||
expect(ww!.name).toBe('Wearwolf')
|
||||
expect(ww!.auraState).toBe('wolf')
|
||||
expect(ww!.delay).toBe('25')
|
||||
|
||||
// Lycanthropy
|
||||
const lyc = reg.getSkillById(224)
|
||||
expect(lyc).toBeDefined()
|
||||
expect(lyc!.name).toBe('Shape Shifting')
|
||||
expect(lyc!.passive).toBe(true)
|
||||
|
||||
// Werebear
|
||||
const wb = reg.getSkillById(228)
|
||||
expect(wb).toBeDefined()
|
||||
expect(wb!.name).toBe('Wearbear')
|
||||
expect(wb!.auraState).toBe('bear')
|
||||
expect(wb!.delay).toBe('25')
|
||||
|
||||
// Feral Rage
|
||||
const fr = reg.getSkillById(232)
|
||||
expect(fr).toBeDefined()
|
||||
expect(fr!.name).toBe('Feral Rage')
|
||||
expect(fr!.auraState).toBe('feralrage')
|
||||
|
||||
// Maul
|
||||
const m = reg.getSkillById(233)
|
||||
expect(m).toBeDefined()
|
||||
expect(m!.name).toBe('Maul')
|
||||
expect(m!.auraState).toBe('maul')
|
||||
|
||||
// Rabies
|
||||
const r = reg.getSkillById(238)
|
||||
expect(r).toBeDefined()
|
||||
expect(r!.name).toBe('Rabies')
|
||||
expect(r!.srvMissileA).toBe('rabiesplague')
|
||||
|
||||
// Fire Claws
|
||||
const fc = reg.getSkillById(239)
|
||||
expect(fc).toBeDefined()
|
||||
expect(fc!.name).toBe('Fire Claws')
|
||||
expect(fc!.eType).toBe('fire')
|
||||
|
||||
// Hunger
|
||||
const h = reg.getSkillById(242)
|
||||
expect(h).toBeDefined()
|
||||
expect(h!.name).toBe('Hunger')
|
||||
|
||||
// Shock Wave
|
||||
const sw = reg.getSkillById(243)
|
||||
expect(sw).toBeDefined()
|
||||
expect(sw!.name).toBe('Shock Wave')
|
||||
expect(sw!.srvMissileA).toBe('shockwave')
|
||||
|
||||
// Fury
|
||||
const f = reg.getSkillById(248)
|
||||
expect(f).toBeDefined()
|
||||
expect(f!.name).toBe('Fury')
|
||||
})
|
||||
|
||||
it('4.4 evaluateSkill113c evaluates Druid Shape Shifting skills against StatList with exact attributes', async () => {
|
||||
const reg = await getSharedDataRegistry('samples/d2')
|
||||
const stats = new UnitStatList()
|
||||
|
||||
// Werewolf
|
||||
const wwRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(223)!,
|
||||
slvl: 1,
|
||||
blvl: 1,
|
||||
statList: stats,
|
||||
})
|
||||
expect(wwRes.manaCost).toBe(15)
|
||||
expect(wwRes.cooldownFrames).toBe(25)
|
||||
|
||||
// Maul
|
||||
const mRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(233)!,
|
||||
slvl: 10,
|
||||
blvl: 10,
|
||||
statList: stats,
|
||||
})
|
||||
expect(mRes.manaCost).toBe(3)
|
||||
expect(mRes.toHitBonusPct).toBe(110)
|
||||
|
||||
// Fury
|
||||
const fRes = evaluateSkill113c({
|
||||
registry: reg,
|
||||
skill: reg.getSkillById(248)!,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: stats,
|
||||
})
|
||||
expect(fRes.manaCost).toBe(4)
|
||||
expect(fRes.toHitBonusPct).toBe(183)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue