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

430 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Adversarial Challenger Stress & Boundary Suite: Necromancer Summoning Spells Tree
* Milestone M4: Skills 69, 70, 75, 79, 80, 85, 89, 90, 94, 95 (1.13c Ground Truth)
*
* Verifies:
* 1. Rapid multi-summon stress testing (pet cap enforcement under burst casting).
* 2. Golem mutual exclusivity stress (switching between 4 golems across 100 iterations leaves exactly 1 golem active).
* 3. Corpse depletion stress (fails cast cleanly when corpses are exhausted or boss corpses are targeted).
* 4. Revive expiration stress (exactly 4500 frames ticks down and despawns minion).
* 5. Blood Golem life link stress (heals damaged player without exceeding maxHp).
* 6. Fire Golem fire absorption stress (healed by large fire attacks instead of taking damage).
* 7. 2:1 isometric ground geometry verification for minion following and radial Holy Fire pulses.
* 8. ActScene summon casting lifecycle, corpse consumption, and overlay creation.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
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'
import { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import type { Monster } from '../../../src/game/combat.ts'
describe('Adversarial Challenger: Necromancer Summoning Stress & Boundary Suite', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyTerrain: any = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
beforeEach(() => {
const dummyOpts: GameEngineOptions = {
spawn: { x: 200, y: 200 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 25,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 200
engine.world.player.y = 200
engine.world.player.cooldown = 0
engine.world.player.mana = 5000
statusElem = { textContent: '' } as any
})
// =========================================================================
// 1. Rapid Multi-Summon Stress (Pet Cap FIFO Enforcement)
// =========================================================================
describe('1. Rapid Multi-Summon Stress (Pet Cap FIFO Enforcement)', () => {
it('strictly maintains exact pet cap under 50 burst summon requests', 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),
}
// At slvl 20, max skeleton count is 8
const maxCount = calculateSkeletonMaxCount(20)
expect(maxCount).toBe(8)
// Provide 50 corpses
const corpses = Array.from({ length: 50 }, (_, i) => ({
id: `corpse-${i}`,
consumed: false,
baseMaxHp: 500,
isBoss: false,
}))
for (let i = 0; i < 50; i++) {
const res = sm.createPet({ owner, skillId: 70, slvl: 20, corpses })
expect(res.created).toBe(true)
const active = sm.getActivePets().filter(p => p.petType === 'skeleton')
expect(active.length).toBeLessThanOrEqual(maxCount)
}
const finalSkeletons = sm.getActivePets().filter(p => p.petType === 'skeleton')
expect(finalSkeletons.length).toBe(maxCount)
// Exactly 50 corpses should have been consumed
expect(corpses.filter(c => c.consumed).length).toBe(50)
})
})
// =========================================================================
// 2. Golem Mutual Exclusivity Stress
// =========================================================================
describe('2. Golem Mutual Exclusivity Stress', () => {
it('rapidly alternates across Clay, Blood, Iron, Fire Golem for 100 cycles with exactly 1 active golem', 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 golemSkillIds = [75, 85, 90, 94]
for (let i = 0; i < 100; i++) {
const skillId = golemSkillIds[i % 4]!
const res = sm.createPet({ owner, skillId, slvl: 10 })
expect(res.created).toBe(true)
const activeGolems = sm.getActivePets().filter(p => p.petGroup === 3)
expect(activeGolems.length).toBe(1)
}
expect(sm.getActivePets().length).toBe(1)
expect(sm.getActivePets()[0]!.petGroup).toBe(3)
})
})
// =========================================================================
// 3. Corpse Depletion & Boss Rejection Stress
// =========================================================================
describe('3. Corpse Depletion & Boss Rejection Stress', () => {
it('fails to summon and rejects cast cleanly when corpses run out or are bosses', 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 mixedCorpses = [
{ id: 'corpse-1', consumed: false, baseMaxHp: 500, isBoss: false },
{ id: 'boss-1', consumed: false, baseMaxHp: 5000, isBoss: true },
{ id: 'corpse-2', consumed: false, baseMaxHp: 500, isBoss: false },
]
// Cast 1 -> consumes corpse-1
const res1 = sm.createPet({ owner, skillId: 80, slvl: 1, corpses: mixedCorpses })
expect(res1.created).toBe(true)
expect(mixedCorpses[0]!.consumed).toBe(true)
// Cast 2 -> skips boss-1, consumes corpse-2
const res2 = sm.createPet({ owner, skillId: 80, slvl: 1, corpses: mixedCorpses })
expect(res2.created).toBe(true)
expect(mixedCorpses[1]!.consumed).toBe(false) // Boss untouched
expect(mixedCorpses[2]!.consumed).toBe(true)
// Cast 3 -> only boss remains -> fails cast
const res3 = sm.createPet({ owner, skillId: 80, slvl: 1, corpses: mixedCorpses })
expect(res3.created).toBe(false)
expect(res3.reason).toBe('missing_corpse')
expect(mixedCorpses[1]!.consumed).toBe(false)
})
})
// =========================================================================
// 4. Revive Lifespan Countdown Stress
// =========================================================================
describe('4. Revive Lifespan Countdown Stress', () => {
it('counts down exactly 4500 frames (180.0s @ 25Hz) and cleanly removes minion', 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-revive', consumed: false, baseMaxHp: 1200, isBoss: false }]
const res = sm.createPet({ owner, skillId: 95, slvl: 5, corpses })
expect(res.created).toBe(true)
const pet = res.pet!
expect(pet.lifespanTicks).toBe(4500)
const me = new MissileEngine(registry)
const auraScanner = new AuraScanner(registry)
// Fast forward 4499 ticks
pet.lifespanTicks = 2
sm.tickPets({
currentTick: 1,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: me,
auraScanner,
})
expect(sm.getActivePets().length).toBe(1)
expect(pet.lifespanTicks).toBe(1)
// Next tick reaches 0 -> minion despawns
sm.tickPets({
currentTick: 2,
owner,
enemies: [],
enemyPositions: new Map(),
missileEngine: me,
auraScanner,
})
expect(sm.getActivePets().length).toBe(0)
})
})
// =========================================================================
// 5. Blood Golem Life Link Healing Stress
// =========================================================================
describe('5. Blood Golem Life Link Healing Stress', () => {
it('heals heavily damaged player on high damage strikes without exceeding maxHp', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 100 * FIXED_ONE })
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: stats,
stateBus: new StateBus(stats, registry),
}
sm.createPet({ owner, skillId: 85, slvl: 20 })
const dummyStats = new UnitStatList(registry, { maxhp: 20000 * FIXED_ONE, hitpoints: 20000 * FIXED_ONE })
const enemy: CombatUnitContext = {
id: 'enemy-1',
name: 'Boss 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)
// Perform 5 melee cycles
for (let tick = 10; tick <= 50; tick += 10) {
sm.tickPets({
currentTick: tick,
owner,
enemies: [enemy],
enemyPositions,
missileEngine: me,
auraScanner,
})
}
// Player life should be restored and never exceed maxHp (1000)
expect(owner.statList.getHp()).toBeGreaterThan(100)
expect(owner.statList.getHp()).toBeLessThanOrEqual(1000)
})
})
// =========================================================================
// 6. Fire Golem Fire Absorption Stress
// =========================================================================
describe('6. Fire Golem Fire Absorption Stress', () => {
it('converts incoming massive fire damage to 100% healing', async () => {
const registry = await getSharedDataRegistry()
const sm = new SummonManager(registry)
const ownerStats = new UnitStatList(registry)
const owner: CombatUnitContext = {
id: 'player',
name: 'Necro',
statList: ownerStats,
stateBus: new StateBus(ownerStats, registry),
}
const res = sm.createPet({ owner, skillId: 94, slvl: 10 })
const golem = res.pet!
// Set golem HP to half
const maxHp256 = golem.statList.getMaxHp256()
golem.statList.setHp256(Math.trunc(maxHp256 / 2))
const halfHp = golem.statList.getHp()
const attackerStats = new UnitStatList(registry)
const attacker: CombatUnitContext = {
id: 'fire_mage',
name: 'Fire Monster',
statList: attackerStats,
stateBus: new StateBus(attackerStats, registry),
}
const golemContext: CombatUnitContext = {
id: golem.uid,
name: 'Fire Golem',
statList: golem.statList,
stateBus: golem.stateBus,
unitType: 'minion',
}
// Hit golem with 200 fire damage
const out = executeSUnitDmg(attacker, golemContext, {
skillId: 0,
attackKind: 'spell',
elemType: 'fire',
elemMin256: 200 * FIXED_ONE,
elemMax256: 200 * FIXED_ONE,
autoHit: true,
})
// Damage dealt should be 0 because of absorption, and golem should be healed
expect(out.totalDamage).toBe(0)
expect(golem.statList.getHp()).toBeGreaterThan(halfHp)
})
})
// =========================================================================
// 7. 2:1 Isometric Ground Geometry Alignment
// =========================================================================
describe('7. 2:1 Isometric Ground Geometry Alignment', () => {
it('evaluates distance along dx^2 + 4*dy^2 <= r^2 for Holy Fire radial pulse', () => {
const radiusPx = 400
const pet = { x: 200, y: 200 }
// Point at (400, 200) -> dx = 200, dy = 0 -> 200^2 + 0 = 40000 <= 160000 (IN)
const p1 = { x: 400, y: 200 }
const d1 = (p1.x - pet.x) ** 2 + 4 * (p1.y - pet.y) ** 2
expect(d1).toBeLessThanOrEqual(radiusPx ** 2)
// Point at (200, 300) -> dx = 0, dy = 100 -> 0 + 4*100^2 = 40000 <= 160000 (IN)
const p2 = { x: 200, y: 300 }
const d2 = (p2.x - pet.x) ** 2 + 4 * (p2.y - pet.y) ** 2
expect(d2).toBeLessThanOrEqual(radiusPx ** 2)
// Point at (200, 450) -> dx = 0, dy = 250 -> 4 * 62500 = 250000 > 160000 (OUT)
const p3 = { x: 200, y: 450 }
const d3 = (p3.x - pet.x) ** 2 + 4 * (p3.y - pet.y) ** 2
expect(d3).toBeGreaterThan(radiusPx ** 2)
})
})
// =========================================================================
// 8. ActScene Summon Casting Lifecycle
// =========================================================================
describe('8. ActScene Summon Casting Lifecycle', () => {
it('executes Raise Skeleton cast with corpse validation and visual overlay in ActScene', () => {
// Spawn dead monster corpse in world
const deadMonster: Monster = {
index: 0,
x: 230,
y: 215,
hp: 0,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'dead',
facing: 0,
stats: { id: 'fallen_0', name: 'Fallen', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5 },
}
engine.world.monsters = [deadMonster]
const hudManager = {
getSkillLevel: (_id: number) => 10,
syncPublishedState: () => {},
mana: 1000,
}
const dummyRuntime: any = { map: null }
const res = castSkill(70, 230, 215, {
engine,
runtime: dummyRuntime,
hudManager: hudManager as any,
status: statusElem,
})
expect(res).toBe(true)
expect((deadMonster as any).corpseConsumed).toBe(true)
expect(statusElem.textContent).toContain('复生骷髅(等级 10,上限 5 只)召唤成功')
expect(engine.overlays.some(o => o.overlayName === 'bonecast')).toBe(true)
})
it('rejects Raise Skeleton in ActScene when no corpse is nearby', () => {
engine.world.monsters = []
const hudManager = {
getSkillLevel: (_id: number) => 1,
syncPublishedState: () => {},
mana: 1000,
}
const dummyRuntime: any = { map: null }
const res = castSkill(70, 200, 200, {
engine,
runtime: dummyRuntime,
hudManager: hudManager as any,
status: statusElem,
})
expect(res).toBe(false)
expect(statusElem.textContent).toContain('需要未被消耗的怪物尸体')
})
})
})