diablo2-web/tests/skills/dru/adv-dru-challenger-summon-s...

927 lines
39 KiB
TypeScript

/**
* Milestone M24 / Cohort 6 — Druid Summoning Adversarial Stress Testing Suite 2
*
* Empirical challenger verification suite probing:
* 1. Pet Exclusivity Groups (Spirits, Wolves/Bear, Vines, Ravens)
* 2. Raven Hit Count Lifecycle & Despawn
* 3. Wolf & Bear Cross-Species Passive Synergies & AR/Def Divergence
* 4. Vine Corpse Consumption, Healing, and Shattered/Non-Corpse Rejection
* 5. Spirit Aura Dissemination to Allies, Mercenaries & Minions
*/
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,
isVineCorpseSkill,
simulateDireWolfCorpseFrenzy,
type ActivePetRecord,
} from '../../../src/game/skills/druid-summon.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList, FIXED_ONE } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
describe('Cohort 6: Druid Summoning Pet Caps, Synergies & Corpses — Adversarial Challenge Suite', () => {
// =========================================================================
// 1. Pet Exclusivity Groups & Caps
// =========================================================================
describe('1. Pet Exclusivity Groups & Caps', () => {
it('1.1 Spirits: Heart of Wolverine replaces active Oak Sage or Spirit of Barbs (max 1)', () => {
let pets: ActivePetRecord[] = []
// Summon Oak Sage
const rOak = resolvePetSummon(pets, {
id: 'oak_1',
skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE,
slvl: 10,
})
pets = rOak.newActivePets
expect(pets.length).toBe(1)
expect(pets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.OAK_SAGE)
// Summon Heart of Wolverine -> evicts Oak Sage
const rWolverine = resolvePetSummon(pets, {
id: 'wolverine_1',
skillId: DRUID_SUMMON_SKILL_IDS.HEART_OF_WOLVERINE,
slvl: 10,
})
expect(rWolverine.evictedPetIds).toEqual(['oak_1'])
expect(rWolverine.newActivePets.length).toBe(1)
expect(rWolverine.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.HEART_OF_WOLVERINE)
pets = rWolverine.newActivePets
// Summon Spirit of Barbs -> evicts Heart of Wolverine
const rBarbs = resolvePetSummon(pets, {
id: 'barbs_1',
skillId: DRUID_SUMMON_SKILL_IDS.SPIRIT_OF_BARBS,
slvl: 10,
})
expect(rBarbs.evictedPetIds).toEqual(['wolverine_1'])
expect(rBarbs.newActivePets.length).toBe(1)
expect(rBarbs.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.SPIRIT_OF_BARBS)
})
it('1.2 Wolves & Bear: Summon Grizzly evicts Spirit Wolves and Dire Wolves; Spirit Wolves evict Grizzly', () => {
let pets: ActivePetRecord[] = []
// Summon 5 Spirit Wolves
for (let i = 1; i <= 5; i++) {
pets = resolvePetSummon(pets, {
id: `sw_${i}`,
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF,
slvl: 5,
}).newActivePets
}
expect(pets.length).toBe(5)
// Summon Grizzly -> evicts all 5 Spirit Wolves
const rGz = resolvePetSummon(pets, {
id: 'bear_1',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY,
slvl: 5,
})
expect(rGz.evictedPetIds).toEqual(['sw_1', 'sw_2', 'sw_3', 'sw_4', 'sw_5'])
expect(rGz.newActivePets.length).toBe(1)
expect(rGz.newActivePets[0]!.petType).toBe('grizzly')
pets = rGz.newActivePets
// Summon Dire Wolf -> evicts Grizzly
const rDW = resolvePetSummon(pets, {
id: 'dw_1',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF,
slvl: 5,
})
expect(rDW.evictedPetIds).toEqual(['bear_1'])
expect(rDW.newActivePets.length).toBe(1)
expect(rDW.newActivePets[0]!.petType).toBe('fenris')
pets = rDW.newActivePets
// Summon Spirit Wolf -> evicts Dire Wolf
const rSW = resolvePetSummon(pets, {
id: 'sw_new',
skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF,
slvl: 5,
})
expect(rSW.evictedPetIds).toEqual(['dw_1'])
expect(rSW.newActivePets.length).toBe(1)
expect(rSW.newActivePets[0]!.petType).toBe('spiritwolf')
})
it('1.3 Vines: Carrion Vine replaces Poison Creeper or Solar Creeper (max 1)', () => {
let pets: ActivePetRecord[] = []
// Summon Poison Creeper
pets = resolvePetSummon(pets, {
id: 'pc_1',
skillId: DRUID_SUMMON_SKILL_IDS.POISON_CREEPER,
slvl: 1,
}).newActivePets
expect(pets.length).toBe(1)
// Summon Carrion Vine -> evicts Poison Creeper
const rCV = resolvePetSummon(pets, {
id: 'cv_1',
skillId: DRUID_SUMMON_SKILL_IDS.CARRION_VINE,
slvl: 1,
})
expect(rCV.evictedPetIds).toEqual(['pc_1'])
expect(rCV.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.CARRION_VINE)
pets = rCV.newActivePets
// Summon Solar Creeper -> evicts Carrion Vine
const rSC = resolvePetSummon(pets, {
id: 'sc_1',
skillId: DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER,
slvl: 1,
})
expect(rSC.evictedPetIds).toEqual(['cv_1'])
expect(rSC.newActivePets[0]!.skillId).toBe(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER)
})
it('1.4 Ravens: Up to 5 ravens coexist alongside active spirit, wolves/bear, and vine (total 8 pets)', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
// 1. Bear
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, slvl: 1 })
// 2. Spirit
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE, slvl: 1 })
// 3. Vine
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.CARRION_VINE, slvl: 1 })
// 4. 5 Ravens
for (let i = 1; i <= 5; i++) {
const out = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 5 })
expect(out.evictedPetUids).toEqual([])
}
const active = sm.getActivePets()
expect(active.length).toBe(8)
expect(active.filter(p => p.petType === 'grizzly').length).toBe(1)
expect(active.filter(p => p.petType === 'totem').length).toBe(1)
expect(active.filter(p => p.petType === 'vine').length).toBe(1)
expect(active.filter(p => p.petType === 'raven').length).toBe(5)
})
})
// =========================================================================
// 2. Raven Hit Count Lifecycle
// =========================================================================
describe('2. Raven Hit Count Lifecycle', () => {
it('2.1 Pure formula: hits remaining equals 11 + slvl per 1.13c ground truth', () => {
expect(calculateRavenStats(1).hitsRemaining).toBe(12) // 11 + 1
expect(calculateRavenStats(5).hitsRemaining).toBe(16) // 11 + 5
expect(calculateRavenStats(10).hitsRemaining).toBe(21) // 11 + 10
expect(calculateRavenStats(20).hitsRemaining).toBe(31) // 11 + 20
})
it('2.2 simulateRavenPeck: decrements hits and despawns upon reaching 0', () => {
let hits = 2
const target = { isImmuneToCurse: false }
const peck1 = simulateRavenPeck(hits, target)
expect(peck1.hitsRemainingAfter).toBe(1)
expect(peck1.blindInflicted).toBe(true)
expect(peck1.dismissed).toBe(false)
const peck2 = simulateRavenPeck(peck1.hitsRemainingAfter, target)
expect(peck2.hitsRemainingAfter).toBe(0)
expect(peck2.dismissed).toBe(true) // Despawns!
})
it('2.3 Runtime SummonManager initializes Raven shotsRemaining to 11 + slvl', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const out = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 1 })
const raven = out.pet!
// In 1.13c, Raven slvl 1 must have 12 hits remaining (11 + 1).
expect(raven.shotsRemaining).toBe(12)
})
it('2.4 Runtime SummonManager.tickPets decrements Raven hit count on attack and despawns upon exhaustion', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const enemyStats = new UnitStatList(reg, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
const enemyBus = new StateBus(enemyStats, reg)
const enemy: CombatUnitContext = { id: 'dummy', name: 'Target_Dummy', statList: enemyStats, stateBus: enemyBus }
const enemyPositions = new Map([['dummy', { x: 0, y: 100 }]])
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
const out = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 1 })
const raven = out.pet!
// Step 50 ticks (5 melee attacks at every 10th tick: 12 - 5 = 7 hits remaining)
for (let tick = 1; tick <= 50; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mEngine,
auraScanner: aScanner,
})
}
expect(raven.shotsRemaining).toBe(7)
expect(sm.getActivePets().length).toBe(1)
// Step 70 more ticks (7 more attacks: 7 - 7 = 0 hits remaining -> despawned)
for (let tick = 51; tick <= 120; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mEngine,
auraScanner: aScanner,
})
}
expect(sm.getActivePets().length).toBe(0)
})
it('2.5 Multi-Raven concurrent attrition: independent hit pools and sequential dismissal', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const enemyStats = new UnitStatList(reg, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE })
const enemyBus = new StateBus(enemyStats, reg)
const enemy: CombatUnitContext = { id: 'dummy', name: 'Target_Dummy', statList: enemyStats, stateBus: enemyBus }
const enemyPositions = new Map([['dummy', { x: 0, y: 100 }]])
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
// Summon 3 Ravens at different slvls:
// Raven 1 (slvl 1): 11 + 1 = 12 hits
// Raven 2 (slvl 5): 11 + 5 = 16 hits
// Raven 3 (slvl 10): 11 + 10 = 21 hits
const r1 = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 1 }).pet!
const r2 = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 5 }).pet!
const r3 = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 10 }).pet!
expect(r1.shotsRemaining).toBe(12)
expect(r2.shotsRemaining).toBe(16)
expect(r3.shotsRemaining).toBe(21)
expect(sm.getActivePets().length).toBe(3)
// Step 120 ticks: 12 attack opportunities (ticks 10, 20, ..., 120)
for (let tick = 1; tick <= 120; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mEngine,
auraScanner: aScanner,
})
}
// Raven 1 (12 hits) is exhausted and dismissed!
// Raven 2 (16 - 12 = 4 hits left)
// Raven 3 (21 - 12 = 9 hits left)
expect(r2.shotsRemaining).toBe(4)
expect(r3.shotsRemaining).toBe(9)
expect(sm.getActivePets().length).toBe(2)
expect(sm.getActivePets().some(p => p.uid === r1.uid)).toBe(false)
// Step 40 more ticks (ticks 121..160: 4 more attacks)
for (let tick = 121; tick <= 160; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mEngine,
auraScanner: aScanner,
})
}
// Raven 2 is exhausted and dismissed!
// Raven 3 (9 - 4 = 5 hits left)
expect(r3.shotsRemaining).toBe(5)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets().some(p => p.uid === r2.uid)).toBe(false)
// Step 50 more ticks (ticks 161..210: 5 more attacks)
for (let tick = 161; tick <= 210; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: mEngine,
auraScanner: aScanner,
})
}
// All ravens now exhausted and dismissed!
expect(sm.getActivePets().length).toBe(0)
})
it('2.6 Raven idling invariant: shotsRemaining does not decrement without combat targets', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
const out = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl: 7 })
const raven = out.pet!
expect(raven.shotsRemaining).toBe(18) // 11 + 7
// Tick 200 times with no enemies present
for (let tick = 1; tick <= 200; tick++) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: mEngine,
auraScanner: aScanner,
})
}
// Shots remaining remains perfectly intact
expect(raven.shotsRemaining).toBe(18)
expect(sm.getActivePets().length).toBe(1)
})
it('2.7 Raven parametric sweep across slvl 1..99 matches 11 + slvl', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
for (let slvl = 1; slvl <= 99; slvl++) {
const pure = calculateRavenStats(slvl)
expect(pure.hitsRemaining).toBe(11 + slvl)
const out = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.RAVEN, slvl })
expect(out.pet!.shotsRemaining).toBe(11 + slvl)
}
})
})
// =========================================================================
// 3. Wolf & Bear Cross-Species Passive Synergies
// =========================================================================
describe('3. Wolf & Bear Cross-Species Passive Synergies', () => {
it('3.1 Formulas: Spirit Wolf gives AR (+25%/lvl) and Defense (+10%/lvl), Dire Wolf gives Life (+25%/lvl), Grizzly gives Damage (+10%/lvl)', () => {
// Spirit Wolf slvl 10: +275% AR (50 + 9*25), +140% Def (50 + 9*10)
const sw10 = calculateSpiritWolfStats(10)
expect(sw10.passiveArBonusPct).toBe(275)
expect(sw10.passiveDefBonusPct).toBe(140)
// Dire Wolf slvl 10: +275% Life (50 + 9*25)
const dw10 = calculateDireWolfStats(10)
expect(dw10.passiveLifeBonusPct).toBe(275)
// Grizzly slvl 10: +115% Damage (25 + 9*10)
const gz10 = calculateGrizzlyStats(10)
expect(gz10.passiveDmgBonusPct).toBe(115)
// Cross-synergy wiring into Dire Wolf:
const dwWithSyn = calculateDireWolfStats(10, { spiritWolfBlvl: 10, grizzlyBlvl: 10 })
expect(dwWithSyn.passiveArBonusPct).toBe(275)
expect(dwWithSyn.passiveDefBonusPct).toBe(140)
expect(dwWithSyn.passiveDmgBonusPct).toBe(115)
// Cross-synergy wiring into Grizzly:
const gzWithSyn = calculateGrizzlyStats(10, { spiritWolfBlvl: 10, direWolfBlvl: 10 })
expect(gzWithSyn.passiveArBonusPct).toBe(275)
expect(gzWithSyn.passiveDefBonusPct).toBe(140)
expect(gzWithSyn.passiveLifeBonusPct).toBe(275)
})
it('3.2 calculateDruidSummonSynergyProfile provides separated AR and Defense bonuses', () => {
const syn = calculateDruidSummonSynergyProfile({
spiritWolf: 10,
direWolf: 10,
grizzly: 10,
})
expect(syn.spiritWolfArBonusPct).toBe(275)
expect(syn.spiritWolfDefBonusPct).toBe(140)
expect(syn.direWolfLifeBonusPct).toBe(275)
expect(syn.grizzlyDmgBonusPct).toBe(115)
})
it('3.3 Runtime SummonManager.createPet applies cross-species synergies to summoned pets', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
// Player has 20 hard points in all 3 skills
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF, 20)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF, 20)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, 20)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const resGz = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, slvl: 20 })
const gz = resGz.pet!
// In 1.13c, Grizzly at slvl 20 with 20 Spirit Wolf & 20 Dire Wolf:
// - Spirit Wolf synergy: +525% AR (50 + 19*25), +240% Def (50 + 19*10)
// - Grizzly innate passive: +215% Damage (25 + 19*10)
expect(gz.statList.getAccruedStat('item_armor_percent')).toBe(240)
expect(gz.statList.getAccruedStat('item_tohit_percent')).toBe(525)
expect(gz.statList.getAccruedStat('damagepercent')).toBe(215)
})
it('3.4 Full differential matrix sweep (sw 0..30, dw 0..30, gz 0..30) verifying AR and Def divergence', () => {
for (let sw = 0; sw <= 30; sw++) {
const expectedAr = sw > 0 ? 50 + (sw - 1) * 25 : 0
const expectedDef = sw > 0 ? 50 + (sw - 1) * 10 : 0
const syn = calculateDruidSummonSynergyProfile({
spiritWolf: sw,
direWolf: sw,
grizzly: sw,
})
expect(syn.spiritWolfArBonusPct).toBe(expectedAr)
expect(syn.spiritWolfDefBonusPct).toBe(expectedDef)
if (sw >= 2) {
// Verify that AR and Def bonuses strictly diverge (AR scaling is +25%/lvl, Def is +10%/lvl)
expect(syn.spiritWolfArBonusPct).toBeGreaterThan(syn.spiritWolfDefBonusPct)
}
}
})
it('3.5 Hard points vs soft points (+skills) isolation in SummonManager cross-species passives', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
// Set base skill level (hard points) = 0, but simulate +15 all skills on gear
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF, 0)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF, 0)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, 0)
// Gear bonus
stats.addStat('allskills', 15)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
// Grizzly summoned at slvl 20 (with 0 hard points in Spirit Wolf and Dire Wolf):
const resGz = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, slvl: 20 })
const gz = resGz.pet!
// Because Spirit Wolf hard points = 0, Grizzly receives ZERO AR% and ZERO Def% synergy bonus:
expect(gz.statList.getAccruedStat('item_armor_percent')).toBe(0)
expect(gz.statList.getAccruedStat('item_tohit_percent')).toBe(0)
// Only Grizzly's own innate slvl 20 passive damage is active (25 + 19*10 = 215%)
expect(gz.statList.getAccruedStat('damagepercent')).toBe(215)
})
it('3.6 Cross-species synergies populated across all 3 species (Spirit Wolf, Dire Wolf, Grizzly)', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
// Allocate hard points: SW = 12, DW = 8, GZ = 6
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF, 12)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF, 8)
stats.setBaseSkillLevel(DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, 6)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
// SW synergies: AR = 50 + 11*25 = 325%, Def = 50 + 11*10 = 160%
// GZ hard points = 6 -> Dmg = 25 + 5*10 = 75%
// DW hard points = 8 -> Life = 50 + 7*25 = 225%
// 1. Test Spirit Wolf (gets own AR 325%, Def 160%, GZ Dmg 75%, DW Life 225%)
const pSW = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF, slvl: 12 }).pet!
expect(pSW.statList.getAccruedStat('item_tohit_percent')).toBe(325)
expect(pSW.statList.getAccruedStat('item_armor_percent')).toBe(160)
expect(pSW.statList.getAccruedStat('damagepercent')).toBe(75)
// 2. Test Dire Wolf (evicts Spirit Wolf, gets SW AR 325%, Def 160%, GZ Dmg 75%, own Life 225%)
const pDW = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_DIRE_WOLF, slvl: 8 }).pet!
expect(pDW.statList.getAccruedStat('item_tohit_percent')).toBe(325)
expect(pDW.statList.getAccruedStat('item_armor_percent')).toBe(160)
expect(pDW.statList.getAccruedStat('damagepercent')).toBe(75)
// 3. Test Grizzly (evicts Dire Wolf, gets SW AR 325%, Def 160%, own Dmg 75%, DW Life 225%)
const pGZ = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, slvl: 6 }).pet!
expect(pGZ.statList.getAccruedStat('item_tohit_percent')).toBe(325)
expect(pGZ.statList.getAccruedStat('item_armor_percent')).toBe(160)
expect(pGZ.statList.getAccruedStat('damagepercent')).toBe(75)
})
})
// =========================================================================
// 4. Vine Corpse Consumption & Healing
// =========================================================================
describe('4. Vine Corpse Consumption & Healing', () => {
it('4.1 Carrion Vine consumes corpse to heal caster % Max Life', () => {
const owner = { maxHp: 1000, maxMana: 500 }
const corpse = { consumed: false, isBoss: false }
// Carrion Vine slvl 1: 4% max HP (40 HP)
const res1 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 1, corpse, owner)
expect(res1.corpseConsumed).toBe(true)
expect(res1.lifeHealed).toBe(40)
expect(corpse.consumed).toBe(true)
// Carrion Vine slvl 10: 9% max HP (90 HP)
const corpse10 = { consumed: false }
const res10 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, corpse10, owner)
expect(res10.lifeHealed).toBe(90)
// Carrion Vine slvl 20: 10% max HP (100 HP)
const corpse20 = { consumed: false }
const res20 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 20, corpse20, owner)
expect(res20.lifeHealed).toBe(100)
})
it('4.2 Solar Creeper consumes corpse to restore caster % Max Mana', () => {
const owner = { maxHp: 1000, maxMana: 600 }
const corpse = { consumed: false }
// Solar Creeper slvl 1: 2% max Mana (12 Mana)
const res1 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER, 1, corpse, owner)
expect(res1.corpseConsumed).toBe(true)
expect(res1.manaRestored).toBe(12)
expect(corpse.consumed).toBe(true)
// Solar Creeper slvl 10: 5% max Mana (30 Mana)
const corpse10 = { consumed: false }
const res10 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER, 10, corpse10, owner)
expect(res10.manaRestored).toBe(30)
// Solar Creeper slvl 20: 6% max Mana (36 Mana)
const corpse20 = { consumed: false }
const res20 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.SOLAR_CREEPER, 20, corpse20, owner)
expect(res20.manaRestored).toBe(36)
})
it('4.3 Rejection: Already consumed corpses and Act Boss corpses are rejected', () => {
const owner = { maxHp: 1000, maxMana: 500 }
// Already consumed corpse
const cConsumed = { consumed: true, isBoss: false }
const r1 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, cConsumed, owner)
expect(r1.corpseConsumed).toBe(false)
expect(r1.lifeHealed).toBe(0)
// Boss corpse
const cBoss = { consumed: false, isBoss: true }
const r2 = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, cBoss, owner)
expect(r2.corpseConsumed).toBe(false)
expect(cBoss.consumed).toBe(false)
})
it('4.4 simulateVineConsumption safely rejects shattered (cold/freeze death) corpses', () => {
const owner = { maxHp: 1000, maxMana: 500 }
const cShattered = { consumed: false, shattered: true } as any
const res = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, cShattered, owner)
// In 1.13c, monsters that shatter into ice leave NO usable corpse (STATE_CORPSE_NOSELECT).
expect(res.corpseConsumed).toBe(false)
expect(cShattered.consumed).toBe(false)
expect(res.lifeHealed).toBe(0)
})
it('4.5 simulateVineConsumption safely rejects non-corpse entities', () => {
const owner = { maxHp: 1000, maxMana: 500 }
const cNonCorpse = { consumed: false, isCorpse: false } as any
const res = simulateVineConsumption(DRUID_SUMMON_SKILL_IDS.CARRION_VINE, 10, cNonCorpse, owner)
// In 1.13c, non-corpse entities cannot be selected (SKILLS_SrvSt63_Corpse_VineCycler).
expect(res.corpseConsumed).toBe(false)
expect(cNonCorpse.consumed).toBe(false)
expect(res.lifeHealed).toBe(0)
})
it('4.6 Comprehensive pathological corpse array rejection in SummonManager.tickPets', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, mana: 250 * FIXED_ONE })
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus }
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
// Create Carrion Vine
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.CARRION_VINE, slvl: 10 })
const adversarialCorpses: any[] = [
null,
undefined,
{ id: 'shattered_ice', consumed: false, shattered: true },
{ id: 'non_corpse_barrel', consumed: false, isCorpse: false },
{ id: 'already_eaten', consumed: true, isBoss: false },
{ id: 'act_boss_diablo', consumed: false, isBoss: true },
{ id: 'valid_corpse_1', consumed: false, isBoss: false, shattered: false, isCorpse: true },
{ id: 'shattered_ice_2', consumed: false, shattered: true },
]
// Tick pets with adversarial corpse array
const outcome = sm.tickPets({
currentTick: 1,
owner,
enemies: [],
enemyPositions: new Map(),
corpses: adversarialCorpses,
missileEngine: mEngine,
auraScanner: aScanner,
})
expect(outcome.corpsesConsumedByPets).toBe(1)
expect(adversarialCorpses[6].consumed).toBe(true) // valid_corpse_1 consumed
// Shattered and non-corpse remain unconsumed!
expect(adversarialCorpses[2].consumed).toBe(false)
expect(adversarialCorpses[3].consumed).toBe(false)
expect(adversarialCorpses[5].consumed).toBe(false)
expect(adversarialCorpses[7].consumed).toBe(false)
// Subsequent tick with remaining corpses: none are valid, so 0 consumed
const outcome2 = sm.tickPets({
currentTick: 2,
owner,
enemies: [],
enemyPositions: new Map(),
corpses: adversarialCorpses,
missileEngine: mEngine,
auraScanner: aScanner,
})
expect(outcome2.corpsesConsumedByPets).toBe(0)
})
it('4.7 simulateDireWolfCorpseFrenzy safely rejects shattered, non-corpse, and null/undefined corpses', () => {
expect(simulateDireWolfCorpseFrenzy(null).rageActivated).toBe(false)
expect(simulateDireWolfCorpseFrenzy(undefined).rageActivated).toBe(false)
const cShattered = { consumed: false, shattered: true }
expect(simulateDireWolfCorpseFrenzy(cShattered).rageActivated).toBe(false)
expect(cShattered.consumed).toBe(false)
const cNonCorpse = { consumed: false, isCorpse: false }
expect(simulateDireWolfCorpseFrenzy(cNonCorpse).rageActivated).toBe(false)
expect(cNonCorpse.consumed).toBe(false)
const cBoss = { consumed: false, isBoss: true }
expect(simulateDireWolfCorpseFrenzy(cBoss).rageActivated).toBe(false)
expect(cBoss.consumed).toBe(false)
const cValid = { consumed: false, isBoss: false, shattered: false, isCorpse: true }
const resValid = simulateDireWolfCorpseFrenzy(cValid)
expect(resValid.rageActivated).toBe(true)
expect(resValid.damageMultiplier).toBe(2.0)
expect(cValid.consumed).toBe(true)
})
})
// =========================================================================
// 5. Spirit Aura Dissemination
// =========================================================================
describe('5. Spirit Aura Dissemination', () => {
it('5.1 AuraScanner pulses Oak Sage (298) % Max Life to party members within range', async () => {
const reg = await getSharedDataRegistry()
const scanner = new AuraScanner(reg)
const playerStats = new UnitStatList(reg, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const playerBus = new StateBus(playerStats, reg)
const player: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: playerStats, stateBus: playerBus, x: 0, y: 0 }
const allyStats = new UnitStatList(reg, { maxhp: 800 * FIXED_ONE, hitpoints: 800 * FIXED_ONE })
const allyBus = new StateBus(allyStats, reg)
const ally: CombatUnitContext = { id: 'ally_1', name: 'Mercenary', statList: allyStats, stateBus: allyBus, x: 20, y: 20 }
// Oak Sage slvl 10: 25 + 5 * 10 = 75% max life
const auraSrc = scanner.setActiveAura(player, 298, 10, 0)!
expect(auraSrc).toBeDefined()
const outcome = scanner.pulseAura(auraSrc, 0, [player, ally], [])
expect(outcome.alliesBuffed).toBe(2)
// Ally received oaksage state with item_maxhp_percent: 75
const allyState = allyBus.getState('oaksage')
expect(allyState).toBeDefined()
expect(allyState!.stats['item_maxhp_percent']).toBe(75)
// Ally max HP scaled from 800 to 1400 (800 * 1.75)
expect(allyStats.getMaxHp256() / FIXED_ONE).toBe(1400)
})
it('5.2 AuraScanner pulses Heart of Wolverine (297) % Damage and % AR to party members within range', async () => {
const reg = await getSharedDataRegistry()
const scanner = new AuraScanner(reg)
const playerStats = new UnitStatList(reg)
const playerBus = new StateBus(playerStats, reg)
const player: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: playerStats, stateBus: playerBus, x: 0, y: 0 }
const allyStats = new UnitStatList(reg, { tohit: 1000 })
const allyBus = new StateBus(allyStats, reg)
const ally: CombatUnitContext = { id: 'ally_1', name: 'Mercenary', statList: allyStats, stateBus: allyBus, x: 20, y: 20 }
// Heart of Wolverine slvl 10: 18 + 7*10 = 88% AR, 13 + 7*10 = 83% ED
const auraSrc = scanner.setActiveAura(player, 297, 10, 0)!
expect(auraSrc).toBeDefined()
const outcome = scanner.pulseAura(auraSrc, 0, [player, ally], [])
expect(outcome.alliesBuffed).toBe(2)
const allyState = allyBus.getState('wolverine')
expect(allyState).toBeDefined()
expect(allyState!.stats['item_tohit_percent']).toBe(88)
expect(allyState!.stats['damagepercent']).toBe(83)
expect(allyStats.getAccruedStat('item_tohit_percent')).toBe(88)
expect(allyStats.getAccruedStat('damagepercent')).toBe(83)
})
it('5.3 SummonManager.tickPets disseminates Spirit Auras to party members and allies', async () => {
const reg = await getSharedDataRegistry()
const arena = new WorldArena({
registry: reg,
classCode: 'dru',
skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE,
slvl: 10,
})
// Spawn an ally in arena.allies
const allyStats = new UnitStatList(reg, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const allyBus = new StateBus(allyStats, reg)
const ally: CombatUnitContext = {
id: 'ally_merc',
name: 'Act2_Desert_Merc',
statList: allyStats,
stateBus: allyBus,
x: 10,
y: 10,
}
arena.allies.push(ally)
// Cast Oak Sage in arena
arena.triggerCast()
expect(arena.summonManager.getActivePets().length).toBe(1)
// Step 60 ticks for pet tick & aura pulse
arena.stepTicks(60)
// Player receives control state:
expect(arena.player.stateBus.hasState('oaksagecontrol')).toBe(true)
// Oak Sage aura pulses to party allies within range:
expect(ally.stateBus.hasState('oaksage')).toBe(true)
expect(allyStats.getAccruedStat('item_maxhp_percent')).toBe(75)
})
it('5.4 Heart of Wolverine and Spirit of Barbs disseminate to party allies AND other active pets', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg)
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus, x: 0, y: 0 }
const allyStats = new UnitStatList(reg, { tohit: 500 })
const allyBus = new StateBus(allyStats, reg)
const ally: CombatUnitContext = { id: 'ally_rogue', name: 'Act1_Rogue', statList: allyStats, stateBus: allyBus, x: 20, y: 20 }
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
// 1. Create Grizzly combat pet
const resGz = sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_GRIZZLY, slvl: 10, x: 10, y: 10 })
const grizzly = resGz.pet!
// 2. Create Heart of Wolverine totem
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.HEART_OF_WOLVERINE, slvl: 10, x: 0, y: 0 })
// Step tick
sm.tickPets({
currentTick: 10,
owner,
allies: [ally],
enemies: [],
enemyPositions: new Map(),
missileEngine: mEngine,
auraScanner: aScanner,
})
// Owner receives wolverine control state
expect(owner.stateBus.hasState('wolverinecontrol')).toBe(true)
// Ally receives wolverine target buff state
expect(ally.stateBus.hasState('wolverine')).toBe(true)
expect(ally.statList.getAccruedStat('damagepercent')).toBe(83) // 13 + 7*10
expect(ally.statList.getAccruedStat('item_tohit_percent')).toBe(88) // 18 + 7*10
// Other pet (Grizzly) also receives wolverine buff!
expect(grizzly.stateBus.hasState('wolverine')).toBe(true)
expect(grizzly.statList.getAccruedStat('damagepercent')).toBe(83 + 25 + 9 * 10) // 83 from aura + 115 from passive = 198
expect(grizzly.statList.getAccruedStat('item_tohit_percent')).toBe(88)
// Test Spirit of Barbs:
const smBarbs = new SummonManager(reg)
const bOwnerStats = new UnitStatList(reg)
const bOwnerBus = new StateBus(bOwnerStats, reg)
const bOwner: CombatUnitContext = { id: 'player_b', name: 'Hero_Druid', statList: bOwnerStats, stateBus: bOwnerBus, x: 0, y: 0 }
const bAllyStats = new UnitStatList(reg)
const bAllyBus = new StateBus(bAllyStats, reg)
const bAlly: CombatUnitContext = { id: 'ally_merc', name: 'Act2_Merc', statList: bAllyStats, stateBus: bAllyBus, x: 15, y: 15 }
const bWolf = smBarbs.createPet({ owner: bOwner, skillId: DRUID_SUMMON_SKILL_IDS.SUMMON_SPIRIT_WOLF, slvl: 5, x: 10, y: 10 }).pet!
smBarbs.createPet({ owner: bOwner, skillId: DRUID_SUMMON_SKILL_IDS.SPIRIT_OF_BARBS, slvl: 10, x: 0, y: 0 })
smBarbs.tickPets({
currentTick: 10,
owner: bOwner,
allies: [bAlly],
enemies: [],
enemyPositions: new Map(),
missileEngine: mEngine,
auraScanner: aScanner,
})
expect(bOwner.stateBus.hasState('barbscontrol')).toBe(true)
expect(bAlly.stateBus.hasState('barbs')).toBe(true)
expect(bWolf.stateBus.hasState('barbs')).toBe(true)
// Thorns return percent: 50 + 9 * 10 = 140%
expect(bAlly.statList.getAccruedStat('thorns_percent')).toBe(140)
expect(bWolf.statList.getAccruedStat('thorns_percent')).toBe(140)
})
it('5.5 Spatial distance cutoff: distant allies are excluded while nearby allies and pets are buffed', async () => {
const reg = await getSharedDataRegistry()
const sm = new SummonManager(reg)
const stats = new UnitStatList(reg, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const bus = new StateBus(stats, reg)
const owner: CombatUnitContext = { id: 'player', name: 'Hero_Druid', statList: stats, stateBus: bus, x: 0, y: 0 }
// Near ally (within radius)
const nearAllyStats = new UnitStatList(reg, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const nearAllyBus = new StateBus(nearAllyStats, reg)
const nearAlly: CombatUnitContext = { id: 'ally_near', name: 'Near_Merc', statList: nearAllyStats, stateBus: nearAllyBus, x: 30, y: 30 }
// Far ally (far out of radius: 3000px away)
const farAllyStats = new UnitStatList(reg, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const farAllyBus = new StateBus(farAllyStats, reg)
const farAlly: CombatUnitContext = { id: 'ally_far', name: 'Far_Merc', statList: farAllyStats, stateBus: farAllyBus, x: 3000, y: 3000 }
const mEngine = new MissileEngine(reg)
const aScanner = new AuraScanner(reg)
// Create Oak Sage
sm.createPet({ owner, skillId: DRUID_SUMMON_SKILL_IDS.OAK_SAGE, slvl: 10, x: 0, y: 0 })
sm.tickPets({
currentTick: 10,
owner,
allies: [nearAlly, farAlly],
enemies: [],
enemyPositions: new Map(),
missileEngine: mEngine,
auraScanner: aScanner,
})
// Near ally is buffed
expect(nearAlly.stateBus.hasState('oaksage')).toBe(true)
// Far ally is NOT buffed
expect(farAlly.stateBus.hasState('oaksage')).toBe(false)
})
})
})