feat(pal): implement Milestone M9 Paladin Defensive Auras tree (10 skills)

This commit is contained in:
troytt 2026-09-24 15:54:52 +00:00
parent f2b78c80de
commit 1a5f897cd6
17 changed files with 1426 additions and 11 deletions

View File

@ -12,9 +12,10 @@
* - `Redemption` (`124`) corpse consumption pulse restoring player HP & Mana. * - `Redemption` (`124`) corpse consumption pulse restoring player HP & Mana.
*/ */
import type { D2DataRegistry, SkillRecord } from './data-registry.ts' 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 { FIXED_ONE } from './stat-list.ts'
import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts' import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts'
import { calculatePrayerStats, calculateRedemptionStats } from '../skills.ts'
export interface ActiveAuraSource { export interface ActiveAuraSource {
readonly sourceUnit: CombatUnitContext readonly sourceUnit: CombatUnitContext
@ -31,11 +32,21 @@ export interface AuraPulseOutcome {
readonly enemiesAffected: number readonly enemiesAffected: number
readonly pulseDamageDealt: number readonly pulseDamageDealt: number
readonly corpsesRedeemed: 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 { export interface AuraPulseOptions {
readonly isTown?: boolean | undefined readonly isTown?: boolean | undefined
readonly radiusPx?: number | undefined readonly radiusPx?: number | undefined
readonly rollChanceFn?: (chancePct: number) => boolean
} }
export class AuraScanner { export class AuraScanner {
@ -88,7 +99,7 @@ export class AuraScanner {
currentTick: number, currentTick: number,
allies: readonly CombatUnitContext[], allies: readonly CombatUnitContext[],
enemies: readonly CombatUnitContext[], enemies: readonly CombatUnitContext[],
corpses?: { id: string; consumed: boolean }[], corpses?: CorpseContext[],
options?: AuraPulseOptions, options?: AuraPulseOptions,
): AuraPulseOutcome { ): AuraPulseOutcome {
const { sourceUnit, skill, slvl } = aura const { sourceUnit, skill, slvl } = aura
@ -149,6 +160,8 @@ export class AuraScanner {
let enemiesAffected = 0 let enemiesAffected = 0
let pulseDamageDealt = 0 let pulseDamageDealt = 0
let corpsesRedeemed = 0 let corpsesRedeemed = 0
let healthRestored = 0
let manaRestored = 0
const radiusSubtiles = skill.auraRangeCalc ? evaluateCalc(skill.auraRangeCalc, calcCtx) : 0 const radiusSubtiles = skill.auraRangeCalc ? evaluateCalc(skill.auraRangeCalc, calcCtx) : 0
const computedRadiusPx = options?.radiusPx ?? (radiusSubtiles > 0 ? Math.round(radiusSubtiles * (40 / 3)) : 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! // In town, offensive / debuff auras against enemies and corpse redemption are suspended!
if (!isTown) { if (!isTown) {
// Conviction (`123`): applies `conviction` (`conviction_pierce` + `-defense`) to wilderness enemies // 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 // 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) { 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) { for (const c of corpses) {
if (!c.consumed) { 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 c.consumed = true
corpsesRedeemed += 1 corpsesRedeemed += 1
sourceUnit.statList.setHp256(sourceUnit.statList.getHp256() + recoverPts * FIXED_ONE) const curHp = sourceUnit.statList.getHp256()
sourceUnit.statList.setMana256(sourceUnit.statList.getMana256() + recoverPts * FIXED_ONE) 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, enemiesAffected,
pulseDamageDealt, pulseDamageDealt,
corpsesRedeemed, corpsesRedeemed,
healthRestored,
manaRestored,
} }
} }
/** /**
* Periodic tick step across all active auras. * Periodic tick step across all active auras.
* Handles continuous per-frame mana drain for Prayer (99) and auto-deactivates at 0 mana.
*/ */
tick( tick(
currentTick: number, currentTick: number,
allies: readonly CombatUnitContext[], allies: readonly CombatUnitContext[],
enemies: readonly CombatUnitContext[], enemies: readonly CombatUnitContext[],
corpses?: { id: string; consumed: boolean }[], corpses?: CorpseContext[],
options?: AuraPulseOptions, options?: AuraPulseOptions,
): AuraPulseOutcome[] { ): AuraPulseOutcome[] {
const outcomes: 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) { if ((currentTick - aura.startTick) % aura.pulseIntervalTicks === 0) {
outcomes.push(this.pulseAura(aura, currentTick, allies, enemies, corpses, options)) outcomes.push(this.pulseAura(aura, currentTick, allies, enemies, corpses, options))
} }
} }
for (const key of aurasToRemove) {
this.activeAuras.delete(key)
}
return outcomes return outcomes
} }
} }

View File

@ -67,12 +67,30 @@ export function computeEffectiveResistance(params: {
lowerResistPierce?: number lowerResistPierce?: number
passiveEnemyPierce?: number passiveEnemyPierce?: number
canBreakImmunity?: boolean canBreakImmunity?: boolean
maxResistance?: number | undefined
isDefenderPlayer?: boolean | undefined
}): ResistanceResult { }): ResistanceResult {
const baseRes = Math.trunc(params.baseRes) const baseRes = Math.trunc(params.baseRes)
const rawConvictionAndLR = Math.max(0, Math.trunc((params.convictionPierce ?? 0) + (params.lowerResistPierce ?? 0))) const rawConvictionAndLR = Math.max(0, Math.trunc((params.convictionPierce ?? 0) + (params.lowerResistPierce ?? 0)))
const canBreak = params.canBreakImmunity ?? true const canBreak = params.canBreakImmunity ?? true
// 1.13c Rule: When base resistance >= 100%, Conviction and Lower Resist apply at 1/5 effectiveness! // 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 = const convictionAndLRApplied =
baseRes >= 100 baseRes >= 100
? canBreak ? canBreak
@ -91,8 +109,10 @@ export function computeEffectiveResistance(params: {
} }
} }
const passivePierce = Math.max(0, Math.trunc(params.passiveEnemyPierce ?? 0)) 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 { return {
baseRes, baseRes,
convictionAndLRApplied, 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). * 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') ? attacker.statList.getAccruedStat('passive_pois_pierce')
: 0) : 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({ const elemResRec = computeEffectiveResistance({
baseRes: defender.statList.getAccruedStat(resStatKey), baseRes: defender.statList.getAccruedStat(resStatKey),
convictionPierce, convictionPierce,
@ -674,8 +706,12 @@ export function executeSUnitDmg(
passiveEnemyPierce: passivePierce, passiveEnemyPierce: passivePierce,
// Magic resistance cannot be broken by Conviction/Lower Resist // Magic resistance cannot be broken by Conviction/Lower Resist
canBreakImmunity: isElemPiercableByLR, canBreakImmunity: isElemPiercableByLR,
maxResistance,
isDefenderPlayer,
}) })
// 6. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`) absorb BEFORE resistances in 1.13c // 6. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`) absorb BEFORE resistances in 1.13c
const reactive = defender.stateBus.triggerReactiveEvents({ const reactive = defender.stateBus.triggerReactiveEvents({
event: 'absorbdamage', event: 'absorbdamage',
@ -748,6 +784,7 @@ export function executeSUnitDmg(
convictionPierce, convictionPierce,
lowerResistPierce, lowerResistPierce,
passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_fire_pierce'), passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_fire_pierce'),
maxResistance: defender.statList.getEffectiveMaxResistance('fire'),
}) })
if (!fireRes.isImmune) { if (!fireRes.isImmune) {
finalElem256 += Math.max(0, Math.trunc((rawFire256 * (100 - fireRes.effectiveRes)) / 100)) finalElem256 += Math.max(0, Math.trunc((rawFire256 * (100 - fireRes.effectiveRes)) / 100))
@ -761,6 +798,7 @@ export function executeSUnitDmg(
convictionPierce, convictionPierce,
lowerResistPierce, lowerResistPierce,
passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_cold_pierce'), passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_cold_pierce'),
maxResistance: defender.statList.getEffectiveMaxResistance('cold'),
}) })
if (!coldRes.isImmune) { if (!coldRes.isImmune) {
finalElem256 += Math.max(0, Math.trunc((rawCold256 * (100 - coldRes.effectiveRes)) / 100)) finalElem256 += Math.max(0, Math.trunc((rawCold256 * (100 - coldRes.effectiveRes)) / 100))
@ -783,11 +821,13 @@ export function executeSUnitDmg(
convictionPierce, convictionPierce,
lowerResistPierce, lowerResistPierce,
passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_ltng_pierce'), passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_ltng_pierce'),
maxResistance: defender.statList.getEffectiveMaxResistance('ltng'),
}) })
if (!ltngRes.isImmune) { if (!ltngRes.isImmune) {
finalElem256 += Math.max(0, Math.trunc((rawLtng256 * (100 - ltngRes.effectiveRes)) / 100)) finalElem256 += Math.max(0, Math.trunc((rawLtng256 * (100 - ltngRes.effectiveRes)) / 100))
} }
} }
} }
// Smite (97) / generic stun application // Smite (97) / generic stun application

View File

@ -229,11 +229,38 @@ export class UnitStatList {
if (pct !== 0) { if (pct !== 0) {
total = Math.trunc((total * (100 + pct)) / 100) 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) 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`. * Attach or update a `SkillStatModifier`.
*/ */

View File

@ -355,6 +355,31 @@ export class StateBus {
return best 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`, * Trigger reactive `auraeventfunc` handlers (`Chilling/Frozen/Shiver Armor`,
* `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`, * `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`,

View File

@ -7007,6 +7007,18 @@ function computeBand5(
return total 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 // 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,
}
}

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculatePrayerStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateResistFireStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateDefianceStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateResistColdStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateCleansingStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateResistLightningStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateVigorStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateMeditationStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateRedemptionStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
}, },
} }
export { calculateSalvationStats } from '../../../skills.ts'
export default skillModule export default skillModule

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})