/** * 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/baker/mpq-archive-source.ts' import { FIXED_ONE, UnitStatList } from '../../../src/server/engine/stat-list.ts' import { StateBus } from '../../../src/server/engine/state-bus.ts' import { AuraScanner } from '../../../src/server/engine/aura-scanner.ts' import { computeEffectiveResistance, executeSUnitDmg, type CombatUnitContext, } from '../../../src/server/engine/combat-pipeline.ts' import { calculateSalvationStats } from '../../../src/server/skills/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) }) }) })