346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
/**
|
|
* Adversarial Stress & Edge-Case Suite — Paladin Defensive Auras (Milestone M9).
|
|
*
|
|
* 5 Critical Scenarios:
|
|
* 1. Passive Max Resistance Stacking & Absolute 95% Hard Cap:
|
|
* - 20 hard points in Resist Fire (+10%), plus item modifier (+15% max fire res)
|
|
* - Total = 75 + 10 + 15 = 100%, strictly clamped to 95% hard ceiling
|
|
* - Verifies combat damage taken is mitigated by exactly 95%.
|
|
* 2. Redemption Multi-Corpse Race & Spatial Bounds:
|
|
* - 10 corpses: 5 within 16 subtiles, 5 outside 16 subtiles
|
|
* - Verifies only corpses within 2:1 isometric radius are eligible
|
|
* - Consumed corpses cannot be re-redeemed; HP/Mana replenishment is clamped to max
|
|
* 3. Cleansing Curse Eviction & Multi-Curse Pressure:
|
|
* - Paladin inflicted with Amplify Damage or Decrepify under Cleansing aura
|
|
* - Verifies curse expiration frames are curtailed proportionally
|
|
* 4. Prayer Mana Exhaustion & Meditation Synergy Boundary:
|
|
* - Paladin with low mana starts Prayer; aura auto-deactivates at 0 mana
|
|
* - Paladin switches to Meditation with 20 base points in Prayer; receives continuous heal pulses indefinitely with 0 mana drain
|
|
* 5. Salvation Elemental Isolation vs Conviction:
|
|
* - Party under Salvation attacked by Fire, Cold, Lightning, Poison, and Magic missiles
|
|
* - Verifies elemental damage is strongly mitigated while Poison and Magic take unmitigated damage
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
|
import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
|
import { AuraScanner } from '../../../src/game/engine/aura-scanner.ts'
|
|
import {
|
|
computeEffectiveResistance,
|
|
executeSUnitDmg,
|
|
type CombatUnitContext,
|
|
} from '../../../src/game/engine/combat-pipeline.ts'
|
|
import { calculateSalvationStats } from '../../../src/game/skills.ts'
|
|
|
|
describe('Paladin Defensive Auras — Adversarial Stress & Edge-Case Verification', () => {
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 1: Passive Max Resistance Stacking & Absolute 95% Hard Cap
|
|
// --------------------------------------------------------------------------
|
|
describe('Scenario 1: Passive Max Resistance Stacking & Absolute 95% Hard Cap', () => {
|
|
it('stacks passive hard-point max res with item bonuses and strictly caps at 95%', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 1000 * FIXED_ONE,
|
|
fireresist: 150, // Accrued fire resistance well above 95%
|
|
})
|
|
|
|
// Paladin with 20 base points in Resist Fire -> +10% max fire res
|
|
stats.setBaseSkillLevel(100, 20)
|
|
expect(stats.getEffectiveMaxResistance('fire')).toBe(85)
|
|
|
|
// Add item bonus: Guardian Angel (+15% to all max resistances)
|
|
stats.addStat('maxfireresist', 15)
|
|
// 75 + 10 + 15 = 100% -> Strictly clamped to 95%!
|
|
expect(stats.getEffectiveMaxResistance('fire')).toBe(95)
|
|
|
|
// Add even more max fire res (+10% from Waterwalk / Hotspur)
|
|
stats.addStat('maxfireresist', 10)
|
|
// 75 + 35 = 110% -> STILL clamped to 95%!
|
|
expect(stats.getEffectiveMaxResistance('fire')).toBe(95)
|
|
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: new StateBus(stats, registry),
|
|
unitType: 'player',
|
|
}
|
|
|
|
const statsAttacker = new UnitStatList(registry)
|
|
const attacker: CombatUnitContext = {
|
|
id: 'attacker',
|
|
name: 'Attacker',
|
|
statList: statsAttacker,
|
|
stateBus: new StateBus(statsAttacker, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
// Attacker hits with 1000 fire damage (in 256-fixed-point)
|
|
const out = executeSUnitDmg(attacker, paladin, {
|
|
skillId: 0,
|
|
attackKind: 'spell',
|
|
elemType: 'fire',
|
|
elemMin256: 1000 * FIXED_ONE,
|
|
elemMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
|
|
// 1000 fire damage mitigated by 95% = 50 damage taken (50 * 256 = 12800)
|
|
expect(out.elemDamage256).toBe(50 * FIXED_ONE)
|
|
expect(paladin.statList.getHp256()).toBe(950 * FIXED_ONE)
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 2: Redemption Multi-Corpse Race & Spatial Bounds
|
|
// --------------------------------------------------------------------------
|
|
describe('Scenario 2: Redemption Multi-Corpse Race & Spatial Bounds', () => {
|
|
it('only redeems corpses within 2:1 isometric radius, locks them immediately, and clamps HP/Mana to max', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Paladin with 100 missing HP and 50 missing Mana
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 900 * FIXED_ONE,
|
|
maxmana: 500 * FIXED_ONE,
|
|
mana: 450 * FIXED_ONE,
|
|
})
|
|
const bus = new StateBus(stats, registry)
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: bus,
|
|
unitType: 'player',
|
|
x: 100,
|
|
y: 100,
|
|
}
|
|
|
|
// 10 corpses: 5 within 16 subtiles (~213px in 2:1 space), 5 outside
|
|
// Note: in 2:1 isometric space dist = Math.hypot(dx, dy * 2)
|
|
const corpses = [
|
|
// Within:
|
|
{ id: 'c1', consumed: false, x: 120, y: 110 }, // dx=20, dy=10 -> hypot(20, 20) = 28.3px < 213px
|
|
{ id: 'c2', consumed: false, x: 80, y: 90 }, // dx=-20, dy=-10 -> hypot(-20, -20) = 28.3px < 213px
|
|
{ id: 'c3', consumed: false, x: 100, y: 150 }, // dx=0, dy=50 -> hypot(0, 100) = 100px < 213px
|
|
{ id: 'c4', consumed: false, x: 250, y: 100 }, // dx=150, dy=0 -> hypot(150, 0) = 150px < 213px
|
|
{ id: 'c5', consumed: false, x: 100, y: 190 }, // dx=0, dy=90 -> hypot(0, 180) = 180px < 213px
|
|
// Outside (>213px):
|
|
{ id: 'c6', consumed: false, x: 400, y: 100 }, // dx=300 -> 300px > 213px
|
|
{ id: 'c7', consumed: false, x: 100, y: 250 }, // dy=150 -> 300px > 213px
|
|
{ id: 'c8', consumed: false, x: 300, y: 300 }, // far
|
|
{ id: 'c9', consumed: false, x: 0, y: 0 }, // far
|
|
{ id: 'c10', consumed: false, x: 500, y: 500 },// far
|
|
]
|
|
|
|
const aura = scanner.setActiveAura(paladin, 124, 20, 0)! // slvl 20: 120 Life & Mana
|
|
const outcome = scanner.pulseAura(aura, 0, [paladin], [], corpses, {
|
|
rollChanceFn: () => true, // 100% deterministic roll
|
|
})
|
|
|
|
expect(outcome.corpsesRedeemed).toBe(5)
|
|
for (let i = 0; i < 5; i++) {
|
|
expect(corpses[i]!.consumed).toBe(true)
|
|
}
|
|
for (let i = 5; i < 10; i++) {
|
|
expect(corpses[i]!.consumed).toBe(false)
|
|
}
|
|
|
|
// Clamped strictly to maxhp (1000) and maxmana (500)
|
|
expect(paladin.statList.getHp256()).toBe(1000 * FIXED_ONE)
|
|
expect(paladin.statList.getMana256()).toBe(500 * FIXED_ONE)
|
|
|
|
// Subsequent pulse on same corpses must redeem 0
|
|
const outcome2 = scanner.pulseAura(aura, 50, [paladin], [], corpses, {
|
|
rollChanceFn: () => true,
|
|
})
|
|
expect(outcome2.corpsesRedeemed).toBe(0)
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 3: Cleansing Curse Eviction & Multi-Curse Pressure
|
|
// --------------------------------------------------------------------------
|
|
describe('Scenario 3: Cleansing Curse Eviction & Multi-Curse Pressure', () => {
|
|
it('curtails remaining curse and poison frames under Cleansing aura pulse', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
const stats = new UnitStatList(registry)
|
|
const bus = new StateBus(stats, registry)
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: bus,
|
|
unitType: 'player',
|
|
}
|
|
|
|
// Apply Decrepify curse at frame 0 for 500 frames (expireFrame = 500)
|
|
bus.applyState({
|
|
stateNameOrId: 'decrepify',
|
|
curseTypeOverride: 1,
|
|
durationFrames: 500,
|
|
currentFrame: 0,
|
|
})
|
|
|
|
// Apply Poison state for 500 frames
|
|
bus.applyState({
|
|
stateNameOrId: 'poison',
|
|
durationFrames: 500,
|
|
currentFrame: 0,
|
|
})
|
|
|
|
expect(bus.getState('decrepify')?.expireFrame).toBe(500)
|
|
expect(bus.getState('poison')?.expireFrame).toBe(500)
|
|
|
|
// Slvl 20 Cleansing: 80% reduction
|
|
const aura = scanner.setActiveAura(paladin, 109, 20, 0)!
|
|
// Pulse at currentFrame = 100
|
|
scanner.pulseAura(aura, 100, [paladin], [])
|
|
|
|
// Remaining frames before pulse: 500 - 100 = 400 frames
|
|
// Reduced by 80% -> 400 * 0.20 = 80 frames remaining
|
|
// New expireFrame = 100 + 80 = 180
|
|
expect(bus.getState('decrepify')?.expireFrame).toBe(180)
|
|
expect(bus.getState('poison')?.expireFrame).toBe(180)
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 4: Prayer Mana Exhaustion & Meditation Synergy Boundary
|
|
// --------------------------------------------------------------------------
|
|
describe('Scenario 4: Prayer Mana Exhaustion & Meditation Synergy Boundary', () => {
|
|
it('Prayer terminates on mana depletion; switching to Meditation provides infinite heal with zero drain', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const scanner = new AuraScanner(registry)
|
|
|
|
// Paladin with low mana: exactly 20 mana units (20 * 256 = 5120)
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 1000 * FIXED_ONE,
|
|
hitpoints: 500 * FIXED_ONE,
|
|
maxmana: 500 * FIXED_ONE,
|
|
mana: 32, // 32 units of mana256 = 2 frames of Prayer
|
|
})
|
|
stats.setBaseSkillLevel(99, 20) // 20 hard points in Prayer (25 HP heal)
|
|
const bus = new StateBus(stats, registry)
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: bus,
|
|
unitType: 'player',
|
|
}
|
|
|
|
// 1. Activate Prayer
|
|
scanner.setActiveAura(paladin, 99, 20, 0)
|
|
scanner.tick(1, [paladin], [])
|
|
expect(paladin.statList.getMana256()).toBe(16)
|
|
scanner.tick(2, [paladin], [])
|
|
expect(paladin.statList.getMana256()).toBe(0)
|
|
// Tick 3: drops below 16 -> aura deactivated!
|
|
scanner.tick(3, [paladin], [])
|
|
expect(paladin.stateBus.hasState('prayer')).toBe(false)
|
|
|
|
// 2. Paladin now has 0 mana, switches to Meditation (120)
|
|
paladin.statList.setMana256(0)
|
|
const medAura = scanner.setActiveAura(paladin, 120, 20, 0)!
|
|
expect(medAura).toBeDefined()
|
|
|
|
// Pulse Meditation 5 times
|
|
for (let tick = 0; tick < 250; tick += 50) {
|
|
scanner.pulseAura(medAura, tick, [paladin], [])
|
|
}
|
|
|
|
// Paladin healed 5 pulses * 25 HP = 125 HP (from 500 to 625 HP)
|
|
expect(paladin.statList.getHp256()).toBe(625 * FIXED_ONE)
|
|
// Mana remains strictly 0 (no drain occurred!)
|
|
expect(paladin.statList.getMana256()).toBe(0)
|
|
})
|
|
})
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Scenario 5: Salvation Elemental Isolation vs Conviction
|
|
// --------------------------------------------------------------------------
|
|
describe('Scenario 5: Salvation Elemental Isolation vs Conviction', () => {
|
|
it('Salvation strongly mitigates Fire, Cold, Lightning, but provides ZERO protection against Poison and Magic', async () => {
|
|
const registry = await getSharedDataRegistry()
|
|
const stats = new UnitStatList(registry, {
|
|
maxhp: 5000 * FIXED_ONE,
|
|
hitpoints: 5000 * FIXED_ONE,
|
|
})
|
|
const bus = new StateBus(stats, registry)
|
|
const salv = calculateSalvationStats(20) // +109% Fire, Cold, Lightning
|
|
|
|
bus.applyState({
|
|
stateNameOrId: 'salvation',
|
|
slvl: 20,
|
|
stats: {
|
|
fireresist: salv.fireResistPct,
|
|
coldresist: salv.coldResistPct,
|
|
lightresist: salv.lightningResistPct,
|
|
},
|
|
})
|
|
|
|
const paladin: CombatUnitContext = {
|
|
id: 'paladin',
|
|
name: 'Paladin',
|
|
statList: stats,
|
|
stateBus: bus,
|
|
unitType: 'player',
|
|
}
|
|
|
|
const statsAtk = new UnitStatList(registry)
|
|
const attacker: CombatUnitContext = {
|
|
id: 'attacker',
|
|
name: 'Attacker',
|
|
statList: statsAtk,
|
|
stateBus: new StateBus(statsAtk, registry),
|
|
unitType: 'monster',
|
|
}
|
|
|
|
// Enemy casts Conviction (-100% resistance)
|
|
const convictionPierce = 100
|
|
|
|
// 1. Fire Attack: 109% (Salvation) - 100% (Conviction) = 9% effective fire res
|
|
const outFire = executeSUnitDmg(attacker, paladin, {
|
|
skillId: 0,
|
|
attackKind: 'spell',
|
|
elemType: 'fire',
|
|
elemMin256: 1000 * FIXED_ONE,
|
|
elemMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
convictionPierce,
|
|
})
|
|
// 1000 * (100 - 9)% = 910 damage
|
|
expect(outFire.elemDamage256).toBe(910 * FIXED_ONE)
|
|
|
|
// 2. Poison Attack: Salvation gives 0 poison res -> 0% base poison res
|
|
const outPois = executeSUnitDmg(attacker, paladin, {
|
|
skillId: 0,
|
|
attackKind: 'spell',
|
|
elemType: 'pois',
|
|
elemMin256: 1000 * FIXED_ONE,
|
|
elemMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
// 1000 * (100 - 0)% = 1000 unmitigated poison damage!
|
|
expect(outPois.elemDamage256).toBe(1000 * FIXED_ONE)
|
|
|
|
// 3. Magic Attack: Salvation gives 0 magic res -> 0% base magic res
|
|
const outMag = executeSUnitDmg(attacker, paladin, {
|
|
skillId: 0,
|
|
attackKind: 'spell',
|
|
elemType: 'mag',
|
|
elemMin256: 1000 * FIXED_ONE,
|
|
elemMax256: 1000 * FIXED_ONE,
|
|
autoHit: true,
|
|
})
|
|
// 1000 * (100 - 0)% = 1000 unmitigated magic damage!
|
|
expect(outMag.elemDamage256).toBe(1000 * FIXED_ONE)
|
|
})
|
|
})
|
|
})
|