From 1a5f897cd6f389643db30d753b002c8d4056c583 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 15:54:52 +0000 Subject: [PATCH] feat(pal): implement Milestone M9 Paladin Defensive Auras tree (10 skills) --- src/game/engine/aura-scanner.ts | 136 ++++- src/game/engine/combat-pipeline.ts | 42 +- src/game/engine/stat-list.ts | 27 + src/game/engine/state-bus.ts | 25 + src/game/skills.ts | 329 +++++++++++ src/game/skills/impl/pal/skill-099-prayer.ts | 1 + .../skills/impl/pal/skill-100-resist-fire.ts | 1 + .../skills/impl/pal/skill-104-defiance.ts | 1 + .../skills/impl/pal/skill-105-resist-cold.ts | 1 + .../skills/impl/pal/skill-109-cleansing.ts | 1 + .../impl/pal/skill-110-resist-lightning.ts | 1 + src/game/skills/impl/pal/skill-115-vigor.ts | 1 + .../skills/impl/pal/skill-120-meditation.ts | 1 + .../skills/impl/pal/skill-124-redemption.ts | 1 + .../skills/impl/pal/skill-125-salvation.ts | 1 + .../adv-pal-defensive-auras-stress.test.ts | 345 ++++++++++++ tests/skills/pal/defensive-auras.test.ts | 523 ++++++++++++++++++ 17 files changed, 1426 insertions(+), 11 deletions(-) create mode 100644 tests/skills/pal/adv-pal-defensive-auras-stress.test.ts create mode 100644 tests/skills/pal/defensive-auras.test.ts diff --git a/src/game/engine/aura-scanner.ts b/src/game/engine/aura-scanner.ts index 3291d1d..f740dfb 100644 --- a/src/game/engine/aura-scanner.ts +++ b/src/game/engine/aura-scanner.ts @@ -12,9 +12,10 @@ * - `Redemption` (`124`) corpse consumption pulse restoring player HP & Mana. */ import type { D2DataRegistry, SkillRecord } from './data-registry.ts' -import { evaluateCalc, compute5BandScaling } from './calc-ast.ts' +import { evaluateCalc, compute5BandScaling, computeDiminishingReturns } from './calc-ast.ts' import { FIXED_ONE } from './stat-list.ts' import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts' +import { calculatePrayerStats, calculateRedemptionStats } from '../skills.ts' export interface ActiveAuraSource { readonly sourceUnit: CombatUnitContext @@ -31,11 +32,21 @@ export interface AuraPulseOutcome { readonly enemiesAffected: number readonly pulseDamageDealt: number readonly corpsesRedeemed: number + readonly healthRestored?: number + readonly manaRestored?: number +} + +export interface CorpseContext { + readonly id: string + consumed: boolean + readonly x?: number + readonly y?: number } export interface AuraPulseOptions { readonly isTown?: boolean | undefined readonly radiusPx?: number | undefined + readonly rollChanceFn?: (chancePct: number) => boolean } export class AuraScanner { @@ -88,7 +99,7 @@ export class AuraScanner { currentTick: number, allies: readonly CombatUnitContext[], enemies: readonly CombatUnitContext[], - corpses?: { id: string; consumed: boolean }[], + corpses?: CorpseContext[], options?: AuraPulseOptions, ): AuraPulseOutcome { const { sourceUnit, skill, slvl } = aura @@ -149,6 +160,8 @@ export class AuraScanner { let enemiesAffected = 0 let pulseDamageDealt = 0 let corpsesRedeemed = 0 + let healthRestored = 0 + let manaRestored = 0 const radiusSubtiles = skill.auraRangeCalc ? evaluateCalc(skill.auraRangeCalc, calcCtx) : 0 const computedRadiusPx = options?.radiusPx ?? (radiusSubtiles > 0 ? Math.round(radiusSubtiles * (40 / 3)) : 0) @@ -202,6 +215,46 @@ export class AuraScanner { } } + // Prayer (99), Cleansing (109), and Meditation (120) Heal Pulse & Synergy: + // - Prayer (99): heals self and party allies within range by edns HP + // - Cleansing (109) & Meditation (120): if caster has hard points in Prayer (blvl > 0), + // emits Prayer heal pulse at Prayer blvl efficiency with 0 mana cost + // - Cleansing (109): shortens active curse and poison durations proportionally on self and allies + if (skill.id === 99 || skill.id === 109 || skill.id === 120) { + let healPts = 0 + if (skill.id === 99) { + healPts = calculatePrayerStats(slvl).healAmount + } else { + const prayerBlvl = sourceUnit.statList.getBaseSkillLevel(99) + if (prayerBlvl > 0) { + healPts = calculatePrayerStats(prayerBlvl).healAmount + } + } + + if (healPts > 0) { + const healPts256 = healPts * FIXED_ONE + const targetsToHeal = [sourceUnit, ...(isTown ? [] : allies.filter(a => a.id !== sourceUnit.id && isTargetInRange(a)))] + for (const target of targetsToHeal) { + const curHp = target.statList.getHp256() + const maxHp = target.statList.getMaxHp256() + const newHp = Math.min(maxHp, curHp + healPts256) + if (newHp > curHp) { + target.statList.setHp256(newHp) + healthRestored += (newHp - curHp) / FIXED_ONE + } + } + } + + if (skill.id === 109) { + // Cleansing curse & poison length reduction (dm34: 30..90 -> 39%..80%) + const reductionPct = computeDiminishingReturns(30, 90, slvl) + const targetsToCleanse = [sourceUnit, ...(isTown ? [] : allies.filter(a => a.id !== sourceUnit.id && isTargetInRange(a)))] + for (const target of targetsToCleanse) { + target.stateBus.reduceCurseAndPoisonDurations(currentTick, reductionPct) + } + } + } + // In town, offensive / debuff auras against enemies and corpse redemption are suspended! if (!isTown) { // Conviction (`123`): applies `conviction` (`conviction_pierce` + `-defense`) to wilderness enemies @@ -301,15 +354,52 @@ export class AuraScanner { } // Redemption (`124`): consumes corpses to recover HP & Mana + // Fixed 16 subtiles radius (~213.33px in 2:1 isometric ground space) + // d100 check against dm34 (24%..86%), restores ln56 (25..120) Life and Mana if (skill.id === 124 && corpses) { - const recoverPts = evaluateCalc('ln34', calcCtx) + const redemptionStats = calculateRedemptionStats(slvl) + const redemptionRadiusPx = Math.round(16 * (40 / 3)) // 213px + const recoverPts = redemptionStats.lifeRecoverPts + const chancePct = redemptionStats.redemptionChancePct + + const rollChance = + options?.rollChanceFn ?? + ((chance: number) => { + return Math.random() * 100 < chance + }) + for (const c of corpses) { - if (!c.consumed) { - c.consumed = true - corpsesRedeemed += 1 - sourceUnit.statList.setHp256(sourceUnit.statList.getHp256() + recoverPts * FIXED_ONE) - sourceUnit.statList.setMana256(sourceUnit.statList.getMana256() + recoverPts * FIXED_ONE) + if (c.consumed) continue + + // Spatial check in 2:1 isometric ground space + if ( + sourceUnit.x !== undefined && + sourceUnit.y !== undefined && + c.x !== undefined && + c.y !== undefined + ) { + const dx = c.x - sourceUnit.x + const dy = c.y - sourceUnit.y + const dist = Math.hypot(dx, dy * 2) + if (dist > redemptionRadiusPx) continue } + + // Chance check + if (!rollChance(chancePct)) continue + + c.consumed = true + corpsesRedeemed += 1 + const curHp = sourceUnit.statList.getHp256() + const maxHp = sourceUnit.statList.getMaxHp256() + const newHp = Math.min(maxHp, curHp + recoverPts * FIXED_ONE) + sourceUnit.statList.setHp256(newHp) + healthRestored += (newHp - curHp) / FIXED_ONE + + const curMana = sourceUnit.statList.getMana256() + const maxMana = sourceUnit.statList.getMaxMana256() + const newMana = Math.min(maxMana, curMana + recoverPts * FIXED_ONE) + sourceUnit.statList.setMana256(newMana) + manaRestored += (newMana - curMana) / FIXED_ONE } } } @@ -321,25 +411,51 @@ export class AuraScanner { enemiesAffected, pulseDamageDealt, corpsesRedeemed, + healthRestored, + manaRestored, } } /** * Periodic tick step across all active auras. + * Handles continuous per-frame mana drain for Prayer (99) and auto-deactivates at 0 mana. */ tick( currentTick: number, allies: readonly CombatUnitContext[], enemies: readonly CombatUnitContext[], - corpses?: { id: string; consumed: boolean }[], + corpses?: CorpseContext[], options?: AuraPulseOptions, ): AuraPulseOutcome[] { const outcomes: AuraPulseOutcome[] = [] - for (const aura of this.activeAuras.values()) { + const aurasToRemove: string[] = [] + + for (const [key, aura] of this.activeAuras.entries()) { + // Prayer (99): continuous mana drain (16 units of mana256 per frame = 1/16 mana/frame) + if (aura.skill.id === 99) { + const curMana256 = aura.sourceUnit.statList.getMana256() + const manaCost256 = 16 // 16 / 256 per frame + if (curMana256 < manaCost256) { + // Depleted mana -> shut down Prayer + aura.sourceUnit.statList.setMana256(0) + aura.sourceUnit.stateBus.removeState('prayer') + aurasToRemove.push(key) + continue + } else { + aura.sourceUnit.statList.setMana256(curMana256 - manaCost256) + } + } + if ((currentTick - aura.startTick) % aura.pulseIntervalTicks === 0) { outcomes.push(this.pulseAura(aura, currentTick, allies, enemies, corpses, options)) } } + + for (const key of aurasToRemove) { + this.activeAuras.delete(key) + } + return outcomes } } + diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index d1730f2..0e8058a 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -67,12 +67,30 @@ export function computeEffectiveResistance(params: { lowerResistPierce?: number passiveEnemyPierce?: number canBreakImmunity?: boolean + maxResistance?: number | undefined + isDefenderPlayer?: boolean | undefined }): ResistanceResult { const baseRes = Math.trunc(params.baseRes) const rawConvictionAndLR = Math.max(0, Math.trunc((params.convictionPierce ?? 0) + (params.lowerResistPierce ?? 0))) const canBreak = params.canBreakImmunity ?? true // 1.13c Rule: When base resistance >= 100%, Conviction and Lower Resist apply at 1/5 effectiveness! + // Note: For player units, positive resistance stacks to buffer against -res, but cannot grant immunity. + const isPlayerOrAlly = params.isDefenderPlayer ?? false + if (isPlayerOrAlly) { + const resAfterConvictionLR = baseRes - rawConvictionAndLR + const passivePierce = Math.max(0, Math.trunc(params.passiveEnemyPierce ?? 0)) + const maxRes = params.maxResistance !== undefined ? Math.min(95, Math.max(0, params.maxResistance)) : 95 + const effectiveRes = Math.max(-100, Math.min(maxRes, resAfterConvictionLR - passivePierce)) + return { + baseRes, + convictionAndLRApplied: rawConvictionAndLR, + resAfterConvictionLR, + isImmune: false, + effectiveRes, + } + } + const convictionAndLRApplied = baseRes >= 100 ? canBreak @@ -91,8 +109,10 @@ export function computeEffectiveResistance(params: { } } + const passivePierce = Math.max(0, Math.trunc(params.passiveEnemyPierce ?? 0)) - const effectiveRes = Math.max(-100, Math.min(99, resAfterConvictionLR - passivePierce)) + const maxRes = params.maxResistance !== undefined ? Math.min(95, Math.max(0, params.maxResistance)) : 99 + const effectiveRes = Math.max(-100, Math.min(maxRes, resAfterConvictionLR - passivePierce)) return { baseRes, convictionAndLRApplied, @@ -102,6 +122,7 @@ export function computeEffectiveResistance(params: { } } + /** * Compute 1.13c `RollToHit` chance (`[5, 95]`, or `100` for `autohit` skills such as Smite / Guided Arrow / Spells). */ @@ -667,6 +688,17 @@ export function executeSUnitDmg( ? attacker.statList.getAccruedStat('passive_pois_pierce') : 0) + const maxResistance = + elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' || elemType === 'pois' + ? defender.statList.getEffectiveMaxResistance(elemType) + : undefined + + const isDefenderPlayer = + defender.unitType === 'player' || + defender.unitType === 'mercenary' || + defender.unitType === 'party' || + defender.unitType === 'party_mercenary' + const elemResRec = computeEffectiveResistance({ baseRes: defender.statList.getAccruedStat(resStatKey), convictionPierce, @@ -674,8 +706,12 @@ export function executeSUnitDmg( passiveEnemyPierce: passivePierce, // Magic resistance cannot be broken by Conviction/Lower Resist canBreakImmunity: isElemPiercableByLR, + maxResistance, + isDefenderPlayer, }) + + // 6. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`) absorb BEFORE resistances in 1.13c const reactive = defender.stateBus.triggerReactiveEvents({ event: 'absorbdamage', @@ -748,6 +784,7 @@ export function executeSUnitDmg( convictionPierce, lowerResistPierce, passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_fire_pierce'), + maxResistance: defender.statList.getEffectiveMaxResistance('fire'), }) if (!fireRes.isImmune) { finalElem256 += Math.max(0, Math.trunc((rawFire256 * (100 - fireRes.effectiveRes)) / 100)) @@ -761,6 +798,7 @@ export function executeSUnitDmg( convictionPierce, lowerResistPierce, passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_cold_pierce'), + maxResistance: defender.statList.getEffectiveMaxResistance('cold'), }) if (!coldRes.isImmune) { finalElem256 += Math.max(0, Math.trunc((rawCold256 * (100 - coldRes.effectiveRes)) / 100)) @@ -783,11 +821,13 @@ export function executeSUnitDmg( convictionPierce, lowerResistPierce, passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_ltng_pierce'), + maxResistance: defender.statList.getEffectiveMaxResistance('ltng'), }) if (!ltngRes.isImmune) { finalElem256 += Math.max(0, Math.trunc((rawLtng256 * (100 - ltngRes.effectiveRes)) / 100)) } } + } // Smite (97) / generic stun application diff --git a/src/game/engine/stat-list.ts b/src/game/engine/stat-list.ts index be079ba..7d0000a 100644 --- a/src/game/engine/stat-list.ts +++ b/src/game/engine/stat-list.ts @@ -229,11 +229,38 @@ export class UnitStatList { if (pct !== 0) { total = Math.trunc((total * (100 + pct)) / 100) } + } else if (key === 'maxfireresist') { + const blvlResistFire = this.getBaseSkillLevel(100) + total += Math.floor(blvlResistFire / 2) + } else if (key === 'maxcoldresist') { + const blvlResistCold = this.getBaseSkillLevel(105) + total += Math.floor(blvlResistCold / 2) + } else if (key === 'maxlightresist') { + const blvlResistLight = this.getBaseSkillLevel(110) + total += Math.floor(blvlResistLight / 2) } return Math.trunc(total) } + /** + * Helper method to compute effective max resistance for an elemental/poison type. + * In 1.13c: base max resistance is 75%, boosted by accrued max resistance (`max{elem}resist`), + * and hard-capped between 75% and 95%. + */ + getEffectiveMaxResistance(elemType: 'fire' | 'cold' | 'ltng' | 'pois'): number { + const statKey = + elemType === 'fire' + ? 'maxfireresist' + : elemType === 'cold' + ? 'maxcoldresist' + : elemType === 'ltng' + ? 'maxlightresist' + : 'maxpoisonresist' + const bonus = this.getAccruedStat(statKey) + return Math.max(75, Math.min(95, 75 + bonus)) + } + /** * Attach or update a `SkillStatModifier`. */ diff --git a/src/game/engine/state-bus.ts b/src/game/engine/state-bus.ts index eaa766a..d35e118 100644 --- a/src/game/engine/state-bus.ts +++ b/src/game/engine/state-bus.ts @@ -355,6 +355,31 @@ export class StateBus { return best } + /** + * Cleansing (Skill 109): Shorten active curse and poison durations proportionally by reductionPct. + * `remainingFrames * (100 - reductionPct) / 100`. + * Also updates corresponding modifier in statList. + */ + reduceCurseAndPoisonDurations(currentFrame: number, reductionPct: number): void { + if (reductionPct <= 0) return + const factor = Math.max(0, (100 - Math.min(100, reductionPct)) / 100) + for (const entry of this.entries) { + const isCurse = entry.curseType > 0 + const isPoison = entry.stateName === 'poison' || entry.stateName.includes('poison') + if ((isCurse || isPoison) && entry.expireFrame > currentFrame) { + const remaining = entry.expireFrame - currentFrame + const newRemaining = Math.max(1, Math.round(remaining * factor)) + entry.expireFrame = currentFrame + newRemaining + for (const mod of this.statList.getActiveModifiers()) { + if (mod.stateName === entry.stateName) { + mod.expireFrame = currentFrame + newRemaining + } + } + } + } + } + + /** * Trigger reactive `auraeventfunc` handlers (`Chilling/Frozen/Shiver Armor`, * `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`, diff --git a/src/game/skills.ts b/src/game/skills.ts index 9e0b423..0a180d2 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -7007,6 +7007,18 @@ function computeBand5( return total } +/** + * Compute `D2Common` diminishing-returns formula `dm(a, b, lvl)`: + * `floor((110 * lvl * (b - a)) / (100 * (lvl + 6))) + a` + */ +function computeDiminishingReturns(a: number, b: number, lvl: number): number { + if (lvl <= 0) return a + const num = 110 * lvl * (b - a) + const den = 100 * (lvl + 6) + return Math.trunc(num / den) + a +} + + // ============================================================================ // PALADIN OFFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators // ============================================================================ @@ -7397,5 +7409,322 @@ export function calculateConvictionStats(slvl: number): { } } +// ============================================================================ +// PALADIN DEFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators +// ============================================================================ + +/** + * 1. Prayer (Skill 99) + * - Periodic heal pulse (every 50 ticks): edns = computeBand5(slvl, 2, 1, 1, 2, 2, 3) HP + * (slvl 1: 2 HP, slvl 10: 11 HP, slvl 20: 25 HP) + * - Mana cost: 16 units of mana256/frame (1/16 mana/frame = 1.5625 mana/second at 25 fps) + * - Auto-deactivates when mana is depleted (< 16 mana256) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculatePrayerStats(slvl: number): { + healAmount: number + healAmount256: number + manaCostPerFrame256: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const healAmount = computeBand5(lvl, 2, 1, 1, 2, 2, 3) + const healAmount256 = healAmount << 8 + const manaCostPerFrame256 = 16 + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + healAmount, + healAmount256, + manaCostPerFrame256, + radiusSubtiles, + radiusYards, + } +} + +/** + * 2. Resist Fire (Skill 100) + * - Active party fire res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35 + * (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20) + * - Passive max fire res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2) + * (Active permanently even when aura is off, capped at 95%) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateResistFireStats( + slvl: number, + blvl?: number, +): { + fireResistPct: number + maxFireResistBonusPct: number + passiveMaxFireResistPct: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const fireResistPct = computeDiminishingReturns(35, 150, lvl) + const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl + const passiveMaxFireResistPct = Math.floor(basePoints / 2) + const maxFireResistBonusPct = passiveMaxFireResistPct + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + fireResistPct, + maxFireResistBonusPct, + passiveMaxFireResistPct, + radiusSubtiles, + radiusYards, + } +} + +/** + * 3. Defiance (Skill 104) + * - Active party %Defense: ln34 = 70 + (slvl - 1) * 10 (+70% at slvl 1, +160% at slvl 10, +260% at slvl 20) + * - Synergizes Holy Shield (+15% defense per hard point) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateDefianceStats(slvl: number): { + defensePercent: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const defensePercent = 70 + (lvl - 1) * 10 + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + defensePercent, + radiusSubtiles, + radiusYards, + } +} + +/** + * 4. Resist Cold (Skill 105) + * - Active party cold res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35 + * (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20) + * - Passive max cold res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2) + * (Active permanently even when aura is off, capped at 95%) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateResistColdStats( + slvl: number, + blvl?: number, +): { + coldResistPct: number + maxColdResistBonusPct: number + passiveMaxColdResistPct: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const coldResistPct = computeDiminishingReturns(35, 150, lvl) + const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl + const passiveMaxColdResistPct = Math.floor(basePoints / 2) + const maxColdResistBonusPct = passiveMaxColdResistPct + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + coldResistPct, + maxColdResistBonusPct, + passiveMaxColdResistPct, + radiusSubtiles, + radiusYards, + } +} + +/** + * 5. Cleansing (Skill 109) + * - Curse and poison duration reduction: dm34 (from Skills.txt item_poisonlengthresist = 100 - dm34, dm34 is 30..90) + * dm(30, 90, slvl): 39% at slvl 1, 71% at slvl 10, 80% at slvl 20 + * - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateCleansingStats( + slvl: number, + prayerBlvl?: number, +): { + curseReductionPct: number + poisonLengthReductionPct: number + prayerHealAmount: number + prayerHealAmount256: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const curseReductionPct = computeDiminishingReturns(30, 90, lvl) + const poisonLengthReductionPct = curseReductionPct + const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0 + const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0 + const prayerHealAmount256 = prayerHealAmount << 8 + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + curseReductionPct, + poisonLengthReductionPct, + prayerHealAmount, + prayerHealAmount256, + radiusSubtiles, + radiusYards, + } +} + +/** + * 6. Resist Lightning (Skill 110) + * - Active party lightning res: dm34 = floor((110 * slvl * (150 - 35)) / (100 * (slvl + 6))) + 35 + * (+53% at slvl 1, +114% at slvl 10, +132% at slvl 20) + * - Passive max lightning res: +1% per 2 base skill points (hard points) = Math.floor((blvl ?? slvl) / 2) + * (Active permanently even when aura is off, capped at 95%) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateResistLightningStats( + slvl: number, + blvl?: number, +): { + lightningResistPct: number + maxLightningResistBonusPct: number + passiveMaxLightningResistPct: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const lightningResistPct = computeDiminishingReturns(35, 150, lvl) + const basePoints = blvl !== undefined ? Math.max(0, blvl) : lvl + const passiveMaxLightningResistPct = Math.floor(basePoints / 2) + const maxLightningResistBonusPct = passiveMaxLightningResistPct + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + lightningResistPct, + maxLightningResistBonusPct, + passiveMaxLightningResistPct, + radiusSubtiles, + radiusYards, + } +} + +/** + * 7. Vigor (Skill 115) + * - Faster Run/Walk (velocitypercent): dm56 = floor((110 * slvl * (50 - 7)) / (100 * (slvl + 6))) + 7 + * (+13% at slvl 1, +36% at slvl 10, +43% at slvl 20) + * - Stamina recovery bonus (staminarecoverybonus): ln34 = 50 + (slvl - 1) * 25 + * (+50% at slvl 1, +275% at slvl 10, +525% at slvl 20) + * - Max stamina % (skill_staminapercent): ln34 = 50 + (slvl - 1) * 25 + * - Radius: ln12 subtiles (16 + (slvl - 1) * 3) + */ +export function calculateVigorStats(slvl: number): { + velocityPercent: number + staminaRecoveryBonusPct: number + maxStaminaPercent: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const velocityPercent = computeDiminishingReturns(7, 50, lvl) + const staminaRecoveryBonusPct = 50 + (lvl - 1) * 25 + const maxStaminaPercent = 50 + (lvl - 1) * 25 + const radiusSubtiles = 16 + (lvl - 1) * 3 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + velocityPercent, + staminaRecoveryBonusPct, + maxStaminaPercent, + radiusSubtiles, + radiusYards, + } +} + +/** + * 8. Meditation (Skill 120) + * - Active party mana recovery rate: ln34 = 300 + (slvl - 1) * 25 + * (+300% at slvl 1, +525% at slvl 10, +775% at slvl 20) + * - Synergy: Free Prayer heal pulse (at Prayer slvl efficiency, 0 mana cost) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateMeditationStats( + slvl: number, + prayerBlvl?: number, +): { + manaRecoveryBonusPct: number + prayerHealAmount: number + prayerHealAmount256: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const manaRecoveryBonusPct = 300 + (lvl - 1) * 25 + const pBlvl = prayerBlvl !== undefined ? Math.max(0, prayerBlvl) : 0 + const prayerHealAmount = pBlvl > 0 ? computeBand5(pBlvl, 2, 1, 1, 2, 2, 3) : 0 + const prayerHealAmount256 = prayerHealAmount << 8 + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + manaRecoveryBonusPct, + prayerHealAmount, + prayerHealAmount256, + radiusSubtiles, + radiusYards, + } +} + +/** + * 9. Redemption (Skill 124) + * - 50-tick cadence, fixed 16 subtiles radius in 2:1 isometric ground space + * - Redemption chance: dm34 = floor((110 * slvl * (100 - 10)) / (100 * (slvl + 6))) + 10 + * (24% at slvl 1, 71% at slvl 10, 86% at slvl 20) + * - Life and Mana replenishment: ln56 = 25 + (slvl - 1) * 5 (25 at slvl 1, 70 at slvl 10, 120 at slvl 20) + * - Consumes valid monster corpses, marks corpse.consumed = true immediately, caster-only restore + */ +export function calculateRedemptionStats(slvl: number): { + redemptionChancePct: number + lifeRecoverPts: number + manaRecoverPts: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const redemptionChancePct = computeDiminishingReturns(10, 100, lvl) + const lifeRecoverPts = 25 + (lvl - 1) * 5 + const manaRecoverPts = lifeRecoverPts + const radiusSubtiles = 16 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + redemptionChancePct, + lifeRecoverPts, + manaRecoverPts, + radiusSubtiles, + radiusYards, + } +} + +/** + * 10. Salvation (Skill 125) + * - Active party All Resistances (Fire, Cold, Lightning): dm34 = floor((110 * slvl * (120 - 50)) / (100 * (slvl + 6))) + 50 + * (61% at slvl 1, 98% at slvl 10, 109% at slvl 20) + * - Strictly zero effect on Poison or Magic resistance! + * - Synergizes Holy Fire (+6%), Holy Freeze (+7%), Holy Shock (+4%) + * - Radius: ln12 subtiles (16 + (slvl - 1) * 2) + */ +export function calculateSalvationStats(slvl: number): { + fireResistPct: number + coldResistPct: number + lightningResistPct: number + radiusSubtiles: number + radiusYards: number +} { + const lvl = Math.max(1, slvl) + const resPct = computeDiminishingReturns(50, 120, lvl) + const radiusSubtiles = 16 + (lvl - 1) * 2 + const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2)) + return { + fireResistPct: resPct, + coldResistPct: resPct, + lightningResistPct: resPct, + radiusSubtiles, + radiusYards, + } +} + + diff --git a/src/game/skills/impl/pal/skill-099-prayer.ts b/src/game/skills/impl/pal/skill-099-prayer.ts index 13c23e1..e77dc89 100644 --- a/src/game/skills/impl/pal/skill-099-prayer.ts +++ b/src/game/skills/impl/pal/skill-099-prayer.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculatePrayerStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-100-resist-fire.ts b/src/game/skills/impl/pal/skill-100-resist-fire.ts index 1f124ff..d01be32 100644 --- a/src/game/skills/impl/pal/skill-100-resist-fire.ts +++ b/src/game/skills/impl/pal/skill-100-resist-fire.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateResistFireStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-104-defiance.ts b/src/game/skills/impl/pal/skill-104-defiance.ts index 30e3196..7a9a596 100644 --- a/src/game/skills/impl/pal/skill-104-defiance.ts +++ b/src/game/skills/impl/pal/skill-104-defiance.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateDefianceStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-105-resist-cold.ts b/src/game/skills/impl/pal/skill-105-resist-cold.ts index b934ffe..5c93989 100644 --- a/src/game/skills/impl/pal/skill-105-resist-cold.ts +++ b/src/game/skills/impl/pal/skill-105-resist-cold.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateResistColdStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-109-cleansing.ts b/src/game/skills/impl/pal/skill-109-cleansing.ts index 738b9c6..740f98e 100644 --- a/src/game/skills/impl/pal/skill-109-cleansing.ts +++ b/src/game/skills/impl/pal/skill-109-cleansing.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateCleansingStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-110-resist-lightning.ts b/src/game/skills/impl/pal/skill-110-resist-lightning.ts index 94112eb..78b20d6 100644 --- a/src/game/skills/impl/pal/skill-110-resist-lightning.ts +++ b/src/game/skills/impl/pal/skill-110-resist-lightning.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateResistLightningStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-115-vigor.ts b/src/game/skills/impl/pal/skill-115-vigor.ts index 9863f29..9fa77c2 100644 --- a/src/game/skills/impl/pal/skill-115-vigor.ts +++ b/src/game/skills/impl/pal/skill-115-vigor.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateVigorStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-120-meditation.ts b/src/game/skills/impl/pal/skill-120-meditation.ts index 3f6488b..851e673 100644 --- a/src/game/skills/impl/pal/skill-120-meditation.ts +++ b/src/game/skills/impl/pal/skill-120-meditation.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateMeditationStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-124-redemption.ts b/src/game/skills/impl/pal/skill-124-redemption.ts index 36c94d0..6bb90a8 100644 --- a/src/game/skills/impl/pal/skill-124-redemption.ts +++ b/src/game/skills/impl/pal/skill-124-redemption.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateRedemptionStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-125-salvation.ts b/src/game/skills/impl/pal/skill-125-salvation.ts index 88d1e0a..346f51e 100644 --- a/src/game/skills/impl/pal/skill-125-salvation.ts +++ b/src/game/skills/impl/pal/skill-125-salvation.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateSalvationStats } from '../../../skills.ts' export default skillModule diff --git a/tests/skills/pal/adv-pal-defensive-auras-stress.test.ts b/tests/skills/pal/adv-pal-defensive-auras-stress.test.ts new file mode 100644 index 0000000..5ea7657 --- /dev/null +++ b/tests/skills/pal/adv-pal-defensive-auras-stress.test.ts @@ -0,0 +1,345 @@ +/** + * 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) + }) + }) +}) diff --git a/tests/skills/pal/defensive-auras.test.ts b/tests/skills/pal/defensive-auras.test.ts new file mode 100644 index 0000000..da68549 --- /dev/null +++ b/tests/skills/pal/defensive-auras.test.ts @@ -0,0 +1,523 @@ +/** + * 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/game/skills.ts' +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' + +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) + }) + }) +})