/** * Comprehensive 1.13c Unit Verification Suite — Paladin Defensive Auras Tree (10 Skills). * * Covers: * - Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5 * - Prayer (99): Periodic heal pulse, continuous mana drain (16/256), auto-deactivation at 0 mana * - Resist Fire (100): Active party fire res, passive max fire res (+1% per 2 hard points, capped at 95%) * - Defiance (104): Active party %Defense, Holy Shield synergy (+15%/blvl) * - Resist Cold (105): Active party cold res, passive max cold res (+1% per 2 hard points, capped at 95%) * - Cleansing (109): Curse & poison duration reduction, free Prayer synergy heal pulse * - Resist Lightning (110): Active party lightning res, passive max lightning res (+1% per 2 hard points, capped at 95%) * - Vigor (115): FRW velocitypercent, stamina recovery bonus, max stamina % * - Meditation (120): Active party mana recovery rate, free Prayer synergy heal pulse * - Redemption (124): 50-tick cadence, fixed 16 subtiles in 2:1 isometric space, dm34 chance, ln56 life/mana restore, corpse consumption * - Salvation (125): Active party Fire/Cold/Lightning res, zero effect on Poison or Magic */ import { describe, expect, it } from 'vitest' import { BATCH1_SKILLS, ISO_GROUND_ASPECT_RATIO, calculatePrayerStats, calculateResistFireStats, calculateDefianceStats, calculateResistColdStats, calculateCleansingStats, calculateResistLightningStats, calculateVigorStats, calculateMeditationStats, calculateRedemptionStats, calculateSalvationStats, } from '../../../src/server/skills/skills.ts' 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' describe('Paladin Defensive Auras — Milestone M9 Architecture & Formula Invariants', () => { it('enforces architecture invariants: BATCH1_SKILLS length === 38 and ISO_GROUND_ASPECT_RATIO === 0.5', () => { expect(Object.keys(BATCH1_SKILLS).length).toBe(38) expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) }) // -------------------------------------------------------------------------- // 1. Prayer (99) // -------------------------------------------------------------------------- describe('Skill #099: Prayer', () => { it('scales heal amount (edns 5-band) and radius at slvl 1, 10, and 20', () => { const s1 = calculatePrayerStats(1) expect(s1.healAmount).toBe(2) expect(s1.healAmount256).toBe(512) expect(s1.manaCostPerFrame256).toBe(16) expect(s1.radiusSubtiles).toBe(16) expect(s1.radiusYards).toBe(10.67) const s10 = calculatePrayerStats(10) expect(s10.healAmount).toBe(11) // 2 + 7*1 + 2*1 = 11 expect(s10.healAmount256).toBe(11 * 256) expect(s10.radiusSubtiles).toBe(34) // 16 + 9*2 = 34 expect(s10.radiusYards).toBe(22.67) const s20 = calculatePrayerStats(20) expect(s20.healAmount).toBe(25) // 2 + 7 + 8 + 4*2 = 25 expect(s20.healAmount256).toBe(25 * 256) expect(s20.radiusSubtiles).toBe(54) // 16 + 19*2 = 54 }) it('heals self and damaged party allies within range on pulse', async () => { const registry = await getSharedDataRegistry() const scanner = new AuraScanner(registry) const statsCaster = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, mana: 500 * FIXED_ONE, }) const busCaster = new StateBus(statsCaster, registry) const caster: CombatUnitContext = { id: 'paladin_caster', name: 'Paladin Caster', statList: statsCaster, stateBus: busCaster, unitType: 'player', x: 0, y: 0, } const statsAlly = new UnitStatList(registry, { maxhp: 800 * FIXED_ONE, hitpoints: 400 * FIXED_ONE, }) const busAlly = new StateBus(statsAlly, registry) const ally: CombatUnitContext = { id: 'party_ally', name: 'Party Ally', statList: statsAlly, stateBus: busAlly, unitType: 'party', x: 50, y: 25, // within range in 2:1 isometric space } const aura = scanner.setActiveAura(caster, 99, 10, 0)! expect(aura).toBeDefined() const outcome = scanner.pulseAura(aura, 0, [caster, ally], []) expect(outcome.healthRestored).toBe(22) // 11 to caster + 11 to ally expect(caster.statList.getHp256()).toBe((500 + 11) * FIXED_ONE) expect(ally.statList.getHp256()).toBe((400 + 11) * FIXED_ONE) }) it('drains mana per tick and auto-deactivates when mana reaches 0', async () => { const registry = await getSharedDataRegistry() const scanner = new AuraScanner(registry) // Paladin with exactly 32 units of mana256 (enough for 2 frames) const stats = new UnitStatList(registry, { maxmana: 100 * FIXED_ONE, mana: 32, }) const bus = new StateBus(stats, registry) const paladin: CombatUnitContext = { id: 'paladin', name: 'Paladin', statList: stats, stateBus: bus, unitType: 'player', } scanner.setActiveAura(paladin, 99, 1, 0) expect(paladin.statList.getMana256()).toBe(32) // Frame 1: drains 16 units -> 16 remaining scanner.tick(1, [paladin], []) expect(paladin.statList.getMana256()).toBe(16) // Frame 2: drains 16 units -> 0 remaining scanner.tick(2, [paladin], []) expect(paladin.statList.getMana256()).toBe(0) // Frame 3: curMana < 16 -> auto-deactivates aura and removes state scanner.tick(3, [paladin], []) expect(paladin.statList.getMana256()).toBe(0) expect(paladin.stateBus.hasState('prayer')).toBe(false) }) }) // -------------------------------------------------------------------------- // 2. Resist Fire (100) // -------------------------------------------------------------------------- describe('Skill #100: Resist Fire', () => { it('scales active party fire resistance at slvl 1, 10, and 20', () => { const s1 = calculateResistFireStats(1) expect(s1.fireResistPct).toBe(53) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateResistFireStats(10) expect(s10.fireResistPct).toBe(114) const s20 = calculateResistFireStats(20) expect(s20.fireResistPct).toBe(132) }) it('grants passive +1% Max Fire Resistance per 2 hard points, active even when aura is inactive', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) expect(stats.getEffectiveMaxResistance('fire')).toBe(75) // 5 hard points in Resist Fire -> +2% max fire res (floor(5/2) = 2) stats.setBaseSkillLevel(100, 5) expect(stats.getAccruedStat('maxfireresist')).toBe(2) expect(stats.getEffectiveMaxResistance('fire')).toBe(77) // +skills items do NOT increase passive max res (blvl only!) stats.setBonusSkillLevel(100, 10) expect(stats.getAccruedStat('maxfireresist')).toBe(2) expect(stats.getEffectiveMaxResistance('fire')).toBe(77) // 20 hard points -> +10% max fire res -> 85% stats.setBaseSkillLevel(100, 20) expect(stats.getAccruedStat('maxfireresist')).toBe(10) expect(stats.getEffectiveMaxResistance('fire')).toBe(85) }) }) // -------------------------------------------------------------------------- // 3. Defiance (104) // -------------------------------------------------------------------------- describe('Skill #104: Defiance', () => { it('scales party %Defense at slvl 1, 10, and 20', () => { const s1 = calculateDefianceStats(1) expect(s1.defensePercent).toBe(70) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateDefianceStats(10) expect(s10.defensePercent).toBe(160) const s20 = calculateDefianceStats(20) expect(s20.defensePercent).toBe(260) }) it('increases party member defense in executeSUnitDmg reducing monster chance to hit', async () => { const registry = await getSharedDataRegistry() const statsPal = new UnitStatList(registry, { armorclass: 1000 }) const busPal = new StateBus(statsPal, registry) busPal.applyState({ stateNameOrId: 'defiance', slvl: 10, stats: { skill_armor_percent: 160 } }) expect(statsPal.getAccruedStat('armorclass')).toBe(2600) // 1000 * 2.6 = 2600 }) }) // -------------------------------------------------------------------------- // 4. Resist Cold (105) // -------------------------------------------------------------------------- describe('Skill #105: Resist Cold', () => { it('scales active party cold resistance at slvl 1, 10, and 20', () => { const s1 = calculateResistColdStats(1) expect(s1.coldResistPct).toBe(53) const s10 = calculateResistColdStats(10) expect(s10.coldResistPct).toBe(114) expect(calculateResistColdStats(20).coldResistPct).toBe(132) }) it('grants passive +1% Max Cold Resistance per 2 hard points', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) expect(stats.getEffectiveMaxResistance('cold')).toBe(75) stats.setBaseSkillLevel(105, 14) expect(stats.getAccruedStat('maxcoldresist')).toBe(7) expect(stats.getEffectiveMaxResistance('cold')).toBe(82) }) }) // -------------------------------------------------------------------------- // 5. Cleansing (109) // -------------------------------------------------------------------------- describe('Skill #109: Cleansing', () => { it('scales curse & poison duration reduction at slvl 1, 10, and 20', () => { const s1 = calculateCleansingStats(1) expect(s1.curseReductionPct).toBe(39) expect(s1.poisonLengthReductionPct).toBe(39) const s10 = calculateCleansingStats(10) expect(s10.curseReductionPct).toBe(71) const s20 = calculateCleansingStats(20) expect(s20.curseReductionPct).toBe(80) }) it('emits free Prayer heal pulse when caster has Prayer hard points (0 mana cost)', async () => { const registry = await getSharedDataRegistry() const scanner = new AuraScanner(registry) const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 100 * FIXED_ONE, mana: 100 * FIXED_ONE, }) stats.setBaseSkillLevel(99, 10) // 10 hard points in Prayer (11 HP heal) const bus = new StateBus(stats, registry) const paladin: CombatUnitContext = { id: 'paladin', name: 'Paladin', statList: stats, stateBus: bus, unitType: 'player', } const aura = scanner.setActiveAura(paladin, 109, 1, 0)! const outcome = scanner.pulseAura(aura, 0, [paladin], []) expect(outcome.healthRestored).toBe(11) expect(paladin.statList.getHp256()).toBe(511 * FIXED_ONE) // Cleansing does NOT drain mana! expect(paladin.statList.getMana256()).toBe(100 * FIXED_ONE) }) it('shortens active curse duration on party members', 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', } // Inflict Amplify Damage curse for 100 frames bus.applyState({ stateNameOrId: 'amplifydamage', curseTypeOverride: 1, durationFrames: 100, currentFrame: 0, }) expect(bus.getState('amplifydamage')?.expireFrame).toBe(100) // Pulse Cleansing slvl 10 (71% reduction) at tick 0 const aura = scanner.setActiveAura(paladin, 109, 10, 0)! scanner.pulseAura(aura, 0, [paladin], []) // Remaining 100 frames reduced by 71% -> 100 * 0.29 = 29 frames remaining expect(bus.getState('amplifydamage')?.expireFrame).toBe(29) }) }) // -------------------------------------------------------------------------- // 6. Resist Lightning (110) // -------------------------------------------------------------------------- describe('Skill #110: Resist Lightning', () => { it('scales active party lightning resistance at slvl 1, 10, and 20', () => { const s1 = calculateResistLightningStats(1) expect(s1.lightningResistPct).toBe(53) expect(calculateResistLightningStats(10).lightningResistPct).toBe(114) expect(calculateResistLightningStats(20).lightningResistPct).toBe(132) }) it('grants passive +1% Max Lightning Resistance per 2 hard points', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) expect(stats.getEffectiveMaxResistance('ltng')).toBe(75) stats.setBaseSkillLevel(110, 20) expect(stats.getAccruedStat('maxlightresist')).toBe(10) expect(stats.getEffectiveMaxResistance('ltng')).toBe(85) }) }) // -------------------------------------------------------------------------- // 7. Vigor (115) // -------------------------------------------------------------------------- describe('Skill #115: Vigor', () => { it('scales FRW %, stamina recovery %, and max stamina % at slvl 1, 10, and 20', () => { const s1 = calculateVigorStats(1) expect(s1.velocityPercent).toBe(13) expect(s1.staminaRecoveryBonusPct).toBe(50) expect(s1.maxStaminaPercent).toBe(50) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateVigorStats(10) expect(s10.velocityPercent).toBe(36) expect(s10.staminaRecoveryBonusPct).toBe(275) expect(s10.maxStaminaPercent).toBe(275) expect(s10.radiusSubtiles).toBe(43) // 16 + 9*3 = 43 const s20 = calculateVigorStats(20) expect(s20.velocityPercent).toBe(43) expect(s20.staminaRecoveryBonusPct).toBe(525) expect(s20.maxStaminaPercent).toBe(525) expect(s20.radiusSubtiles).toBe(73) // 16 + 19*3 = 73 }) }) // -------------------------------------------------------------------------- // 8. Meditation (120) // -------------------------------------------------------------------------- describe('Skill #120: Meditation', () => { it('scales party mana recovery bonus at slvl 1, 10, and 20', () => { const s1 = calculateMeditationStats(1) expect(s1.manaRecoveryBonusPct).toBe(300) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateMeditationStats(10) expect(s10.manaRecoveryBonusPct).toBe(525) const s20 = calculateMeditationStats(20) expect(s20.manaRecoveryBonusPct).toBe(775) }) it('emits free Prayer heal pulse with 0 mana drain', async () => { const registry = await getSharedDataRegistry() const scanner = new AuraScanner(registry) const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 100 * FIXED_ONE, mana: 100 * FIXED_ONE, }) 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', } const aura = scanner.setActiveAura(paladin, 120, 1, 0)! const outcome = scanner.pulseAura(aura, 0, [paladin], []) expect(outcome.healthRestored).toBe(25) expect(paladin.statList.getHp256()).toBe(525 * FIXED_ONE) expect(paladin.statList.getMana256()).toBe(100 * FIXED_ONE) }) }) // -------------------------------------------------------------------------- // 9. Redemption (124) // -------------------------------------------------------------------------- describe('Skill #124: Redemption', () => { it('scales redemption chance and recovery amounts at slvl 1, 10, and 20', () => { const s1 = calculateRedemptionStats(1) expect(s1.redemptionChancePct).toBe(24) expect(s1.lifeRecoverPts).toBe(25) expect(s1.manaRecoverPts).toBe(25) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateRedemptionStats(10) expect(s10.redemptionChancePct).toBe(71) expect(s10.lifeRecoverPts).toBe(70) expect(s10.manaRecoverPts).toBe(70) const s20 = calculateRedemptionStats(20) expect(s20.redemptionChancePct).toBe(86) expect(s20.lifeRecoverPts).toBe(120) expect(s20.manaRecoverPts).toBe(120) }) it('consumes valid corpses within 16 subtiles and restores Life & Mana to caster only', async () => { const registry = await getSharedDataRegistry() const scanner = new AuraScanner(registry) const stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, maxmana: 500 * FIXED_ONE, mana: 200 * FIXED_ONE, }) const bus = new StateBus(stats, registry) const paladin: CombatUnitContext = { id: 'paladin', name: 'Paladin', statList: stats, stateBus: bus, unitType: 'player', x: 0, y: 0, } const corpses = [ { id: 'corpse_1', consumed: false, x: 20, y: 10 }, // inside 16 subtiles (~213px) { id: 'corpse_2', consumed: false, x: 50, y: 25 }, // inside { id: 'corpse_3', consumed: true, x: 10, y: 5 }, // already consumed { id: 'corpse_4', consumed: false, x: 300, y: 150 }, // outside range ] const aura = scanner.setActiveAura(paladin, 124, 10, 0)! // slvl 10: 70 Life/Mana // Always pass rollChanceFn for deterministic test const outcome = scanner.pulseAura(aura, 0, [paladin], [], corpses, { rollChanceFn: () => true, }) expect(outcome.corpsesRedeemed).toBe(2) expect(corpses[0]!.consumed).toBe(true) expect(corpses[1]!.consumed).toBe(true) expect(corpses[3]!.consumed).toBe(false) // untouched outside // Restored 2 corpses * 70 pts = 140 HP and 140 Mana expect(paladin.statList.getHp256()).toBe((500 + 140) * FIXED_ONE) expect(paladin.statList.getMana256()).toBe((200 + 140) * FIXED_ONE) }) }) // -------------------------------------------------------------------------- // 10. Salvation (125) // -------------------------------------------------------------------------- describe('Skill #125: Salvation', () => { it('scales active Fire, Cold, Lightning resistances at slvl 1, 10, and 20', () => { const s1 = calculateSalvationStats(1) expect(s1.fireResistPct).toBe(61) expect(s1.coldResistPct).toBe(61) expect(s1.lightningResistPct).toBe(61) expect(s1.radiusSubtiles).toBe(16) const s10 = calculateSalvationStats(10) expect(s10.fireResistPct).toBe(98) expect(s10.coldResistPct).toBe(98) expect(s10.lightningResistPct).toBe(98) const s20 = calculateSalvationStats(20) expect(s20.fireResistPct).toBe(109) expect(s20.coldResistPct).toBe(109) expect(s20.lightningResistPct).toBe(109) }) it('strictly provides zero resistance bonus to Poison or Magic', async () => { const registry = await getSharedDataRegistry() const stats = new UnitStatList(registry) const bus = new StateBus(stats, registry) const salv = calculateSalvationStats(10) bus.applyState({ stateNameOrId: 'salvation', slvl: 10, stats: { fireresist: salv.fireResistPct, coldresist: salv.coldResistPct, lightresist: salv.lightningResistPct, }, }) expect(stats.getAccruedStat('fireresist')).toBe(98) expect(stats.getAccruedStat('coldresist')).toBe(98) expect(stats.getAccruedStat('lightresist')).toBe(98) // Strictly zero to poison or magic! expect(stats.getAccruedStat('poisonresist')).toBe(0) expect(stats.getAccruedStat('magicresist')).toBe(0) }) }) })