/** * 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/server/skills/skills.ts' import { executeSUnitDmg, type CombatUnitContext, } from '../../../src/server/engine/combat-pipeline.ts' import { FIXED_ONE, UnitStatList } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { getSharedDataRegistry } from '../../../src/baker/mpq-archive-source.ts' import { SummonManager } from '../../../src/server/ai/summon-manager.ts' import { MissileEngine } from '../../../src/server/engine/missile-engine.ts' import { AuraScanner } from '../../../src/server/engine/aura-scanner.ts' import { GameEngine, type GameEngineOptions } from '../../../src/server/engine/engine.ts' import { castSkill } from '../../../src/server/scene/skill-caster.ts' import type { Monster } from '../../../src/server/engine/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('需要未被消耗的怪物尸体') }) }) })