diablo2-web/tests/skills/nec/summoning.test.ts

493 lines
19 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Necromancer Summoning Spells Tree Verification Suite
* Milestone M4: Skills 69, 70, 75, 79, 80, 85, 89, 90, 94, 95
*
* Verifies:
* 1. Architecture invariants:
* - BATCH1_SKILLS length strictly 38.
* - ISO_GROUND_ASPECT_RATIO = 0.5.
* - Decoupled standalone summon formulas.
* - calculateSkillDamage returns { min: 0, max: 0 } for all 10 summoning skills.
* 2. Skeleton Mastery (69): flat HP, flat damage, life %, dmg %, defense scaling.
* 3. Raise Skeleton (70): max count formula ((slvl < 4) ? slvl : 2 + floor(slvl/3)), corpse requirement.
* 4. Clay Golem (75): Golem Group 3 exclusivity, slow-on-hit effect against targets and melee attackers.
* 5. Golem Mastery (79): passive HP % (20% + 25%/lvl) and run/walk speed bonuses.
* 6. Raise Skeletal Mage (80): max count matching warriors, 4 elemental types, ranged elemental missile firing.
* 7. Blood Golem (85): 70% life link healing on damage dealt, damage sharing with player.
* 8. Summon Resist (89): diminishing passive elemental & poison resistances (dm56(28, 75)) capped at 75%.
* 9. Iron Golem (90): consumes metallic item, inherits properties, innate Thorns reflection (150% + 15%/lvl).
* 10. Fire Golem (94): Holy Fire pulse aura every 50 ticks, fire absorption healing, Fire Nova death explosion.
* 11. Revive (95): max count = slvl, copies target monster stats with +200% HP and +215% damage, 180s (4500 frames) lifespan timer.
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
calculateSkillDamage,
ISO_GROUND_ASPECT_RATIO,
calculateSkeletonMaxCount,
calculateSkeletalMageMaxCount,
calculateReviveMaxCount,
calculateSkeletonMasteryStats,
calculateGolemMasteryStats,
calculateSummonResistPct,
calculateClayGolemStats,
calculateBloodGolemStats,
calculateIronGolemStats,
calculateFireGolemStats,
calculateReviveStats,
} from '../../../src/game/skills.ts'
import {
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { SummonManager } from '../../../src/game/engine/summon-manager.ts'
import { MissileEngine } from '../../../src/game/engine/missile-engine.ts'
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
describe('Milestone M4: Necromancer Summoning Spells Tree (1.13c Ground Truth)', () => {
// =========================================================================
// 1. Architecture Invariants
// =========================================================================
describe('1. Architecture Invariants', () => {
it('preserves BATCH1_SKILLS length strictly at 38 skills', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('enforces ISO_GROUND_ASPECT_RATIO === 0.5', () => {
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
})
it('returns { min: 0, max: 0 } from calculateSkillDamage for all 10 summoning skills', () => {
const summonSkillIds = [69, 70, 75, 79, 80, 85, 89, 90, 94, 95]
for (const id of summonSkillIds) {
const dmg = calculateSkillDamage(id, 20)
expect(dmg).toEqual({ min: 0, max: 0 })
}
})
})
// =========================================================================
// 2. Skeleton Mastery (Skill 69)
// =========================================================================
describe('2. Skeleton Mastery (Skill 69)', () => {
it('calculates flat HP, flat damage, revive HP %, damage %, and defense at slvl 1, 10, 20', () => {
const sm1 = calculateSkeletonMasteryStats(1)
expect(sm1.skeletonHp).toBe(8)
expect(sm1.skeletonDamage).toBe(2)
expect(sm1.reviveHpPct).toBe(5)
expect(sm1.damagePct).toBe(10)
expect(sm1.defense).toBe(10)
const sm10 = calculateSkeletonMasteryStats(10)
expect(sm10.skeletonHp).toBe(80)
expect(sm10.skeletonDamage).toBe(20)
expect(sm10.reviveHpPct).toBe(50)
expect(sm10.damagePct).toBe(100)
expect(sm10.defense).toBe(100)
const sm20 = calculateSkeletonMasteryStats(20)
expect(sm20.skeletonHp).toBe(160)
expect(sm20.skeletonDamage).toBe(40)
expect(sm20.reviveHpPct).toBe(100)
expect(sm20.damagePct).toBe(200)
expect(sm20.defense).toBe(200)
})
})
// =========================================================================
// 3. Raise Skeleton (Skill 70)
// =========================================================================
describe('3. Raise Skeleton (Skill 70)', () => {
it('calculates exact 1.13c max skeleton count formula', () => {
expect(calculateSkeletonMaxCount(1)).toBe(1)
expect(calculateSkeletonMaxCount(2)).toBe(2)
expect(calculateSkeletonMaxCount(3)).toBe(3)
expect(calculateSkeletonMaxCount(4)).toBe(3)
expect(calculateSkeletonMaxCount(5)).toBe(3)
expect(calculateSkeletonMaxCount(6)).toBe(4)
expect(calculateSkeletonMaxCount(9)).toBe(5)
expect(calculateSkeletonMaxCount(10)).toBe(5)
expect(calculateSkeletonMaxCount(20)).toBe(8)
})
it('requires and synchronously consumes an unconsumed non-boss corpse', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
// No corpses available -> fails with 'missing_corpse'
const failRes = sm.createPet({
owner,
skillId: 70,
slvl: 1,
corpses: [],
})
expect(failRes.created).toBe(false)
expect(failRes.reason).toBe('missing_corpse')
// Boss corpse available -> fails
const bossCorpse = [{ id: 'corpse-boss', consumed: false, baseMaxHp: 5000, isBoss: true }]
const failBoss = sm.createPet({
owner,
skillId: 70,
slvl: 1,
corpses: bossCorpse,
})
expect(failBoss.created).toBe(false)
expect(failBoss.reason).toBe('missing_corpse')
expect(bossCorpse[0]!.consumed).toBe(false)
// Valid corpse available -> succeeds and marks corpse consumed
const validCorpse = [{ id: 'corpse-1', consumed: false, baseMaxHp: 500, isBoss: false }]
const successRes = sm.createPet({
owner,
skillId: 70,
slvl: 1,
corpses: validCorpse,
})
expect(successRes.created).toBe(true)
expect(validCorpse[0]!.consumed).toBe(true)
expect(successRes.pet?.kind).toBe('skeleton')
})
})
// =========================================================================
// 4. Clay Golem (Skill 75)
// =========================================================================
describe('4. Clay Golem (Skill 75)', () => {
it('evaluates slow percentage and base HP with synergies', () => {
const c1 = calculateClayGolemStats(1, 0, 0)
expect(c1.slowPct).toBe(11)
expect(c1.baseHp).toBe(175)
const c10 = calculateClayGolemStats(10, 0, 0)
expect(c10.slowPct).toBe(33) // 11 + floor(40*9/16) = 11 + 22 = 33
expect(c10.baseHp).toBe(490)
const c20 = calculateClayGolemStats(20, 0, 0)
expect(c20.slowPct).toBe(40) // 11 + floor(40*19/26) = 11 + 29 = 40
expect(c20.baseHp).toBe(840)
// Synergy: +5% HP per base pt in Blood Golem
const c20Syn = calculateClayGolemStats(20, 0, 10)
expect(c20Syn.hpBonusPct).toBe(50)
})
it('enforces Golem Group 3 exclusivity (only 1 active golem allowed)', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
// Summon Clay Golem
const clay = sm.createPet({ owner, skillId: 75, slvl: 1 })
expect(clay.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]!.kind).toBe('clay_golem')
// Summon Blood Golem -> evicts Clay Golem!
const blood = sm.createPet({ owner, skillId: 85, slvl: 1 })
expect(blood.created).toBe(true)
expect(blood.evictedPetUids).toContain(clay.pet!.uid)
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]!.kind).toBe('blood_golem')
})
})
// =========================================================================
// 5. Golem Mastery (Skill 79)
// =========================================================================
describe('5. Golem Mastery (Skill 79)', () => {
it('calculates passive HP % (20% + 25%/lvl) and diminishing speed %', () => {
const gm1 = calculateGolemMasteryStats(1)
expect(gm1.hpBonusPct).toBe(20)
expect(gm1.speedBonusPct).toBe(20)
const gm10 = calculateGolemMasteryStats(10)
expect(gm10.hpBonusPct).toBe(245) // 20 + 9*25 = 245
expect(gm10.speedBonusPct).toBe(32) // 20 + floor(20*9/15) = 20 + 12 = 32
const gm20 = calculateGolemMasteryStats(20)
expect(gm20.hpBonusPct).toBe(495) // 20 + 19*25 = 495
expect(gm20.speedBonusPct).toBe(35) // 20 + floor(20*19/25) = 20 + 15 = 35
})
})
// =========================================================================
// 6. Raise Skeletal Mage (Skill 80)
// =========================================================================
describe('6. Raise Skeletal Mage (Skill 80)', () => {
it('calculates max count formula matching warriors', () => {
expect(calculateSkeletalMageMaxCount(1)).toBe(1)
expect(calculateSkeletalMageMaxCount(3)).toBe(3)
expect(calculateSkeletalMageMaxCount(4)).toBe(3)
expect(calculateSkeletalMageMaxCount(6)).toBe(4)
expect(calculateSkeletalMageMaxCount(20)).toBe(8)
})
it('spawns elemental variants that fire ranged missiles', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
const corpses = [{ id: 'corpse-1', consumed: false, baseMaxHp: 500, isBoss: false }]
const res = sm.createPet({ owner, skillId: 80, slvl: 5, corpses })
expect(res.created).toBe(true)
expect(res.pet?.kind).toBe('skeleton_mage')
expect(['fire', 'cold', 'lightning', 'poison']).toContain(res.pet?.mageElement)
// Test missile firing in tickPets
const me = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
const dummyStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'enemy-1',
name: 'Target Dummy',
statList: dummyStats,
stateBus: new StateBus(dummyStats, registry),
}
const enemyPositions = new Map([['enemy-1', { x: 100, y: 100 }]])
const tickRes = sm.tickPets({
currentTick: 1,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: me,
auraScanner,
})
expect(tickRes.petAttacks).toBeGreaterThanOrEqual(1)
expect(me.getActiveMissiles().length).toBeGreaterThanOrEqual(1)
})
})
// =========================================================================
// 7. Blood Golem (Skill 85)
// =========================================================================
describe('7. Blood Golem (Skill 85)', () => {
it('evaluates life link: 70% damage dealt returned as player healing', () => {
const bg = calculateBloodGolemStats(1, 0)
expect(bg.lifeLeechPct).toBe(70)
expect(bg.damageSharePct).toBe(30)
expect(bg.baseHp).toBe(201)
})
it('heals owner when Blood Golem attacks in tickPets', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
sm.createPet({ owner, skillId: 85, slvl: 10 })
const dummyStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'enemy-1',
name: 'Target Dummy',
statList: dummyStats,
stateBus: new StateBus(dummyStats, registry),
}
const enemyPositions = new Map([['enemy-1', { x: 20, y: 60 }]])
const me = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
// Step at tick 10 (combat interval)
sm.tickPets({
currentTick: 10,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: me,
auraScanner,
})
// Owner should have received healing from Blood Golem Life Link
expect(owner.statList.getHp()).toBeGreaterThan(500)
})
})
// =========================================================================
// 8. Summon Resist (Skill 89)
// =========================================================================
describe('8. Summon Resist (Skill 89)', () => {
it('calculates exact dm12 diminishing curve with 75% cap', () => {
expect(calculateSummonResistPct(1)).toBe(28)
expect(calculateSummonResistPct(5)).toBe(47)
expect(calculateSummonResistPct(10)).toBe(57)
expect(calculateSummonResistPct(20)).toBe(66)
expect(calculateSummonResistPct(60)).toBe(75) // Capped at 75%
})
})
// =========================================================================
// 9. Iron Golem (Skill 90)
// =========================================================================
describe('9. Iron Golem (Skill 90)', () => {
it('calculates innate Thorns reflection (150% + 15%/lvl) and defense bonus', () => {
const ig1 = calculateIronGolemStats(1)
expect(ig1.thornsReturnPct).toBe(150)
expect(ig1.defenseBonus).toBe(0)
const ig10 = calculateIronGolemStats(10)
expect(ig10.thornsReturnPct).toBe(285) // 150 + 9*15 = 285
expect(ig10.defenseBonus).toBe(315) // 9*35 = 315
const ig20 = calculateIronGolemStats(20)
expect(ig20.thornsReturnPct).toBe(435) // 150 + 19*15 = 435
expect(ig20.defenseBonus).toBe(665) // 19*35 = 665
})
it('inherits item damage, defense, and auras when created from an item', () => {
const item = { baseDamage: 45, defense: 350, auras: [98] } // Might aura
const ig = calculateIronGolemStats(5, item)
expect(ig.inheritedDamage).toBe(45)
expect(ig.inheritedDefense).toBe(350)
expect(ig.inheritedAuras).toEqual([98])
})
})
// =========================================================================
// 10. Fire Golem (Skill 94)
// =========================================================================
describe('10. Fire Golem (Skill 94)', () => {
it('calculates Holy Fire aura pulse damage, fire absorption, and death explosion', () => {
const fg1 = calculateFireGolemStats(1)
expect(fg1.holyFireAuraLevel).toBe(1)
expect(fg1.holyFirePulseDmg).toBe(4) // 2 + 1*2
expect(fg1.fireAbsorbPct).toBe(100)
expect(fg1.deathExplosionDmg).toBe(100)
const fg10 = calculateFireGolemStats(10)
expect(fg10.holyFireAuraLevel).toBe(10)
expect(fg10.holyFirePulseDmg).toBe(22) // 2 + 10*2
expect(fg10.deathExplosionDmg).toBe(280) // 100 + 9*20 = 280
const fg20 = calculateFireGolemStats(20)
expect(fg20.holyFireAuraLevel).toBe(20)
expect(fg20.holyFirePulseDmg).toBe(42) // 2 + 20*2
expect(fg20.deathExplosionDmg).toBe(480) // 100 + 19*20 = 480
})
it('emits Holy Fire radial pulse every 50 ticks in tickPets', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
sm.createPet({ owner, skillId: 94, slvl: 10 })
const dummyStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'enemy-1',
name: 'Target Dummy',
statList: dummyStats,
stateBus: new StateBus(dummyStats, registry),
}
const enemyPositions = new Map([['enemy-1', { x: 10, y: 70 }]])
const me = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
// Step at tick 50 (Holy Fire pulse tick)
const tickRes = sm.tickPets({
currentTick: 50,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: me,
auraScanner,
})
expect(tickRes.damageDealt).toBeGreaterThan(0)
})
})
// =========================================================================
// 11. Revive (Skill 95)
// =========================================================================
describe('11. Revive (Skill 95)', () => {
it('calculates max count = slvl, duration = 4500 frames (180s), and multipliers', () => {
expect(calculateReviveMaxCount(1)).toBe(1)
expect(calculateReviveMaxCount(10)).toBe(10)
expect(calculateReviveMaxCount(20)).toBe(20)
const rev0 = calculateReviveStats(1, 0)
expect(rev0.durationFrames).toBe(4500)
expect(rev0.hpMultiplier).toBe(3.0) // +200% base
expect(rev0.damageMultiplier).toBe(3.15) // +215% base
// Boosted by Skeleton Mastery: +5%/lvl HP and +10%/lvl damage
const rev10 = calculateReviveStats(1, 10)
expect(rev10.hpMultiplier).toBe(3.5) // 3.0 + 10 * 0.05
expect(rev10.damageMultiplier).toBe(4.15) // 3.15 + 10 * 0.10
})
it('ticks down lifespan and despawns after 4500 frames', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
const corpses = [{ id: 'corpse-1', consumed: false, baseMaxHp: 800, isBoss: false }]
const res = sm.createPet({ owner, skillId: 95, slvl: 1, corpses })
expect(res.created).toBe(true)
expect(sm.getActivePets().length).toBe(1)
const pet = res.pet!
expect(pet.lifespanTicks).toBe(4500)
const me = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
// Set lifespan to 1 tick remaining
pet.lifespanTicks = 1
sm.tickPets({
currentTick: 1,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: me,
auraScanner,
})
// Must be expired and removed from active pets
expect(sm.getActivePets().length).toBe(0)
})
})
})