From 853f61d634c77494d73278fb8e5e88ed23ec2f69 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 25 Sep 2026 03:06:36 +0000 Subject: [PATCH] feat(dru): Milestone M24 Druid Summoning skills, pet exclusivity, spirit auras, corpse cycles, and stress suite (closes #471) --- src/game/skills/druid-summon.ts | 783 ++++++++++++++++++ .../skills/dru/adv-dru-summon-stress.test.ts | 696 ++++++++++++++++ 2 files changed, 1479 insertions(+) create mode 100644 src/game/skills/druid-summon.ts create mode 100644 tests/skills/dru/adv-dru-summon-stress.test.ts diff --git a/src/game/skills/druid-summon.ts b/src/game/skills/druid-summon.ts new file mode 100644 index 0000000..e3f1940 --- /dev/null +++ b/src/game/skills/druid-summon.ts @@ -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 + } +} diff --git a/tests/skills/dru/adv-dru-summon-stress.test.ts b/tests/skills/dru/adv-dru-summon-stress.test.ts new file mode 100644 index 0000000..58a4227 --- /dev/null +++ b/tests/skills/dru/adv-dru-summon-stress.test.ts @@ -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) + }) + }) +})