503 lines
19 KiB
TypeScript
503 lines
19 KiB
TypeScript
/**
|
|
* Adversarial Challenger Stress & Boundary Suite: Necromancer Curses Tree
|
|
* Milestone M3: Skills 66, 71, 72, 76, 77, 81, 82, 86, 87, 91 (1.13c Ground Truth)
|
|
*
|
|
* Verifies:
|
|
* 1. High-density multi-monster curse overwrite cascades (50 monsters in 2:1 isometric ellipses).
|
|
* 2. Multi-curse rapid alternating application (Amp -> Decrep -> Weaken -> Lower Resist) across 100 cycles with 0 stat leakage.
|
|
* 3. Attract inviolability under 100 continuous attempts of competing curses.
|
|
* 4. Borderline physical immunity threshold transitions (99%, 100%, 109%, 110%, 119%, 120%, 130%).
|
|
* 5. Iron Maiden lethal melee reflection scenarios against high-damage and physical-immune monsters.
|
|
* 6. Difficulty duration scaling under Nightmare (50%) and Hell (25%) for Dim Vision.
|
|
* 7. Boss and Champion mental curse rejection in ActScene (Dim Vision, Terror, Confuse, Attract).
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
import {
|
|
ISO_GROUND_ASPECT_RATIO,
|
|
calculateCurseRadiusPx,
|
|
calculateCurseDurationFrames,
|
|
calculateIronMaidenReturnPct,
|
|
calculateLowerResistReduction,
|
|
computeImmunityBreak,
|
|
calculateCurseEffect,
|
|
} from '../../../src/game/skills.ts'
|
|
import {
|
|
computeEffectiveResistance,
|
|
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 { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
|
|
import { castSkill } from '../../../src/scene/act-scene.ts'
|
|
import type { Monster, CombatTerrain } from '../../../src/game/combat.ts'
|
|
|
|
describe('Adversarial Challenger: Necromancer Curses Stress & Boundary Suite', () => {
|
|
let engine: GameEngine
|
|
let statusElem: HTMLElement
|
|
const dummyRuntime: any = { map: null }
|
|
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 = 1000
|
|
statusElem = { textContent: '' } as any
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Applications
|
|
// =========================================================================
|
|
describe('Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Targeting', () => {
|
|
it('afflicts exactly monsters inside the 2:1 ground ellipse and excludes those outside', () => {
|
|
const radiusPx = calculateCurseRadiusPx(66, 1) // slvl 1 Amplify Damage: 80 px
|
|
const monsters: Monster[] = []
|
|
|
|
// Generate 50 monsters in radial bands
|
|
for (let i = 0; i < 50; i++) {
|
|
// Vary distance from 20 px to 160 px
|
|
const dist = 20 + i * 2.8
|
|
const angle = (i * 0.45)
|
|
const mx = 200 + Math.cos(angle) * dist
|
|
const my = 200 + Math.sin(angle) * (dist * ISO_GROUND_ASPECT_RATIO)
|
|
|
|
monsters.push({
|
|
index: i,
|
|
x: mx,
|
|
y: my,
|
|
hp: 500,
|
|
cooldown: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
stats: { id: `fallen_${i}`, name: 'Fallen', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5 },
|
|
})
|
|
}
|
|
|
|
engine.world.monsters = monsters
|
|
|
|
// Cast Amplify Damage (66) at (200, 200)
|
|
const castSuccess = castSkill(66, 200, 200, {
|
|
engine,
|
|
runtime: dummyRuntime,
|
|
hudManager: null,
|
|
status: statusElem,
|
|
})
|
|
expect(castSuccess).toBe(true)
|
|
|
|
// Verify each monster's curse state against 2:1 ground geometry
|
|
let afflictedCount = 0
|
|
for (const m of monsters) {
|
|
const isoDist = Math.hypot(m.x - 200, (m.y - 200) / ISO_GROUND_ASPECT_RATIO)
|
|
if (isoDist <= radiusPx) {
|
|
expect(m.curse?.name).toBe('amplifydamage')
|
|
expect(m.curse?.curseType).toBe(1)
|
|
afflictedCount++
|
|
} else {
|
|
expect(m.curse).toBeUndefined()
|
|
}
|
|
}
|
|
|
|
expect(afflictedCount).toBeGreaterThan(15)
|
|
expect(afflictedCount).toBeLessThan(50)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 2: Rapid Alternating Multi-Curse Overwrites (100 Cycles)
|
|
// =========================================================================
|
|
describe('Challenge 2: 100 Cycles of Rapid Alternating Curse Overwrites with Zero Stat Leakage', () => {
|
|
it('alternates Amp -> Decrep -> Weaken -> Lower Resist 100 times without residual modifier accumulation', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList()
|
|
const bus = new StateBus(stats, registry)
|
|
|
|
for (let cycle = 0; cycle < 100; cycle++) {
|
|
// Step 1: Amplify Damage
|
|
const r1 = bus.applyState({
|
|
stateNameOrId: 'amplifydamage',
|
|
slvl: 20,
|
|
stats: { damageresist: -100, amplify_phys_pierce: 100 },
|
|
curseTypeOverride: 1,
|
|
})
|
|
expect(r1.applied).toBe(true)
|
|
expect(bus.hasState('amplifydamage')).toBe(true)
|
|
expect(stats.getModifierBonus('damageresist')).toBe(-100)
|
|
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(100)
|
|
expect(stats.getModifierBonus('damagepercent')).toBe(0)
|
|
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
|
|
|
|
// Step 2: Decrepify
|
|
const r2 = bus.applyState({
|
|
stateNameOrId: 'decrepify',
|
|
slvl: 20,
|
|
stats: { damageresist: -50, velocitypercent: -50, damagepercent: -50 },
|
|
curseTypeOverride: 1,
|
|
})
|
|
expect(r2.applied).toBe(true)
|
|
expect(bus.hasState('decrepify')).toBe(true)
|
|
expect(bus.hasState('amplifydamage')).toBe(false)
|
|
expect(stats.getModifierBonus('damageresist')).toBe(-50)
|
|
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
|
|
expect(stats.getModifierBonus('velocitypercent')).toBe(-50)
|
|
expect(stats.getModifierBonus('damagepercent')).toBe(-50)
|
|
|
|
// Step 3: Weaken
|
|
const r3 = bus.applyState({
|
|
stateNameOrId: 'weaken',
|
|
slvl: 20,
|
|
stats: { damagepercent: -33 },
|
|
curseTypeOverride: 1,
|
|
})
|
|
expect(r3.applied).toBe(true)
|
|
expect(bus.hasState('weaken')).toBe(true)
|
|
expect(bus.hasState('decrepify')).toBe(false)
|
|
expect(stats.getModifierBonus('damageresist')).toBe(0)
|
|
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
|
|
expect(stats.getModifierBonus('damagepercent')).toBe(-33)
|
|
|
|
// Step 4: Lower Resist
|
|
const r4 = bus.applyState({
|
|
stateNameOrId: 'lowerresist',
|
|
slvl: 20,
|
|
stats: { lowerresist_pierce: 63, fireresist: -63, lightresist: -63, coldresist: -63, poisonresist: -63 },
|
|
curseTypeOverride: 1,
|
|
})
|
|
expect(r4.applied).toBe(true)
|
|
expect(bus.hasState('lowerresist')).toBe(true)
|
|
expect(bus.hasState('weaken')).toBe(false)
|
|
expect(stats.getModifierBonus('damagepercent')).toBe(0)
|
|
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
|
|
expect(stats.getModifierBonus('fireresist')).toBe(-63)
|
|
}
|
|
|
|
// Final audit: only Lower Resist remains
|
|
expect(bus.getActiveEntries().length).toBe(1)
|
|
expect(bus.hasState('lowerresist')).toBe(true)
|
|
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
|
|
expect(stats.getModifierBonus('damageresist')).toBe(0)
|
|
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
|
|
expect(stats.getModifierBonus('damagepercent')).toBe(0)
|
|
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 3: Attract Inviolability Under 100 Competing Attacks
|
|
// =========================================================================
|
|
describe('Challenge 3: Attract Inviolability Under 100 Competing Curse Attempts', () => {
|
|
it('defends Attract from 100 continuous attempts by competing curses and duplicates', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList()
|
|
const bus = new StateBus(stats, registry)
|
|
|
|
// Apply initial Attract
|
|
const initAttract = bus.applyState({
|
|
stateNameOrId: 'attract',
|
|
slvl: 10,
|
|
curseTypeOverride: 2,
|
|
durationFrames: 1110,
|
|
})
|
|
expect(initAttract.applied).toBe(true)
|
|
|
|
const competingCurses = [
|
|
'amplifydamage',
|
|
'decrepify',
|
|
'weaken',
|
|
'ironmaiden',
|
|
'terror',
|
|
'confuse',
|
|
'lifetap',
|
|
'lowerresist',
|
|
'dimvision',
|
|
'attract',
|
|
]
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const curseToTest = competingCurses[i % competingCurses.length]!
|
|
const result = bus.applyState({
|
|
stateNameOrId: curseToTest,
|
|
slvl: 20,
|
|
curseTypeOverride: curseToTest === 'attract' ? 2 : 1,
|
|
})
|
|
expect(result.applied).toBe(false)
|
|
expect(result.blockedByAttract).toBe(true)
|
|
expect(bus.hasState('attract')).toBe(true)
|
|
}
|
|
|
|
// Attract retains original slvl
|
|
const activeAttract = bus.getState('attract')
|
|
expect(activeAttract).toBeDefined()
|
|
expect(activeAttract?.slvl).toBe(10)
|
|
expect(activeAttract?.durationFrames).toBe(1110)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 4: Borderline Physical Immunity Threshold Transitions
|
|
// =========================================================================
|
|
describe('Challenge 4: Borderline Physical Immunity Threshold Transitions', () => {
|
|
it('verifies exact threshold boundaries under Amplify Damage (-100% / -20% on immune)', () => {
|
|
// 99%: non-immune -> 99 - 100 = -1% (not immune, full pierce)
|
|
const b99 = computeImmunityBreak(99, 100)
|
|
expect(b99.wasImmune).toBe(false)
|
|
expect(b99.effectiveRes).toBe(-1)
|
|
expect(b99.broken).toBe(false)
|
|
|
|
// 100%: immune -> 100 - 20 = 80% (broken!)
|
|
const b100 = computeImmunityBreak(100, 100)
|
|
expect(b100.wasImmune).toBe(true)
|
|
expect(b100.effectiveRes).toBe(80)
|
|
expect(b100.broken).toBe(true)
|
|
|
|
// 109%: immune -> 109 - 20 = 89% (broken!)
|
|
const b109 = computeImmunityBreak(109, 100)
|
|
expect(b109.effectiveRes).toBe(89)
|
|
expect(b109.broken).toBe(true)
|
|
|
|
// 110%: immune -> 110 - 20 = 90% (broken!)
|
|
const b110 = computeImmunityBreak(110, 100)
|
|
expect(b110.effectiveRes).toBe(90)
|
|
expect(b110.broken).toBe(true)
|
|
|
|
// 119%: immune -> 119 - 20 = 99% (broken!)
|
|
const b119 = computeImmunityBreak(119, 100)
|
|
expect(b119.effectiveRes).toBe(99)
|
|
expect(b119.broken).toBe(true)
|
|
|
|
// 120%: immune -> 120 - 20 = 100% (remains immune!)
|
|
const b120 = computeImmunityBreak(120, 100)
|
|
expect(b120.wasImmune).toBe(true)
|
|
expect(b120.effectiveRes).toBe(100)
|
|
expect(b120.broken).toBe(false)
|
|
|
|
// 130%: immune -> 130 - 20 = 110% (remains immune!)
|
|
const b130 = computeImmunityBreak(130, 100)
|
|
expect(b130.wasImmune).toBe(true)
|
|
expect(b130.effectiveRes).toBe(110)
|
|
expect(b130.broken).toBe(false)
|
|
})
|
|
|
|
it('verifies exact threshold boundaries under Decrepify (-50% / -10% on immune)', () => {
|
|
// 99%: non-immune -> 99 - 50 = 49%
|
|
const b99 = computeImmunityBreak(99, 50)
|
|
expect(b99.wasImmune).toBe(false)
|
|
expect(b99.effectiveRes).toBe(49)
|
|
|
|
// 100%: immune -> 100 - 10 = 90% (broken!)
|
|
const b100 = computeImmunityBreak(100, 50)
|
|
expect(b100.wasImmune).toBe(true)
|
|
expect(b100.effectiveRes).toBe(90)
|
|
expect(b100.broken).toBe(true)
|
|
|
|
// 109%: immune -> 109 - 10 = 99% (broken!)
|
|
const b109 = computeImmunityBreak(109, 50)
|
|
expect(b109.effectiveRes).toBe(99)
|
|
expect(b109.broken).toBe(true)
|
|
|
|
// 110%: immune -> 110 - 10 = 100% (remains immune!)
|
|
const b110 = computeImmunityBreak(110, 50)
|
|
expect(b110.effectiveRes).toBe(100)
|
|
expect(b110.broken).toBe(false)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 5: Iron Maiden Lethal Reflection Scenarios
|
|
// =========================================================================
|
|
describe('Challenge 5: Iron Maiden Lethal Melee Reflection Scenarios', () => {
|
|
it('kills a high-damage monster on melee attack via lethal reflected damage', () => {
|
|
const monsterStats = new UnitStatList()
|
|
monsterStats.setHp256(500 * FIXED_ONE)
|
|
const monsterBus = new StateBus(monsterStats)
|
|
monsterBus.applyState({
|
|
stateNameOrId: 'ironmaiden',
|
|
slvl: 20, // 675% reflection
|
|
curseTypeOverride: 1,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'lethal_monster',
|
|
name: 'Moon Lord',
|
|
statList: monsterStats,
|
|
stateBus: monsterBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const defenderStats = new UnitStatList()
|
|
defenderStats.setHp256(2000 * FIXED_ONE)
|
|
const defenderBus = new StateBus(defenderStats)
|
|
const defender: CombatUnitContext = {
|
|
id: 'hero',
|
|
name: 'Paladin',
|
|
statList: defenderStats,
|
|
stateBus: defenderBus,
|
|
unitType: 'player',
|
|
}
|
|
|
|
// Moon Lord attacks Paladin for 200 physical melee damage
|
|
const outcome = executeSUnitDmg(attacker, defender, {
|
|
skillId: 0,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 200 * FIXED_ONE,
|
|
flatPhysMax256: 200 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
// Raw reflection: 200 * 6.75 = 1350 damage!
|
|
// Attacker had 500 HP, so HP drops to 0!
|
|
expect(outcome.reflectedPhys256).toBe(1350 * FIXED_ONE)
|
|
expect(monsterStats.getHp256()).toBe(0)
|
|
})
|
|
|
|
it('inflicts 0 reflected damage on a 100% physical immune monster', () => {
|
|
const immuneMonsterStats = new UnitStatList()
|
|
immuneMonsterStats.setHp256(1000 * FIXED_ONE)
|
|
immuneMonsterStats.addStat('damageresist', 100) // Physical Immune!
|
|
const monsterBus = new StateBus(immuneMonsterStats)
|
|
monsterBus.applyState({
|
|
stateNameOrId: 'ironmaiden',
|
|
slvl: 20,
|
|
curseTypeOverride: 1,
|
|
})
|
|
const attacker: CombatUnitContext = {
|
|
id: 'ghost',
|
|
name: 'Ghost',
|
|
statList: immuneMonsterStats,
|
|
stateBus: monsterBus,
|
|
unitType: 'monster',
|
|
}
|
|
|
|
const defenderStats = new UnitStatList()
|
|
defenderStats.setHp256(2000 * FIXED_ONE)
|
|
const defender: CombatUnitContext = {
|
|
id: 'hero',
|
|
name: 'Paladin',
|
|
statList: defenderStats,
|
|
stateBus: new StateBus(defenderStats),
|
|
unitType: 'player',
|
|
}
|
|
|
|
// Ghost attacks Paladin
|
|
const outcome = executeSUnitDmg(attacker, defender, {
|
|
skillId: 0,
|
|
attackKind: 'melee',
|
|
srcDam: 0,
|
|
flatPhysMin256: 100 * FIXED_ONE,
|
|
flatPhysMax256: 100 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
// Since ghost is 100% physical immune, reflected damage is 0!
|
|
expect(outcome.reflectedPhys256 ?? 0).toBe(0)
|
|
expect(immuneMonsterStats.getHp256()).toBe(1000 * FIXED_ONE)
|
|
})
|
|
})
|
|
|
|
// =========================================================================
|
|
// Challenge 6: Boss and Champion Mental Curse Immunity in ActScene
|
|
// =========================================================================
|
|
describe('Challenge 6: Boss and Champion Mental Curse Immunity in ActScene', () => {
|
|
it('afflicts normal monsters but skips bosses and champions for Dim Vision, Terror, Confuse, Attract', () => {
|
|
const normalMonster: Monster = {
|
|
index: 0,
|
|
x: 210,
|
|
y: 200,
|
|
hp: 500,
|
|
cooldown: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
stats: { id: 'zombie_normal', name: 'Zombie', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5, rank: 'normal' },
|
|
}
|
|
|
|
const champMonster: Monster = {
|
|
index: 1,
|
|
x: 215,
|
|
y: 200,
|
|
hp: 1500,
|
|
cooldown: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
stats: { id: 'zombie_champ', name: 'Zombie Champion', hp: 1500, damage: 20, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 20, rank: 'champion' },
|
|
}
|
|
|
|
const bossMonster: Monster = {
|
|
index: 2,
|
|
x: 220,
|
|
y: 200,
|
|
hp: 5000,
|
|
cooldown: 0,
|
|
hitFlash: 0,
|
|
corpseTicks: 0,
|
|
state: 'idle',
|
|
facing: 0,
|
|
stats: { id: 'andariel', name: 'Andariel', hp: 5000, damage: 50, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, rank: 'unique', type: 'boss', isBoss: true } as any,
|
|
}
|
|
|
|
engine.world.monsters = [normalMonster, champMonster, bossMonster]
|
|
|
|
// 1. Cast Dim Vision (71)
|
|
castSkill(71, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
|
|
expect(normalMonster.curse?.name).toBe('dimvision')
|
|
expect(champMonster.curse).toBeUndefined()
|
|
expect(bossMonster.curse).toBeUndefined()
|
|
|
|
// 2. Cast Terror (77)
|
|
castSkill(77, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
|
|
expect(normalMonster.curse?.name).toBe('terror')
|
|
expect(champMonster.curse).toBeUndefined()
|
|
expect(bossMonster.curse).toBeUndefined()
|
|
|
|
// 3. Cast Confuse (81)
|
|
castSkill(81, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
|
|
expect(normalMonster.curse?.name).toBe('confuse')
|
|
expect(champMonster.curse).toBeUndefined()
|
|
expect(bossMonster.curse).toBeUndefined()
|
|
|
|
// 4. Attempt to cast Attract (86) on the boss
|
|
const attractBoss = castSkill(86, 220, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
|
|
expect(attractBoss).toBe(false)
|
|
expect(bossMonster.curse).toBeUndefined()
|
|
|
|
// 5. Cast Decrepify (87) — Decrepify affects Act Bosses in Diablo II!
|
|
const decrepSuccess = castSkill(87, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
|
|
expect(decrepSuccess).toBe(true)
|
|
expect(normalMonster.curse?.name).toBe('decrepify')
|
|
expect(champMonster.curse?.name).toBe('decrepify')
|
|
expect(bossMonster.curse?.name).toBe('decrepify') // Boss IS affected by Decrepify!
|
|
})
|
|
})
|
|
})
|