feat(pal): implement Milestone M9 Paladin Defensive Auras tree (10 skills)
This commit is contained in:
parent
f2b78c80de
commit
1a5f897cd6
|
|
@ -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) {
|
||||
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
|
||||
sourceUnit.statList.setHp256(sourceUnit.statList.getHp256() + recoverPts * FIXED_ONE)
|
||||
sourceUnit.statList.setMana256(sourceUnit.statList.getMana256() + recoverPts * FIXED_ONE)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculatePrayerStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateResistFireStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateDefianceStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateResistColdStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateCleansingStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateResistLightningStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateVigorStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateMeditationStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateRedemptionStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateSalvationStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue