feat(dru): Milestone M24 Druid Summoning skills, pet exclusivity, spirit auras, corpse cycles, and stress suite (closes #471)

This commit is contained in:
troytt 2026-09-25 03:06:36 +00:00
parent 0c5f5a88ab
commit 853f61d634
2 changed files with 1479 additions and 0 deletions

View File

@ -0,0 +1,783 @@
/**
* Diablo II: Lord of Destruction v1.13c — Druid Summoning Skills Module
*
* Dedicated pure calculations, pet group mutual exclusivity, spirit aura broadcasting,
* vine corpse consumption cycles, wolf & grizzly cross-synergies, and adversarial kinematics
* for the 10 Druid Summoning Tree Skills:
* - Skill 221: Raven (ReqLevel 1)
* - Skill 222: Poison Creeper / Plague Poppy (ReqLevel 1)
* - Skill 226: Oak Sage (ReqLevel 6)
* - Skill 227: Summon Spirit Wolf (ReqLevel 6)
* - Skill 231: Carrion Vine / Cycle of Life (ReqLevel 12)
* - Skill 236: Heart of Wolverine (ReqLevel 18)
* - Skill 237: Summon Dire Wolf / Fenris (ReqLevel 18)
* - Skill 241: Solar Creeper / Vines (ReqLevel 24)
* - Skill 246: Spirit of Barbs (ReqLevel 30)
* - Skill 247: Summon Grizzly (ReqLevel 30)
*
* 1.13c Ground Truth:
* - Skills.txt, MonStats.txt, PetType.txt, SkillDesc.txt (Patch_D2.mpq)
* - PetType exclusivity:
* Group 1: Spirit Wolf (max 5), Dire Wolf (max 3), Grizzly (max 1) — Mutually exclusive species.
* Group 2: Oak Sage (max 1), Heart of Wolverine (max 1), Spirit of Barbs (max 1) — Only 1 active spirit totem.
* Group 3: Poison Creeper (max 1), Carrion Vine (max 1), Solar Creeper (max 1) — Only 1 active vine creature.
* Group 0 (Raven): Independent pet slot, max 5 ravens. Each raven has limited hits (11 + slvl).
* - Wolf & Grizzly passive synergy network:
* Spirit Wolf hard points grant passive AR% and Defense% to all 3 species.
* Dire Wolf hard points grant passive Life% to all 3 species.
* Grizzly hard points grant passive Damage% to all 3 species.
* - Spirit auras:
* Oak Sage: party Max Life% aura (25 + 5 * slvl)%.
* Heart of Wolverine: party Enhanced Damage% (13 + 7 * slvl)% and AR% (18 + 7 * slvl)%.
* Spirit of Barbs: party Thorns return (50 + 10 * (slvl - 1))% or (50 + 20 * (slvl - 1))%.
* - Vine corpse consumption:
* Carrion Vine consumes corpses to restore Druid's Life%.
* Solar Creeper consumes corpses to restore Druid's Mana%.
* Poison Creeper leaves poison mat traps that poison walking enemies.
* - Dire Wolf corpse frenzy:
* Consumes combat corpses to enter rage buff (+100% damage for 500 frames / 20s).
* - Zero runtime MPQ reading.
*/
import { compute5BandScaling, computeDiminishingReturns } from '../engine/calc-ast.ts'
// --- Skill IDs ---
export const DRUID_SUMMON_SKILL_IDS = {
RAVEN: 221,
POISON_CREEPER: 222,
OAK_SAGE: 226,
SUMMON_SPIRIT_WOLF: 227,
CARRION_VINE: 231,
HEART_OF_WOLVERINE: 236,
SUMMON_DIRE_WOLF: 237,
SOLAR_CREEPER: 241,
SPIRIT_OF_BARBS: 246,
SUMMON_GRIZZLY: 247,
} as const
// --- Pet Group Classifications ---
export const enum DruidPetGroup {
RAVEN = 0,
WOLF_BEAR = 1,
SPIRIT = 2,
VINE = 3,
}
// --- Interfaces for Skill Calculations ---
export interface RavenStats {
readonly slvl: number
readonly maxRavens: number
readonly hitsRemaining: number
readonly minDamage: number
readonly maxDamage: number
readonly attackRating: number
readonly manaCost: number
readonly manaCost256: number
readonly blindOverlay: string
}
export interface PoisonCreeperStats {
readonly slvl: number
readonly maxPets: number
readonly hpBonusPct: number
readonly minPoisonDamage: number
readonly maxPoisonDamage: number
readonly durationFrames: number
readonly manaCost: number
readonly manaCost256: number
readonly synergyBonusPct: number
}
export interface OakSageStats {
readonly slvl: number
readonly maxPets: number
readonly lifeBonusPct: number
readonly radiusSubtiles: number
readonly radiusYards: number
readonly hpBonusPct: number
readonly manaCost: number
readonly manaCost256: number
readonly auraState: string
}
export interface SpiritWolfStats {
readonly slvl: number
readonly maxWolves: number
readonly minDamage: number
readonly maxDamage: number
readonly passiveArBonusPct: number
readonly passiveDefBonusPct: number
readonly passiveLifeBonusPct: number
readonly passiveDmgBonusPct: number
readonly elementalResistPct: number
readonly manaCost: number
readonly manaCost256: number
readonly coldDamagePct: number
}
export interface CarrionVineStats {
readonly slvl: number
readonly maxPets: number
readonly hpBonusPct: number
readonly healLifePct: number
readonly searchRadiusSubtiles: number
readonly manaCost: number
readonly manaCost256: number
}
export interface HeartOfWolverineStats {
readonly slvl: number
readonly maxPets: number
readonly damageBonusPct: number
readonly attackRatingBonusPct: number
readonly radiusSubtiles: number
readonly radiusYards: number
readonly hpBonusPct: number
readonly manaCost: number
readonly manaCost256: number
readonly auraState: string
}
export interface DireWolfStats {
readonly slvl: number
readonly maxWolves: number
readonly minDamage: number
readonly maxDamage: number
readonly passiveLifeBonusPct: number
readonly passiveArBonusPct: number
readonly passiveDefBonusPct: number
readonly passiveDmgBonusPct: number
readonly corpseFrenzyDamageBonusPct: number
readonly corpseFrenzyDurationFrames: number
readonly elementalResistPct: number
readonly manaCost: number
readonly manaCost256: number
}
export interface SolarCreeperStats {
readonly slvl: number
readonly maxPets: number
readonly hpBonusPct: number
readonly restoreManaPct: number
readonly searchRadiusSubtiles: number
readonly manaCost: number
readonly manaCost256: number
}
export interface SpiritOfBarbsStats {
readonly slvl: number
readonly maxPets: number
readonly thornsReturnPct: number
readonly radiusSubtiles: number
readonly radiusYards: number
readonly hpBonusPct: number
readonly manaCost: number
readonly manaCost256: number
readonly auraState: string
}
export interface GrizzlyStats {
readonly slvl: number
readonly maxPets: number
readonly minDamage: number
readonly maxDamage: number
readonly passiveDmgBonusPct: number
readonly passiveLifeBonusPct: number
readonly passiveArBonusPct: number
readonly passiveDefBonusPct: number
readonly smiteStunFrames: number
readonly smiteKnockback: boolean
readonly elementalResistPct: number
readonly manaCost: number
readonly manaCost256: number
}
// --- Synergy Cross-Reference Package ---
export interface DruidSummonSynergyProfile {
readonly spiritWolfArDefBonusPct: number
readonly direWolfLifeBonusPct: number
readonly grizzlyDmgBonusPct: number
}
// --- Pure Calculation Functions ---
/**
* 1. Raven (Skill 221)
* 1.13c Ground truth:
* - Param2 = 5 (max ravens = min(slvl, 5))
* - Param5 = 12, Param6 = 1 -> hits = 12 + (slvl - 1) * 1 = 11 + slvl
* - Damage: min = 2 + (slvl - 1) * 1, max = 4 + (slvl - 1) * 1
* - AR: ToHit = 100, LevToHit = 15 -> 100 + (slvl - 1) * 15
* - Mana: 6 (fixed, manaCost256 = 1536)
*/
export function calculateRavenStats(slvl: number): RavenStats {
const lvl = Math.max(1, slvl)
const maxRavens = Math.min(lvl, 5)
const hitsRemaining = 11 + lvl
const minDamage = 1 + lvl
const maxDamage = 3 + lvl
const attackRating = 100 + (lvl - 1) * 15
return {
slvl: lvl,
maxRavens,
hitsRemaining,
minDamage,
maxDamage,
attackRating,
manaCost: 6,
manaCost256: 1536,
blindOverlay: 'blind_curse',
}
}
/**
* 2. Poison Creeper (Skill 222)
* 1.13c Ground truth:
* - Max pets = 1 (vine group)
* - Mana: 8 (manaCost256 = 2048)
* - HP%: calc1 = par3 * (lvl - 1) = 25 * (lvl - 1)%
* - Synergy from Rabies: +18%/blvl (or +8%/blvl)
*/
export function calculatePoisonCreeperStats(slvl: number, rabiesBlvl: number = 0): PoisonCreeperStats {
const lvl = Math.max(1, slvl)
const hpBonusPct = 25 * (lvl - 1)
const synBonusPct = rabiesBlvl * 18
// Base poison scaling
const baseMin = 4 + (lvl - 1) * 4
const baseMax = 6 + (lvl - 1) * 5
const minPoisonDamage = Math.trunc((baseMin * (100 + synBonusPct)) / 100)
const maxPoisonDamage = Math.trunc((baseMax * (100 + synBonusPct)) / 100)
return {
slvl: lvl,
maxPets: 1,
hpBonusPct,
minPoisonDamage,
maxPoisonDamage,
durationFrames: 100 + lvl * 25,
manaCost: 8,
manaCost256: 2048,
synergyBonusPct: synBonusPct,
}
}
/**
* 3. Oak Sage (Skill 226)
* 1.13c Ground truth:
* - Helper skill 298 (Oak Sage Aura):
* item_maxhp_percent: ln34 with param3=30, param4=5 -> 30 + (slvl - 1) * 5 = 25 + 5 * slvl (%)
* Radius: ln12 with param1=30, param2=2 subtiles -> 30 + (slvl - 1) * 2 subtiles
* - Spirit HP adj: (slvl - 1) * 30%
* - Mana: 15 base, lvlmana = 1 -> 15 + (slvl - 1)
*/
export function calculateOakSageStats(slvl: number): OakSageStats {
const lvl = Math.max(1, slvl)
const lifeBonusPct = 25 + 5 * lvl
const radiusSubtiles = 30 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(1))
const hpBonusPct = (lvl - 1) * 30
const manaCost = 15 + (lvl - 1)
const manaCost256 = manaCost * 256
return {
slvl: lvl,
maxPets: 1,
lifeBonusPct,
radiusSubtiles,
radiusYards,
hpBonusPct,
manaCost,
manaCost256,
auraState: 'oaksageaura',
}
}
/**
* 4. Summon Spirit Wolf (Skill 227)
* 1.13c Ground truth:
* - Max wolves = min(slvl, 5)
* - Mana: 15 (fixed, manaCost256 = 3840)
* - 5-band physical damage: (2, 6) base, +1/lvl (1-8), +2/lvl (9-16), +4/lvl (17-22), +5/lvl (23-28), +8/lvl (29+)
* - Passive AR% to wolves/bear: 50 + (slvl - 1) * 25%
* - Passive Defense% to wolves/bear: 50 + (slvl - 1) * 10%
* - Dire Wolf synergy to Spirit Wolf Life%: 50 + (direWolfBlvl - 1) * 25% (if blvl > 0)
* - Grizzly synergy to Spirit Wolf Damage%: 25 + (grizzlyBlvl - 1) * 10% (if blvl > 0)
* - Resists: min(slvl * 5, 85)%
* - Cold damage conversion: 50%
*/
export function calculateSpiritWolfStats(
slvl: number,
synergies?: { direWolfBlvl?: number; grizzlyBlvl?: number }
): SpiritWolfStats {
const lvl = Math.max(1, slvl)
const maxWolves = Math.min(lvl, 5)
const baseMin = compute5BandScaling(lvl, 2, 1, 2, 4, 5, 8)
const baseMax = compute5BandScaling(lvl, 6, 1, 2, 4, 5, 8)
const passiveArBonusPct = 50 + (lvl - 1) * 25
const passiveDefBonusPct = 50 + (lvl - 1) * 10
const dwLvl = synergies?.direWolfBlvl || 0
const passiveLifeBonusPct = dwLvl > 0 ? 50 + (dwLvl - 1) * 25 : 0
const gzLvl = synergies?.grizzlyBlvl || 0
const passiveDmgBonusPct = gzLvl > 0 ? 25 + (gzLvl - 1) * 10 : 0
const minDamage = Math.trunc((baseMin * (100 + passiveDmgBonusPct)) / 100)
const maxDamage = Math.trunc((baseMax * (100 + passiveDmgBonusPct)) / 100)
const elementalResistPct = Math.min(85, lvl * 5)
return {
slvl: lvl,
maxWolves,
minDamage,
maxDamage,
passiveArBonusPct,
passiveDefBonusPct,
passiveLifeBonusPct,
passiveDmgBonusPct,
elementalResistPct,
manaCost: 15,
manaCost256: 3840,
coldDamagePct: 50,
}
}
/**
* 5. Carrion Vine (Skill 231)
* 1.13c Ground truth:
* - Max pets = 1 (vine group)
* - Mana: 10 (fixed, manaCost256 = 2560)
* - Vine HP adj: (slvl - 1) * 25%
* - CorpseCycler (307): dm12 with param1=3, param2=12 -> 3% base, diminishing to 12% max
* - Search radius: 10 subtiles
*/
export function calculateCarrionVineStats(slvl: number): CarrionVineStats {
const lvl = Math.max(1, slvl)
const hpBonusPct = (lvl - 1) * 25
const healLifePct = computeDiminishingReturns(3, 12, lvl)
return {
slvl: lvl,
maxPets: 1,
hpBonusPct,
healLifePct,
searchRadiusSubtiles: 10,
manaCost: 10,
manaCost256: 2560,
}
}
/**
* 6. Heart of Wolverine (Skill 236)
* 1.13c Ground truth:
* - Helper skill 297 (Wolverine Aura):
* damagepercent: ln56 with param5=20, param6=7 -> 20 + (slvl - 1) * 7 = 13 + 7 * slvl (%)
* item_tohit_percent: ln34 with param3=25, param4=7 -> 25 + (slvl - 1) * 7 = 18 + 7 * slvl (%)
* Radius: ln12 with param1=30, param2=2 subtiles
* - Spirit HP adj: (slvl - 1) * 25%
* - Mana: 20 base, lvlmana = 1 -> 20 + (slvl - 1)
*/
export function calculateHeartOfWolverineStats(slvl: number): HeartOfWolverineStats {
const lvl = Math.max(1, slvl)
const damageBonusPct = 13 + 7 * lvl
const attackRatingBonusPct = 18 + 7 * lvl
const radiusSubtiles = 30 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(1))
const hpBonusPct = (lvl - 1) * 25
const manaCost = 20 + (lvl - 1)
const manaCost256 = manaCost * 256
return {
slvl: lvl,
maxPets: 1,
damageBonusPct,
attackRatingBonusPct,
radiusSubtiles,
radiusYards,
hpBonusPct,
manaCost,
manaCost256,
auraState: 'wolverine_aura',
}
}
/**
* 7. Summon Dire Wolf (Skill 237)
* 1.13c Ground truth:
* - Max wolves = min(slvl, 3)
* - Mana: 20 (fixed, manaCost256 = 5120)
* - 5-band physical damage: (7, 12) base, +2/lvl (1-8), +3/lvl (9-16), +6/lvl (17-22), +8/+9/lvl (23-28), +11/+13/lvl (29+)
* - Passive Life% to wolves/bear: 50 + (slvl - 1) * 25%
* - Spirit Wolf synergy to Dire Wolf AR% and Defense%:
* AR%: 50 + (spiritWolfBlvl - 1) * 25% (if blvl > 0)
* Def%: 50 + (spiritWolfBlvl - 1) * 10% (if blvl > 0)
* - Grizzly synergy to Dire Wolf Damage%: 25 + (grizzlyBlvl - 1) * 10% (if blvl > 0)
* - Corpse eating frenzy: fenris rage (Skill 314) -> +100% damage for 500 frames (20s)
* - Resists: min(slvl * 5, 85)%
*/
export function calculateDireWolfStats(
slvl: number,
synergies?: { spiritWolfBlvl?: number; grizzlyBlvl?: number }
): DireWolfStats {
const lvl = Math.max(1, slvl)
const maxWolves = Math.min(lvl, 3)
const baseMin = compute5BandScaling(lvl, 7, 2, 3, 6, 8, 11)
const baseMax = compute5BandScaling(lvl, 12, 2, 3, 6, 9, 13)
const passiveLifeBonusPct = 50 + (lvl - 1) * 25
const swLvl = synergies?.spiritWolfBlvl || 0
const passiveArBonusPct = swLvl > 0 ? 50 + (swLvl - 1) * 25 : 0
const passiveDefBonusPct = swLvl > 0 ? 50 + (swLvl - 1) * 10 : 0
const gzLvl = synergies?.grizzlyBlvl || 0
const passiveDmgBonusPct = gzLvl > 0 ? 25 + (gzLvl - 1) * 10 : 0
const minDamage = Math.trunc((baseMin * (100 + passiveDmgBonusPct)) / 100)
const maxDamage = Math.trunc((baseMax * (100 + passiveDmgBonusPct)) / 100)
const elementalResistPct = Math.min(85, lvl * 5)
return {
slvl: lvl,
maxWolves,
minDamage,
maxDamage,
passiveLifeBonusPct,
passiveArBonusPct,
passiveDefBonusPct,
passiveDmgBonusPct,
corpseFrenzyDamageBonusPct: 100,
corpseFrenzyDurationFrames: 500,
elementalResistPct,
manaCost: 20,
manaCost256: 5120,
}
}
/**
* 8. Solar Creeper (Skill 241)
* 1.13c Ground truth:
* - Max pets = 1 (vine group)
* - Mana: 14 base, lvlmana = 1 -> 14 + (slvl - 1)
* - Vine HP adj: (slvl - 1) * 20%
* - VineCycler (325): dm12 with param1=1, param2=8 -> 1% base, diminishing to 8% max
* - Search radius: 10 subtiles
*/
export function calculateSolarCreeperStats(slvl: number): SolarCreeperStats {
const lvl = Math.max(1, slvl)
const hpBonusPct = (lvl - 1) * 20
const restoreManaPct = computeDiminishingReturns(1, 8, lvl)
const manaCost = 14 + (lvl - 1)
const manaCost256 = manaCost * 256
return {
slvl: lvl,
maxPets: 1,
hpBonusPct,
restoreManaPct,
searchRadiusSubtiles: 10,
manaCost,
manaCost256,
}
}
/**
* 9. Spirit of Barbs (Skill 246)
* 1.13c Ground truth:
* - Helper skill 296 (Barbs Aura):
* thorns_percent: ln34 with param3=50, param4=10 -> 50 + (slvl - 1) * 10%
* (Or Skills.txt param3=50, param4=20 -> 50 + (slvl - 1) * 20%)
* Radius: ln12 with param1=30, param2=2 subtiles
* - Spirit HP adj: (slvl - 1) * 25%
* - Mana: 25 base, lvlmana = 1 -> 25 + (slvl - 1)
*/
export function calculateSpiritOfBarbsStats(slvl: number): SpiritOfBarbsStats {
const lvl = Math.max(1, slvl)
const thornsReturnPct = 50 + (lvl - 1) * 10
const radiusSubtiles = 30 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(1))
const hpBonusPct = (lvl - 1) * 25
const manaCost = 25 + (lvl - 1)
const manaCost256 = manaCost * 256
return {
slvl: lvl,
maxPets: 1,
thornsReturnPct,
radiusSubtiles,
radiusYards,
hpBonusPct,
manaCost,
manaCost256,
auraState: 'barbs_aura',
}
}
/**
* 10. Summon Grizzly (Skill 247)
* 1.13c Ground truth:
* - Max pets = 1 (apex solo summon)
* - Mana: 40 (fixed, manaCost256 = 10240)
* - 5-band physical damage: (30, 60) base, +10/lvl (1-8), +15/lvl (9-16), +20/lvl (17-22), +26/lvl (23-28), +30/lvl (29+)
* - Passive Damage% to wolves: 25 + (slvl - 1) * 10%
* - Dire Wolf synergy to Grizzly Life%: 50 + (direWolfBlvl - 1) * 25% (if blvl > 0)
* - Spirit Wolf synergy to Grizzly AR% and Defense%:
* AR%: 50 + (spiritWolfBlvl - 1) * 25% (if blvl > 0)
* Def%: 50 + (spiritWolfBlvl - 1) * 10% (if blvl > 0)
* - Smite attack: Paw swipe stuns (15 + (slvl - 1) * 5 frames, max 250) and knocks back
* - Resists: min(slvl * 5, 85)%
*/
export function calculateGrizzlyStats(
slvl: number,
synergies?: { spiritWolfBlvl?: number; direWolfBlvl?: number }
): GrizzlyStats {
const lvl = Math.max(1, slvl)
const baseMin = compute5BandScaling(lvl, 30, 10, 15, 20, 26, 30)
const baseMax = compute5BandScaling(lvl, 60, 10, 15, 20, 26, 30)
const passiveDmgBonusPct = 25 + (lvl - 1) * 10
const dwLvl = synergies?.direWolfBlvl || 0
const passiveLifeBonusPct = dwLvl > 0 ? 50 + (dwLvl - 1) * 25 : 0
const swLvl = synergies?.spiritWolfBlvl || 0
const passiveArBonusPct = swLvl > 0 ? 50 + (swLvl - 1) * 25 : 0
const passiveDefBonusPct = swLvl > 0 ? 50 + (swLvl - 1) * 10 : 0
const smiteStunFrames = Math.min(250, 15 + (lvl - 1) * 5)
const elementalResistPct = Math.min(85, lvl * 5)
return {
slvl: lvl,
maxPets: 1,
minDamage: baseMin,
maxDamage: baseMax,
passiveDmgBonusPct,
passiveLifeBonusPct,
passiveArBonusPct,
passiveDefBonusPct,
smiteStunFrames,
smiteKnockback: true,
elementalResistPct,
manaCost: 40,
manaCost256: 10240,
}
}
// --- Synergy Network Cross-Calculator ---
export function calculateDruidSummonSynergyProfile(hardPoints: {
spiritWolf?: number
direWolf?: number
grizzly?: number
}): DruidSummonSynergyProfile {
const sw = hardPoints.spiritWolf ?? 0
const dw = hardPoints.direWolf ?? 0
const gz = hardPoints.grizzly ?? 0
return {
spiritWolfArDefBonusPct: sw > 0 ? 50 + (sw - 1) * 25 : 0,
direWolfLifeBonusPct: dw > 0 ? 50 + (dw - 1) * 25 : 0,
grizzlyDmgBonusPct: gz > 0 ? 25 + (gz - 1) * 10 : 0,
}
}
// --- Pet Slot & Mutual Exclusivity Simulator ---
export interface ActivePetRecord {
readonly id: string
readonly skillId: number
readonly petGroup: DruidPetGroup
readonly petType: string
}
export interface PetSummonResolution {
readonly allowed: boolean
readonly evictedPetIds: string[]
readonly newActivePets: ActivePetRecord[]
readonly notes: string[]
}
/**
* Validates and simulates pet slot allocation, PetType.txt group exclusivity,
* and FIFO eviction according to D2 1.13c rules.
*/
export function resolvePetSummon(
currentPets: readonly ActivePetRecord[],
newPet: { id: string; skillId: number; slvl: number }
): PetSummonResolution {
const { id, skillId, slvl } = newPet
const evictedPetIds: string[] = []
const notes: string[] = []
// Classify group and type
let petGroup = DruidPetGroup.RAVEN
let petType = 'raven'
let maxPets = 5
if (skillId === DRUID_SUMMON_SKILL_IDS.RAVEN) {
petGroup = DruidPetGroup.RAVEN
petType = 'raven'
maxPets = Math.min(slvl, 5)
} else if ([227, 237, 247].includes(skillId)) {
petGroup = DruidPetGroup.WOLF_BEAR
if (skillId === DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF) {
petType = 'spiritwolf'
maxPets = Math.min(slvl, 5)
} else if (skillId === DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF) {
petType = 'fenris'
maxPets = Math.min(slvl, 3)
} else {
petType = 'grizzly'
maxPets = 1
}
} else if ([226, 236, 246].includes(skillId)) {
petGroup = DruidPetGroup.SPIRIT
petType = 'totem'
maxPets = 1
} else if ([222, 231, 241].includes(skillId)) {
petGroup = DruidPetGroup.VINE
petType = 'vine'
maxPets = 1
}
const remainingPets: ActivePetRecord[] = []
for (const pet of currentPets) {
// 1. PetGroup exclusivity:
// If summoning in Group 1 (Wolf/Bear), evict any pet in Group 1 that has a different species!
if (petGroup === DruidPetGroup.WOLF_BEAR && pet.petGroup === DruidPetGroup.WOLF_BEAR) {
if (pet.petType !== petType) {
evictedPetIds.push(pet.id)
notes.push(`evicted_different_species:${pet.petType}`)
continue
}
}
// If summoning in Group 2 (Spirit Totem), evict any existing Spirit!
if (petGroup === DruidPetGroup.SPIRIT && pet.petGroup === DruidPetGroup.SPIRIT) {
evictedPetIds.push(pet.id)
notes.push(`evicted_previous_spirit:${pet.petType}`)
continue
}
// If summoning in Group 3 (Vine), evict any existing Vine!
if (petGroup === DruidPetGroup.VINE && pet.petGroup === DruidPetGroup.VINE) {
evictedPetIds.push(pet.id)
notes.push(`evicted_previous_vine:${pet.petType}`)
continue
}
remainingPets.push(pet)
}
// 2. FIFO eviction within same species when activeCount >= maxPets
const sameSpecies = remainingPets.filter(p => p.petType === petType)
while (sameSpecies.length >= maxPets) {
const oldest = sameSpecies.shift()!
const idx = remainingPets.findIndex(p => p.id === oldest.id)
if (idx !== -1) {
evictedPetIds.push(oldest.id)
notes.push(`fifo_evicted:${oldest.id}`)
remainingPets.splice(idx, 1)
}
}
const newRecord: ActivePetRecord = {
id,
skillId,
petGroup,
petType,
}
remainingPets.push(newRecord)
return {
allowed: true,
evictedPetIds,
newActivePets: remainingPets,
notes,
}
}
// --- Kinematic & Action Simulators ---
export interface RavenPeckOutcome {
readonly hitsRemainingAfter: number
readonly damageDealt: number
readonly blindInflicted: boolean
readonly dismissed: boolean
}
export function simulateRavenPeck(currentHits: number, target: { isImmuneToCurse?: boolean }): RavenPeckOutcome {
const hitsRemainingAfter = Math.max(0, currentHits - 1)
const damageDealt = 2 + Math.floor(Math.random() * 3)
const blindInflicted = !target.isImmuneToCurse
const dismissed = hitsRemainingAfter === 0
return {
hitsRemainingAfter,
damageDealt,
blindInflicted,
dismissed,
}
}
export interface VineConsumptionOutcome {
readonly corpseConsumed: boolean
readonly lifeHealed: number
readonly manaRestored: number
}
export function simulateVineConsumption(
vineSkillId: number,
slvl: number,
corpse: { consumed: boolean; isBoss?: boolean },
owner: { maxHp: number; maxMana: number }
): VineConsumptionOutcome {
if (corpse.consumed || corpse.isBoss) {
return { corpseConsumed: false, lifeHealed: 0, manaRestored: 0 }
}
corpse.consumed = true
if (vineSkillId === DRUID_SUMMON_SKILL_IDS.CARRION_VINE) {
const stats = calculateCarrionVineStats(slvl)
const lifeHealed = Math.trunc((owner.maxHp * stats.healLifePct) / 100)
return { corpseConsumed: true, lifeHealed, manaRestored: 0 }
}
if (vineSkillId === DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER) {
const stats = calculateSolarCreeperStats(slvl)
const manaRestored = Math.trunc((owner.maxMana * stats.restoreManaPct) / 100)
return { corpseConsumed: true, lifeHealed: 0, manaRestored }
}
return { corpseConsumed: true, lifeHealed: 0, manaRestored: 0 }
}
export interface DireWolfCorpseFrenzyOutcome {
readonly consumed: boolean
readonly rageActivated: boolean
readonly rageDurationFrames: number
readonly damageMultiplier: number
}
export function simulateDireWolfCorpseFrenzy(
corpse: { consumed: boolean; isBoss?: boolean }
): DireWolfCorpseFrenzyOutcome {
if (corpse.consumed || corpse.isBoss) {
return { consumed: false, rageActivated: false, rageDurationFrames: 0, damageMultiplier: 1 }
}
corpse.consumed = true
return {
consumed: true,
rageActivated: true,
rageDurationFrames: 500, // 20 seconds
damageMultiplier: 2.0, // +100% damage
}
}

View File

@ -0,0 +1,696 @@
/**
* Milestone M24 — Druid Summoning Skills Parity & Adversarial Stress Suite
*
* Exhaustive 4-dimension verification suite for the 10 Druid Summoning Skills:
* - Skill 221: Raven
* - Skill 222: Poison Creeper (Plague Poppy)
* - Skill 226: Oak Sage
* - Skill 227: Summon Spirit Wolf
* - Skill 231: Carrion Vine (Cycle of Life)
* - Skill 236: Heart of Wolverine
* - Skill 237: Summon Dire Wolf (Fenris)
* - Skill 241: Solar Creeper (Vines)
* - Skill 246: Spirit of Barbs
* - Skill 247: Summon Grizzly
*
* Verifies:
* - Dimension 1: Exact 1.13c numerical scaling, level progressions, and mana curves
* - Dimension 2: PetType.txt group mutual exclusivity, species eviction, and FIFO limits
* - Dimension 3: Kinematics, corpse consumption cycles, raven hit counters, and wolf/grizzly passive synergies
* - Dimension 4: Invariants (BATCH1_SKILLS=38), DataRegistry, and SkillModule integration
*/
import { describe, expect, it } from 'vitest'
import {
DRUID_SUMMON_SKILL_IDS,
DruidPetGroup,
calculateRavenStats,
calculatePoisonCreeperStats,
calculateOakSageStats,
calculateSpiritWolfStats,
calculateCarrionVineStats,
calculateHeartOfWolverineStats,
calculateDireWolfStats,
calculateSolarCreeperStats,
calculateSpiritOfBarbsStats,
calculateGrizzlyStats,
calculateDruidSummonSynergyProfile,
resolvePetSummon,
simulateRavenPeck,
simulateVineConsumption,
simulateDireWolfCorpseFrenzy,
type ActivePetRecord,
} from '../../../src/game/skills/druid-summon.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, getRegisteredSkillModule } from '../../../src/game/skills/registry.ts'
describe('Milestone M24 — Druid Summoning Skills Parity & Adversarial Stress Suite', () => {
// =========================================================================
// Dimension 1: Exact 1.13c Numerical Scaling & Level Progressions
// =========================================================================
describe('Dimension 1: Exact 1.13c Numerical Scaling & Formulas', () => {
it('1.1 Raven (221): Evaluates hits scaling (11 + slvl), max ravens, and 6 mana', () => {
// slvl 1
const r1 = calculateRavenStats(1)
expect(r1.maxRavens).toBe(1)
expect(r1.hitsRemaining).toBe(12)
expect(r1.minDamage).toBe(2)
expect(r1.maxDamage).toBe(4)
expect(r1.attackRating).toBe(100)
expect(r1.manaCost).toBe(6)
expect(r1.manaCost256).toBe(1536)
// slvl 5
const r5 = calculateRavenStats(5)
expect(r5.maxRavens).toBe(5)
expect(r5.hitsRemaining).toBe(16)
expect(r5.minDamage).toBe(6)
expect(r5.maxDamage).toBe(8)
expect(r5.attackRating).toBe(160)
// slvl 10: max ravens capped at 5
const r10 = calculateRavenStats(10)
expect(r10.maxRavens).toBe(5)
expect(r10.hitsRemaining).toBe(21)
expect(r10.minDamage).toBe(11)
expect(r10.maxDamage).toBe(13)
expect(r10.attackRating).toBe(235)
// slvl 20
const r20 = calculateRavenStats(20)
expect(r20.maxRavens).toBe(5)
expect(r20.hitsRemaining).toBe(31)
expect(r20.minDamage).toBe(21)
expect(r20.maxDamage).toBe(23)
expect(r20.attackRating).toBe(385)
})
it('1.2 Poison Creeper (222): Evaluates HP scaling (+25%/lvl) and Rabies synergy', () => {
const pc1 = calculatePoisonCreeperStats(1, 0)
expect(pc1.maxPets).toBe(1)
expect(pc1.hpBonusPct).toBe(0)
expect(pc1.minPoisonDamage).toBe(4)
expect(pc1.maxPoisonDamage).toBe(6)
expect(pc1.manaCost).toBe(8)
expect(pc1.manaCost256).toBe(2048)
const pc10 = calculatePoisonCreeperStats(10, 0)
expect(pc10.hpBonusPct).toBe(225) // 25 * 9%
expect(pc10.minPoisonDamage).toBe(40)
expect(pc10.maxPoisonDamage).toBe(51)
// With 10 blvl in Rabies (+180% synergy)
const pc10Syn = calculatePoisonCreeperStats(10, 10)
expect(pc10Syn.synergyBonusPct).toBe(180)
expect(pc10Syn.minPoisonDamage).toBe(Math.trunc((40 * 280) / 100)) // 112
})
it('1.3 Oak Sage (226): Evaluates party Max Life% aura (25 + 5*slvl) and mana progression', () => {
// slvl 1: 30% life, radius 30 subtiles (20 yards), mana 15
const os1 = calculateOakSageStats(1)
expect(os1.maxPets).toBe(1)
expect(os1.lifeBonusPct).toBe(30)
expect(os1.radiusSubtiles).toBe(30)
expect(os1.radiusYards).toBe(20.0)
expect(os1.hpBonusPct).toBe(0)
expect(os1.manaCost).toBe(15)
expect(os1.manaCost256).toBe(3840)
// slvl 10: 75% life, radius 48 subtiles (32 yards), mana 24
const os10 = calculateOakSageStats(10)
expect(os10.lifeBonusPct).toBe(75)
expect(os10.radiusSubtiles).toBe(48)
expect(os10.radiusYards).toBe(32.0)
expect(os10.hpBonusPct).toBe(270) // 9 * 30%
expect(os10.manaCost).toBe(24)
expect(os10.manaCost256).toBe(6144)
// slvl 20: 125% life, radius 68 subtiles (45.3 yards), mana 34
const os20 = calculateOakSageStats(20)
expect(os20.lifeBonusPct).toBe(125)
expect(os20.radiusSubtiles).toBe(68)
expect(os20.radiusYards).toBe(45.3)
expect(os20.hpBonusPct).toBe(570)
expect(os20.manaCost).toBe(34)
expect(os20.manaCost256).toBe(8704)
})
it('1.4 Summon Spirit Wolf (227): Evaluates 5-band damage scaling and passive AR/Def bonuses', () => {
// slvl 1: 2-6 damage, passive +50% AR, +50% Def, max 1 wolf
const sw1 = calculateSpiritWolfStats(1)
expect(sw1.maxWolves).toBe(1)
expect(sw1.minDamage).toBe(2)
expect(sw1.maxDamage).toBe(6)
expect(sw1.passiveArBonusPct).toBe(50)
expect(sw1.passiveDefBonusPct).toBe(50)
expect(sw1.manaCost).toBe(15)
expect(sw1.coldDamagePct).toBe(50)
// slvl 5: max 5 wolves
const sw5 = calculateSpiritWolfStats(5)
expect(sw5.maxWolves).toBe(5)
// slvl 10: base: 2+7*1+2*2 = 13 min, 6+7*1+2*2 = 17 max
const sw10 = calculateSpiritWolfStats(10)
expect(sw10.maxWolves).toBe(5)
expect(sw10.minDamage).toBe(13)
expect(sw10.maxDamage).toBe(17)
expect(sw10.passiveArBonusPct).toBe(275) // 50 + 9*25
expect(sw10.passiveDefBonusPct).toBe(140) // 50 + 9*10
// slvl 20: base: 13+6*2+4*4 = 41 min, 17+6*2+4*4 = 45 max
const sw20 = calculateSpiritWolfStats(20)
expect(sw20.minDamage).toBe(41)
expect(sw20.maxDamage).toBe(45)
// With synergies: 10 hard points in Dire Wolf (+275% life) and 10 in Grizzly (+115% dmg)
const sw20Syn = calculateSpiritWolfStats(20, { direWolfBlvl: 10, grizzlyBlvl: 10 })
expect(sw20Syn.passiveLifeBonusPct).toBe(275)
expect(sw20Syn.passiveDmgBonusPct).toBe(115)
expect(sw20Syn.minDamage).toBe(Math.trunc((41 * 215) / 100)) // 88
expect(sw20Syn.maxDamage).toBe(Math.trunc((45 * 215) / 100)) // 96
})
it('1.5 Carrion Vine (231): Evaluates corpse heal % diminishing returns and fixed 10 mana', () => {
// slvl 1: heal 4% max HP (dm12 with 3, 12)
const cv1 = calculateCarrionVineStats(1)
expect(cv1.maxPets).toBe(1)
expect(cv1.healLifePct).toBe(4)
expect(cv1.searchRadiusSubtiles).toBe(10)
expect(cv1.manaCost).toBe(10)
expect(cv1.manaCost256).toBe(2560)
// slvl 10: diminishing returns between 3 and 12 -> 9%
const cv10 = calculateCarrionVineStats(10)
expect(cv10.healLifePct).toBe(9)
expect(cv10.hpBonusPct).toBe(225) // 9 * 25%
// slvl 20: 10%
const cv20 = calculateCarrionVineStats(20)
expect(cv20.healLifePct).toBe(10)
})
it('1.6 Heart of Wolverine (236): Evaluates party Enhanced Damage% and Attack Rating% aura', () => {
// slvl 1: +20% ED, +25% AR, radius 30 subtiles (20 yards), mana 20
const hw1 = calculateHeartOfWolverineStats(1)
expect(hw1.maxPets).toBe(1)
expect(hw1.damageBonusPct).toBe(20)
expect(hw1.attackRatingBonusPct).toBe(25)
expect(hw1.radiusSubtiles).toBe(30)
expect(hw1.manaCost).toBe(20)
expect(hw1.manaCost256).toBe(5120)
// slvl 10: 20 + 9*7 = 83% ED, 25 + 9*7 = 88% AR, radius 48 subtiles, mana 29
const hw10 = calculateHeartOfWolverineStats(10)
expect(hw10.damageBonusPct).toBe(83)
expect(hw10.attackRatingBonusPct).toBe(88)
expect(hw10.radiusSubtiles).toBe(48)
expect(hw10.manaCost).toBe(29)
expect(hw10.manaCost256).toBe(7424)
// slvl 20: 20 + 19*7 = 153% ED, 25 + 19*7 = 158% AR, radius 68 subtiles, mana 39
const hw20 = calculateHeartOfWolverineStats(20)
expect(hw20.damageBonusPct).toBe(153)
expect(hw20.attackRatingBonusPct).toBe(158)
expect(hw20.radiusSubtiles).toBe(68)
expect(hw20.manaCost).toBe(39)
})
it('1.7 Summon Dire Wolf (237): Evaluates 5-band damage scaling, passive Life% bonus, and frenzy', () => {
// slvl 1: 7-12 damage, +50% life bonus, max 1 wolf, 20 mana
const dw1 = calculateDireWolfStats(1)
expect(dw1.maxWolves).toBe(1)
expect(dw1.minDamage).toBe(7)
expect(dw1.maxDamage).toBe(12)
expect(dw1.passiveLifeBonusPct).toBe(50)
expect(dw1.corpseFrenzyDamageBonusPct).toBe(100)
expect(dw1.corpseFrenzyDurationFrames).toBe(500)
expect(dw1.manaCost).toBe(20)
expect(dw1.manaCost256).toBe(5120)
// slvl 3: max 3 wolves
const dw3 = calculateDireWolfStats(3)
expect(dw3.maxWolves).toBe(3)
// slvl 10: base: 7+7*2+2*3 = 27 min, 12+7*2+2*3 = 32 max
const dw10 = calculateDireWolfStats(10)
expect(dw10.maxWolves).toBe(3)
expect(dw10.minDamage).toBe(27)
expect(dw10.maxDamage).toBe(32)
expect(dw10.passiveLifeBonusPct).toBe(275) // 50 + 9*25
// slvl 20: base: 27+6*3+4*6 = 69 min, 32+6*3+4*6 = 74 max
const dw20 = calculateDireWolfStats(20)
expect(dw20.minDamage).toBe(69)
expect(dw20.maxDamage).toBe(74)
expect(dw20.passiveLifeBonusPct).toBe(525) // 50 + 19*25
// With Grizzly synergy (+115% damage)
const dw20Syn = calculateDireWolfStats(20, { grizzlyBlvl: 10 })
expect(dw20Syn.minDamage).toBe(Math.trunc((69 * 215) / 100)) // 148
expect(dw20Syn.maxDamage).toBe(Math.trunc((74 * 215) / 100)) // 159
})
it('1.8 Solar Creeper (241): Evaluates corpse mana % diminishing returns and mana scaling', () => {
// slvl 1: restore 2% max mana, mana 14 (dm12 with 1, 8)
const sc1 = calculateSolarCreeperStats(1)
expect(sc1.maxPets).toBe(1)
expect(sc1.restoreManaPct).toBe(2)
expect(sc1.searchRadiusSubtiles).toBe(10)
expect(sc1.manaCost).toBe(14)
expect(sc1.manaCost256).toBe(3584)
// slvl 10: 5% mana, mana 23
const sc10 = calculateSolarCreeperStats(10)
expect(sc10.restoreManaPct).toBe(5)
expect(sc10.hpBonusPct).toBe(180) // 9 * 20%
expect(sc10.manaCost).toBe(23)
expect(sc10.manaCost256).toBe(5888)
// slvl 20: 6% mana, mana 33
const sc20 = calculateSolarCreeperStats(20)
expect(sc20.restoreManaPct).toBe(6)
expect(sc20.manaCost).toBe(33)
})
it('1.9 Spirit of Barbs (246): Evaluates thorns reflection % scaling and mana progression', () => {
// slvl 1: 50% thorns, radius 30 subtiles (20 yards), mana 25
const sb1 = calculateSpiritOfBarbsStats(1)
expect(sb1.maxPets).toBe(1)
expect(sb1.thornsReturnPct).toBe(50)
expect(sb1.radiusSubtiles).toBe(30)
expect(sb1.radiusYards).toBe(20.0)
expect(sb1.manaCost).toBe(25)
expect(sb1.manaCost256).toBe(6400)
// slvl 10: 50 + 9*10 = 140% thorns, mana 34
const sb10 = calculateSpiritOfBarbsStats(10)
expect(sb10.thornsReturnPct).toBe(140)
expect(sb10.manaCost).toBe(34)
expect(sb10.manaCost256).toBe(8704)
// slvl 20: 50 + 19*10 = 240% thorns, mana 44
const sb20 = calculateSpiritOfBarbsStats(20)
expect(sb20.thornsReturnPct).toBe(240)
expect(sb20.manaCost).toBe(44)
})
it('1.10 Summon Grizzly (247): Evaluates apex solo pet damage, Smite stun, and passive synergy', () => {
// slvl 1: 30-60 damage, +25% passive damage, smite stun 15 frames, 40 mana
const gz1 = calculateGrizzlyStats(1)
expect(gz1.maxPets).toBe(1)
expect(gz1.minDamage).toBe(30)
expect(gz1.maxDamage).toBe(60)
expect(gz1.passiveDmgBonusPct).toBe(25)
expect(gz1.smiteStunFrames).toBe(15)
expect(gz1.smiteKnockback).toBe(true)
expect(gz1.manaCost).toBe(40)
expect(gz1.manaCost256).toBe(10240)
// slvl 10: base: 30+7*10+2*15 = 130 min, 60+7*10+2*15 = 160 max
const gz10 = calculateGrizzlyStats(10)
expect(gz10.minDamage).toBe(130)
expect(gz10.maxDamage).toBe(160)
expect(gz10.passiveDmgBonusPct).toBe(115) // 25 + 9*10
expect(gz10.smiteStunFrames).toBe(60) // 15 + 9*5
// slvl 20: base: 130+6*15+4*20 = 300 min, 160+6*15+4*20 = 330 max
const gz20 = calculateGrizzlyStats(20)
expect(gz20.minDamage).toBe(300)
expect(gz20.maxDamage).toBe(330)
expect(gz20.passiveDmgBonusPct).toBe(215) // 25 + 19*10
expect(gz20.smiteStunFrames).toBe(110) // 15 + 19*5
})
})
// =========================================================================
// Dimension 2: Pet Group Exclusivity & Slot Eviction Matrix
// =========================================================================
describe('Dimension 2: Pet Group Exclusivity & Slot Eviction Matrix (5)', () => {
it('2.1 Group 1 (Wolf & Bear): Grizzly and wolves mutually evict one another', () => {
let activePets: ActivePetRecord[] = []
// Summon 3 Spirit Wolves
for (let i = 1; i <= 3; i++) {
const res = resolvePetSummon(activePets, {
id: `wolf_${i}`,
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF,
slvl: 5,
})
activePets = res.newActivePets
}
expect(activePets.length).toBe(3)
expect(activePets.every(p => p.petType === 'spiritwolf')).toBe(true)
// Summoning Grizzly MUST evict all 3 Spirit Wolves!
const gzRes = resolvePetSummon(activePets, {
id: 'grizzly_1',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY,
slvl: 1,
})
expect(gzRes.evictedPetIds).toEqual(['wolf_1', 'wolf_2', 'wolf_3'])
expect(gzRes.newActivePets.length).toBe(1)
expect(gzRes.newActivePets[0]!.petType).toBe('grizzly')
activePets = gzRes.newActivePets
// Summoning Dire Wolf MUST evict Grizzly!
const dwRes = resolvePetSummon(activePets, {
id: 'dire_1',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF,
slvl: 1,
})
expect(dwRes.evictedPetIds).toEqual(['grizzly_1'])
expect(dwRes.newActivePets.length).toBe(1)
expect(dwRes.newActivePets[0]!.petType).toBe('fenris')
})
it('2.2 Group 2 (Spirit Totems): Only 1 spirit totem can be active at a time', () => {
let activePets: ActivePetRecord[] = []
// Summon Oak Sage
const r1 = resolvePetSummon(activePets, {
id: 'spirit_oak',
skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE,
slvl: 1,
})
activePets = r1.newActivePets
expect(activePets.length).toBe(1)
expect(activePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.OAK_SAGE)
// Summon Heart of Wolverine -> evicts Oak Sage
const r2 = resolvePetSummon(activePets, {
id: 'spirit_wolverine',
skillId: DRUID_SUMMON_SKILL_IDS.HEART_OF_WOLVERINE,
slvl: 1,
})
expect(r2.evictedPetIds).toEqual(['spirit_oak'])
expect(r2.newActivePets.length).toBe(1)
expect(r2.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.HEART_OF_WOLVERINE)
activePets = r2.newActivePets
// Summon Spirit of Barbs -> evicts Heart of Wolverine
const r3 = resolvePetSummon(activePets, {
id: 'spirit_barbs',
skillId: DRUID_SUMMON_SKILL_IDS.SPIRIT_OF_BARBS,
slvl: 1,
})
expect(r3.evictedPetIds).toEqual(['spirit_wolverine'])
expect(r3.newActivePets.length).toBe(1)
expect(r3.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.SPIRIT_OF_BARBS)
})
it('2.3 Group 3 (Vines): Only 1 vine creature can be active at a time', () => {
let activePets: ActivePetRecord[] = []
// Summon Poison Creeper
const r1 = resolvePetSummon(activePets, {
id: 'vine_poison',
skillId: DRUID_SUMMON_SKILL_IDS.POISON_CREEPER,
slvl: 1,
})
activePets = r1.newActivePets
expect(activePets.length).toBe(1)
// Summon Carrion Vine -> evicts Poison Creeper
const r2 = resolvePetSummon(activePets, {
id: 'vine_carrion',
skillId: DRUID_SUMMON_SKILL_IDS.CARRION_VINE,
slvl: 1,
})
expect(r2.evictedPetIds).toEqual(['vine_poison'])
expect(r2.newActivePets.length).toBe(1)
expect(r2.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.CARRION_VINE)
activePets = r2.newActivePets
// Summon Solar Creeper -> evicts Carrion Vine
const r3 = resolvePetSummon(activePets, {
id: 'vine_solar',
skillId: DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER,
slvl: 1,
})
expect(r3.evictedPetIds).toEqual(['vine_carrion'])
expect(r3.newActivePets.length).toBe(1)
expect(r3.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER)
})
it('2.4 Group 0 (Ravens): Ravens can coexist with wolf/bear, spirit, and vine simultaneously', () => {
let activePets: ActivePetRecord[] = []
// Summon Grizzly (Group 1)
activePets = resolvePetSummon(activePets, {
id: 'bear',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY,
slvl: 1,
}).newActivePets
// Summon Oak Sage (Group 2)
activePets = resolvePetSummon(activePets, {
id: 'spirit',
skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE,
slvl: 1,
}).newActivePets
// Summon Carrion Vine (Group 3)
activePets = resolvePetSummon(activePets, {
id: 'vine',
skillId: DRUID_SUMMON_SKILL_IDS.CARRION_VINE,
slvl: 1,
}).newActivePets
// Summon 5 Ravens (Group 0)
for (let i = 1; i <= 5; i++) {
const res = resolvePetSummon(activePets, {
id: `raven_${i}`,
skillId: DRUID_SUMMON_SKILL_IDS.RAVEN,
slvl: 5,
})
expect(res.evictedPetIds).toEqual([]) // None evicted!
activePets = res.newActivePets
}
// Total active pets = 1 Grizzly + 1 Spirit + 1 Vine + 5 Ravens = 8 pets!
expect(activePets.length).toBe(8)
expect(activePets.filter(p => p.petGroup === DruidPetGroup.RAVEN).length).toBe(5)
expect(activePets.filter(p => p.petGroup === DruidPetGroup.WOLF_BEAR).length).toBe(1)
expect(activePets.filter(p => p.petGroup === DruidPetGroup.SPIRIT).length).toBe(1)
expect(activePets.filter(p => p.petGroup === DruidPetGroup.VINE).length).toBe(1)
})
it('2.5 FIFO Eviction: Enforces species caps and replaces oldest pet', () => {
let activePets: ActivePetRecord[] = []
// Summon 5 Spirit Wolves
for (let i = 1; i <= 5; i++) {
activePets = resolvePetSummon(activePets, {
id: `sw_${i}`,
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF,
slvl: 5,
}).newActivePets
}
expect(activePets.length).toBe(5)
// Summon 6th Spirit Wolf -> evicts sw_1 (FIFO)
const res6 = resolvePetSummon(activePets, {
id: 'sw_6',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF,
slvl: 5,
})
expect(res6.evictedPetIds).toEqual(['sw_1'])
expect(res6.newActivePets.length).toBe(5)
expect(res6.newActivePets.map(p => p.id)).toEqual(['sw_2', 'sw_3', 'sw_4', 'sw_5', 'sw_6'])
})
})
// =========================================================================
// Dimension 3: Kinematics, Corpse Cycles & Combat Synergies
// =========================================================================
describe('Dimension 3: Kinematics, Corpse Cycles & Combat Synergies (4)', () => {
it('3.1 Raven peck: Decrements hits counter, inflicts blind, and departs at 0 hits', () => {
let hits = 3
const target = { isImmuneToCurse: false }
// Peck 1
const p1 = simulateRavenPeck(hits, target)
expect(p1.hitsRemainingAfter).toBe(2)
expect(p1.blindInflicted).toBe(true)
expect(p1.dismissed).toBe(false)
hits = p1.hitsRemainingAfter
// Peck 2
const p2 = simulateRavenPeck(hits, target)
expect(p2.hitsRemainingAfter).toBe(1)
expect(p2.dismissed).toBe(false)
hits = p2.hitsRemainingAfter
// Peck 3 (Final)
const p3 = simulateRavenPeck(hits, target)
expect(p3.hitsRemainingAfter).toBe(0)
expect(p3.dismissed).toBe(true)
// Curse immune target (Boss)
const bossTarget = { isImmuneToCurse: true }
const pBoss = simulateRavenPeck(5, bossTarget)
expect(pBoss.blindInflicted).toBe(false)
})
it('3.2 Vine corpse consumption: Carrion heals life, Solar restores mana, ignores consumed/boss corpses', () => {
const owner = { maxHp: 1000, maxMana: 500 }
// Carrion Vine slvl 10 (9% max HP = 90 HP)
const corpse1 = { consumed: false, isBoss: false }
const cRes = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, corpse1, owner)
expect(cRes.corpseConsumed).toBe(true)
expect(cRes.lifeHealed).toBe(90)
expect(cRes.manaRestored).toBe(0)
expect(corpse1.consumed).toBe(true)
// Trying to consume already consumed corpse fails
const cRes2 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, corpse1, owner)
expect(cRes2.corpseConsumed).toBe(false)
expect(cRes2.lifeHealed).toBe(0)
// Solar Creeper slvl 10 (5% max Mana = 25 Mana)
const corpse2 = { consumed: false, isBoss: false }
const sRes = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER, 10, corpse2, owner)
expect(sRes.corpseConsumed).toBe(true)
expect(sRes.manaRestored).toBe(25)
expect(sRes.lifeHealed).toBe(0)
// Boss corpses cannot be eaten by vines
const bossCorpse = { consumed: false, isBoss: true }
const bRes = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, bossCorpse, owner)
expect(bRes.corpseConsumed).toBe(false)
expect(bossCorpse.consumed).toBe(false)
})
it('3.3 Dire Wolf corpse frenzy: Consumes combat corpse to activate 500-frame +100% damage rage', () => {
const corpse = { consumed: false, isBoss: false }
const out = simulateDireWolfCorpseFrenzy(corpse)
expect(out.consumed).toBe(true)
expect(out.rageActivated).toBe(true)
expect(out.rageDurationFrames).toBe(500)
expect(out.damageMultiplier).toBe(2.0) // +100%
expect(corpse.consumed).toBe(true)
// Already consumed corpse cannot re-trigger frenzy
const out2 = simulateDireWolfCorpseFrenzy(corpse)
expect(out2.consumed).toBe(false)
expect(out2.rageActivated).toBe(false)
})
it('3.4 Wolf & Grizzly cross-species synergy network correctly calculates mutual bonuses', () => {
const syn = calculateDruidSummonSynergyProfile({
spiritWolf: 10, // +275% AR & Def
direWolf: 10, // +275% Life
grizzly: 10, // +115% Damage
})
expect(syn.spiritWolfArDefBonusPct).toBe(275)
expect(syn.direWolfLifeBonusPct).toBe(275)
expect(syn.grizzlyDmgBonusPct).toBe(115)
})
})
// =========================================================================
// Dimension 4: Invariants, DataRegistry & SkillModule Architecture
// =========================================================================
describe('Dimension 4: Invariants, DataRegistry & SkillModule Architecture (4)', () => {
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 Summoning skills are properly defined in SkillModule architecture', async () => {
const summonIds = [221, 222, 226, 227, 231, 236, 237, 241, 246, 247]
const { skillModule: m221 } = await import('../../../src/game/skills/impl/dru/skill-221-raven.ts')
const { skillModule: m222 } = await import('../../../src/game/skills/impl/dru/skill-222-plague-poppy.ts')
const { skillModule: m226 } = await import('../../../src/game/skills/impl/dru/skill-226-oak-sage.ts')
const { skillModule: m227 } = await import('../../../src/game/skills/impl/dru/skill-227-summon-spirit-wolf.ts')
const { skillModule: m231 } = await import('../../../src/game/skills/impl/dru/skill-231-cycle-of-life.ts')
const { skillModule: m236 } = await import('../../../src/game/skills/impl/dru/skill-236-heart-of-wolverine.ts')
const { skillModule: m237 } = await import('../../../src/game/skills/impl/dru/skill-237-summon-fenris.ts')
const { skillModule: m241 } = await import('../../../src/game/skills/impl/dru/skill-241-vines.ts')
const { skillModule: m246 } = await import('../../../src/game/skills/impl/dru/skill-246-spirit-of-barbs.ts')
const { skillModule: m247 } = await import('../../../src/game/skills/impl/dru/skill-247-summon-grizzly.ts')
const modules = [m221, m222, m226, m227, m231, m236, m237, m241, m246, m247]
for (let i = 0; i < summonIds.length; i++) {
const id = summonIds[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 Summoning skills from Patch_D2.mpq with exact attributes', async () => {
const registry = await getSharedDataRegistry()
const druSummonIds = [221, 222, 226, 227, 231, 236, 237, 241, 246, 247]
for (const id of druSummonIds) {
const rec = registry.getSkillById(id)
expect(rec).toBeDefined()
expect(rec!.charClass).toBe('dru')
expect(rec!.summon).toBeTruthy()
}
})
it('4.4 evaluateSkill113c evaluates all 10 Druid Summoning skills against UnitStatList with exact attributes', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
// Raven (221)
const rRaven = evaluateSkill113c({
registry,
skill: registry.getSkillById(221)!,
slvl: 10,
blvl: 10,
statList: stats,
})
expect(rRaven.manaCost).toBe(6)
expect(rRaven.minPhysDmg).toBe(11)
expect(rRaven.maxPhysDmg).toBe(13)
expect(rRaven.petMax).toBe(5)
// Oak Sage (226)
const rOak = evaluateSkill113c({
registry,
skill: registry.getSkillById(226)!,
slvl: 10,
blvl: 10,
statList: stats,
})
expect(rOak.manaCost).toBe(24)
expect(rOak.radiusSubtiles).toBe(75)
expect(rOak.petMax).toBe(1)
// Spirit Wolf (227)
const rSW = evaluateSkill113c({
registry,
skill: registry.getSkillById(227)!,
slvl: 10,
blvl: 10,
statList: stats,
})
expect(rSW.manaCost).toBe(15)
expect(rSW.minPhysDmg).toBe(13)
expect(rSW.maxPhysDmg).toBe(17)
expect(rSW.petMax).toBe(5)
// Grizzly (247)
const rGz = evaluateSkill113c({
registry,
skill: registry.getSkillById(247)!,
slvl: 10,
blvl: 10,
statList: stats,
})
expect(rGz.manaCost).toBe(40)
expect(rGz.minPhysDmg).toBe(130)
expect(rGz.maxPhysDmg).toBe(160)
expect(rGz.petMax).toBe(1)
})
})
})