430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
/**
|
||
* 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('需要未被消耗的怪物尸体')
|
||
})
|
||
})
|
||
})
|