493 lines
19 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|