/** * 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) }) }) })