feat(paladin): implement Milestone M8 Paladin Offensive Auras tree

- Implement 1.13c exact calculators for all 10 Paladin Offensive Auras in skills.ts:
  - Might (98): Party physical %ED (40% + 10%/lvl)
  - Holy Fire (102): Radial fire pulse + weapon added fire damage, Resist Fire (+18%) and Salvation (+6%) synergies
  - Thorns (103): Melee physical reflection (250% + 40%/lvl), physical resistance mitigation, PvP 1/4th penalty
  - Blessed Aim (108): Active party %AR, +5% passive AR per hard point even when inactive
  - Concentration (113): Party %ED, 20% uninterrupted attack chance, +50% Blessed Hammer synergy
  - Holy Freeze (114): Continuous enemy slow (25%..54%), CBF bypass, cold pulse + weapon cold damage, synergies
  - Holy Shock (118): Radial lightning pulse (1..max), weapon lightning damage, Resist Lightning (+12%) and Salvation (+4%) synergies
  - Sanctuary (119): Radial magic pulse + knockback vs Undead, sets Undead physical resistance to 0%, %ED vs Undead
  - Fanaticism (122): Caster IAS, %AR, asymmetric caster %ED vs ally %ED (strictly half)
  - Conviction (123): Enemy defense % and elemental resistance reduction (-30%..-150%), 1/5th immunity breaking (-6%..-30%), zero impact on Poison/Magic, caster immunity
- Engine integrations:
  - stat-list.ts: Blessed Aim passive AR (blvl * 5%)
  - combat-pipeline.ts: Sanctuary Undead phys res zeroing, Thorns PvP reflection, Fanaticism caster/ally split, Concentration uninterrupted chance, added weapon elemental damage
  - state-bus.ts: Thorns melee reflection trigger
  - aura-scanner.ts: 50-tick cadence in 2:1 isometric ground space
  - per-skill modules: re-export calculators for skills 98-123
- Add comprehensive test suites:
  - tests/skills/pal/offensive-auras.test.ts (21 tests)
  - tests/skills/pal/adv-pal-auras-stress.test.ts (5 adversarial scenarios)
- Preserve BATCH1_SKILLS length invariant strictly at 38 skills
This commit is contained in:
troytt 2026-09-24 14:22:29 +00:00
parent 967a545116
commit ff2d091254
18 changed files with 1663 additions and 15 deletions

View File

@ -35,6 +35,7 @@ export interface AuraPulseOutcome {
export interface AuraPulseOptions {
readonly isTown?: boolean | undefined
readonly radiusPx?: number | undefined
}
export class AuraScanner {
@ -122,6 +123,11 @@ export class AuraScanner {
stats['concentration_damage_pct'] = evaluateCalc('ln12', calcCtx)
}
// Fanaticism (`122`): Caster receives full caster %ED (50 + (slvl - 1) * 17)
if (skill.id === 122) {
stats['damagepercent'] = 50 + (slvl - 1) * 17
}
const isTown = options?.isTown ?? false
const stateDuration = aura.pulseIntervalTicks + 5
const selfState = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
@ -144,6 +150,26 @@ export class AuraScanner {
let pulseDamageDealt = 0
let corpsesRedeemed = 0
const radiusSubtiles = skill.auraRangeCalc ? evaluateCalc(skill.auraRangeCalc, calcCtx) : 0
const computedRadiusPx = options?.radiusPx ?? (radiusSubtiles > 0 ? Math.round(radiusSubtiles * (40 / 3)) : 0)
const isTargetInRange = (target: CombatUnitContext): boolean => {
if (computedRadiusPx <= 0) return true
if (
sourceUnit.x === undefined ||
sourceUnit.y === undefined ||
target.x === undefined ||
target.y === undefined
) {
return true
}
const dx = target.x - sourceUnit.x
const dy = target.y - sourceUnit.y
// 2:1 isometric ground space
const dist = Math.hypot(dx, dy * 2)
return dist <= computedRadiusPx
}
// Friendly stat aura (`srvdofunc = 65` or Oak Sage / Wolverine / Barbs):
// Town vs Wilderness Filtering:
// In town: only self receives the aura buff; allies, mercenaries, and summons are skipped.
@ -152,6 +178,16 @@ export class AuraScanner {
const tgtState = skill.auraTgtState || selfState
for (const ally of allies) {
if (ally.id === sourceUnit.id) continue
if (!isTargetInRange(ally)) continue
const allyStats = { ...stats }
// Fanaticism (122): allies receive strictly HALF of caster %ED and is_ally flag
if (skill.id === 122) {
const casterEd = stats['damagepercent'] ?? (50 + (slvl - 1) * 17)
allyStats['damagepercent'] = Math.trunc(casterEd / 2)
allyStats['is_ally'] = 1
}
ally.stateBus.applyState({
stateNameOrId: tgtState,
sourceUnitId: sourceUnit.id,
@ -159,7 +195,7 @@ export class AuraScanner {
slvl,
durationFrames: stateDuration,
currentFrame: currentTick,
stats,
stats: allyStats,
isAuraOverride: true,
})
alliesBuffed += 1
@ -170,10 +206,12 @@ export class AuraScanner {
if (!isTown) {
// Conviction (`123`): applies `conviction` (`conviction_pierce` + `-defense`) to wilderness enemies
if (skill.id === 123) {
const pierce = Math.min(150, evaluateCalc('ln34', calcCtx))
const defPenalty = -evaluateCalc('ln12', calcCtx)
const pierce = Math.min(150, 30 + (slvl - 1) * 5)
const defPenalty = -Math.min(90, 30 + (slvl - 1) * 5)
for (const enemy of enemies) {
if (enemy.statList.getHp256() <= 0) continue
if (!isTargetInRange(enemy)) continue
enemy.stateBus.applyState({
stateNameOrId: skill.auraTgtState || 'conviction',
sourceUnitId: sourceUnit.id,
@ -218,21 +256,35 @@ export class AuraScanner {
for (const enemy of enemies) {
if (enemy.statList.getHp256() <= 0) continue
if (skill.id === 119 && !enemy.isUndead) continue // Sanctuary only pulses vs Undead
if (!isTargetInRange(enemy)) continue
// Holy Freeze (114): enemies receive item_slow stat, but NO ground overlay (holywindcold overlay is null)
if (skill.auraTgtState) {
const enemyTgtState = skill.auraTgtState || (skill.id === 114 ? 'holywindcold' : '')
if (enemyTgtState) {
const slowPct = skill.id === 114
? Math.min(54, 25 + Math.floor((slvl * 35) / (slvl + 6)))
: (evaluateCalc('dm56', calcCtx) || 0)
enemy.stateBus.applyState({
stateNameOrId: skill.auraTgtState,
stateNameOrId: enemyTgtState,
sourceUnitId: sourceUnit.id,
sourceSkillId: skill.id,
slvl,
durationFrames: stateDuration,
currentFrame: currentTick,
stats: skill.id === 114 ? { item_slow: evaluateCalc('dm56', calcCtx) } : {},
stats: skill.id === 114 ? { item_slow: slowPct, velocitypercent: -slowPct } : {},
isAuraOverride: true,
})
}
if (skill.id === 119) {
enemy.stateBus.applyState({
stateNameOrId: 'knockback',
sourceSkillId: 119,
slvl,
durationFrames: 10,
})
}
const out = executeSUnitDmg(sourceUnit, enemy, {
skillId: skill.id,
attackKind: 'spell',
@ -241,6 +293,7 @@ export class AuraScanner {
elemMax256: max256,
autoHit: true,
unblockable: true,
...(skill.id === 119 ? { knockback: true } : {}),
})
pulseDamageDealt += out.totalDamage
enemiesAffected += 1

View File

@ -20,12 +20,20 @@
import { FIXED_ONE, UnitStatList } from './stat-list.ts'
import { StateBus } from './state-bus.ts'
import { calculateLowerResistReduction, calculateIronMaidenReturnPct } from './missile-engine.ts'
import {
calculateHolyFireStats,
calculateHolyFreezeStats,
calculateHolyShockStats,
} from '../skills.ts'
export interface CombatUnitContext {
readonly id: string
readonly name: string
readonly statList: UnitStatList
readonly stateBus: StateBus
readonly x?: number
readonly y?: number
readonly ownerUnitId?: string
readonly isUndead?: boolean
readonly isDemon?: boolean
readonly isMoving?: boolean
@ -315,6 +323,8 @@ export interface SUnitDmgPacket {
} | undefined
readonly stunDurationFrames?: number | undefined
readonly knockback?: boolean | undefined
readonly isAlly?: boolean | undefined
readonly rollUninterrupted?: number | undefined
}
export interface SUnitDmgOutcome {
@ -329,6 +339,7 @@ export interface SUnitDmgOutcome {
readonly targetHpBefore256: number
readonly targetHpAfter256: number
readonly targetKilled: boolean
readonly uninterrupted?: boolean
readonly attackerStatesApplied?: readonly string[]
readonly attackerColdDamage256?: number
readonly reflectedPhys256?: number
@ -440,11 +451,46 @@ export function executeSUnitDmg(
const curseDmgMod =
(dmgPctBonus === 0 && attacker.stateBus.hasState('weaken') ? -33 : 0) +
(dmgPctBonus === 0 && attacker.stateBus.hasState('decrepify') ? -50 : 0)
const edPct =
let edPct =
(packet.physDamagePct ?? 0) +
dmgPctBonus +
curseDmgMod +
attacker.statList.getModifierBonus('skill_damage_percent')
// Fanaticism: caster vs ally %ED distinction if not already accrued in statList
if (attacker.stateBus.hasState('fanaticism') && dmgPctBonus === 0) {
const fanatSlvl = attacker.stateBus.getState('fanaticism')?.slvl ?? 1
const casterEd = 50 + (fanatSlvl - 1) * 17
const isAlly = Boolean(
packet.isAlly ||
attacker.unitType === 'party' ||
attacker.unitType === 'mercenary' ||
attacker.unitType === 'party_mercenary' ||
attacker.stateBus.getState('fanaticism')?.stats?.['is_ally'] === 1
)
const fanatEd = isAlly ? Math.trunc(casterEd / 2) : casterEd
edPct += fanatEd
}
// Might: if not already accrued in statList
if (attacker.stateBus.hasState('might') && dmgPctBonus === 0) {
const mightSlvl = attacker.stateBus.getState('might')?.slvl ?? 1
edPct += 40 + (mightSlvl - 1) * 10
}
// Concentration: if not already accrued in statList
if (attacker.stateBus.hasState('concentration') && dmgPctBonus === 0) {
const concSlvl = attacker.stateBus.getState('concentration')?.slvl ?? 1
edPct += 60 + (concSlvl - 1) * 15
}
// Sanctuary: +150% + 30%/lvl %ED vs Undead
if (attacker.stateBus.hasState('sanctuary') && defender.isUndead) {
const sanctSlvl = attacker.stateBus.getState('sanctuary')?.slvl ?? 1
const sanctEd = 150 + (sanctSlvl - 1) * 30
edPct += sanctEd
}
rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100))
}
@ -458,7 +504,45 @@ export function executeSUnitDmg(
}
// 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules
const elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
let elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
// Added elemental damage from Paladin offensive auras (Holy Fire, Holy Freeze, Holy Shock) on physical attacks
if (attackKind !== 'spell' && packet.skillId !== 97) {
if (attacker.stateBus.hasState('holyfire')) {
const hfSlvl = attacker.stateBus.getState('holyfire')?.slvl ?? 1
const rfBlvl = attacker.statList.getBaseSkillLevel(100)
const salvBlvl = attacker.statList.getBaseSkillLevel(125)
const hfStats = calculateHolyFireStats(hfSlvl, { resistFire: rfBlvl, salvation: salvBlvl })
const avgHf256 = Math.trunc(((hfStats.weaponFireMin + hfStats.weaponFireMax) * FIXED_ONE) / 2)
rawElem256 += avgHf256
if (!packet.elemType) {
elemType = 'fire'
}
}
if (attacker.stateBus.hasState('holyfreeze')) {
const freezeSlvl = attacker.stateBus.getState('holyfreeze')?.slvl ?? 1
const rcBlvl = attacker.statList.getBaseSkillLevel(105)
const salvBlvl = attacker.statList.getBaseSkillLevel(125)
const freezeStats = calculateHolyFreezeStats(freezeSlvl, { resistCold: rcBlvl, salvation: salvBlvl })
const avgFreeze256 = Math.trunc(((freezeStats.weaponColdMin + freezeStats.weaponColdMax) * FIXED_ONE) / 2)
rawElem256 += avgFreeze256
if (!packet.elemType) {
elemType = 'cold'
}
}
if (attacker.stateBus.hasState('holyshock')) {
const shockSlvl = attacker.stateBus.getState('holyshock')?.slvl ?? 1
const rlBlvl = attacker.statList.getBaseSkillLevel(110)
const salvBlvl = attacker.statList.getBaseSkillLevel(125)
const shockStats = calculateHolyShockStats(shockSlvl, { resistLightning: rlBlvl, salvation: salvBlvl })
const avgShock256 = Math.trunc(((shockStats.weaponLightningMin + shockStats.weaponLightningMax) * FIXED_ONE) / 2)
rawElem256 += avgShock256
if (!packet.elemType) {
elemType = 'ltng'
}
}
}
const isEnchantFire = Boolean(packet.isEnchant || packet.skillId === 52 || attacker.stateBus.hasState('enchant'))
if (isEnchantFire && attackKind === 'missile' && elemType === 'fire') {
// 1.13c Param3 = 33: Ranged attacks (bow/crossbow/throwing) receive only 33% of Enchant fire damage bonus
@ -527,11 +611,22 @@ export function executeSUnitDmg(
? 50
: 0
const physResRec = computeEffectiveResistance({
let physResRec = computeEffectiveResistance({
baseRes: defender.statList.getAccruedStat('damageresist'),
convictionPierce: curseAmpPierce,
})
// Sanctuary: Paladin physical attacks ignore Undead physical resistance (damageresist = 0%, piercing immunity)
if (attacker.stateBus.hasState('sanctuary') && defender.isUndead && attackKind !== 'spell') {
physResRec = {
baseRes: physResRec.baseRes,
convictionAndLRApplied: physResRec.convictionAndLRApplied,
resAfterConvictionLR: 0,
isImmune: false,
effectiveRes: 0,
}
}
const resStatKey =
elemType === 'fire'
? 'fireresist'
@ -743,10 +838,18 @@ export function executeSUnitDmg(
attackerStatesApplied.push('slowed')
}
// Iron Golem Thorns reflection
const isPvP = Boolean(
(defender.unitType === 'player' || defender.ownerUnitId !== undefined) &&
(attacker.unitType === 'player' || attacker.ownerUnitId !== undefined)
)
// Iron Golem / stat-based Thorns reflection (if not already handled by stateBus thorns)
const thornsReturnPct = defender.statList.getAccruedStat('thorns_return_pct')
if (thornsReturnPct > 0 && finalPhys256 > 0) {
const rawThorns256 = Math.trunc((finalPhys256 * thornsReturnPct) / 100)
if (thornsReturnPct > 0 && finalPhys256 > 0 && !defender.stateBus.hasState('thorns')) {
let rawThorns256 = Math.trunc((finalPhys256 * thornsReturnPct) / 100)
if (isPvP) {
rawThorns256 = Math.trunc(rawThorns256 / 4)
}
const attackerPhysRes = computeEffectiveResistance({
baseRes: attacker.statList.getAccruedStat('damageresist'),
})
@ -766,6 +869,8 @@ export function executeSUnitDmg(
incomingElem256: finalElem256,
elemType,
attackerStateBus: attacker.stateBus,
attackerStatList: attacker.statList,
isPvP,
})
if (reactiveDamage.attackerStatesApplied.length > 0) {
attackerStatesApplied.push(...reactiveDamage.attackerStatesApplied)
@ -817,6 +922,16 @@ export function executeSUnitDmg(
}
}
// Concentration: 20% chance that attack will not be interrupted
const hasConcentration =
attacker.stateBus.hasState('concentration') ||
attacker.statList.getModifierBonus('skill_concentration') > 0
const isUninterrupted = hasConcentration
? (packet.rollUninterrupted !== undefined
? packet.rollUninterrupted < 20
: (packet.roll100 !== undefined ? packet.roll100 < 20 : true))
: false
return {
hit: true,
avoidedReason: 'none',
@ -829,6 +944,7 @@ export function executeSUnitDmg(
targetHpBefore256: hpBefore256,
targetHpAfter256: hpAfter256,
targetKilled: hpAfter256 === 0 && hpBefore256 > 0,
...(isUninterrupted ? { uninterrupted: true } : {}),
...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}),
...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}),
...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}),

View File

@ -754,7 +754,7 @@ export class D2DataRegistry {
aura: numberCell(row, 'aura', 0) !== 0,
auraFilter: numberCell(row, 'aurafilter', 0),
auraState: textCell(row, 'aurastate'),
auraTgtState: textCell(row, 'auratgtstate'),
auraTgtState: textCell(row, 'auratargetstate') || textCell(row, 'auratgtstate'),
auraLenCalc: textCell(row, 'auralencalc'),
auraRangeCalc: textCell(row, 'aurarangecalc'),
auraStat1: textCell(row, 'aurastat1'),

View File

@ -216,8 +216,16 @@ export class UnitStatList {
total += calculateFireMasteryBonus(effLvl)
}
}
} else if (key === 'passive_ar_bonus_pct') {
const blvlBlessedAim = this.getBaseSkillLevel(108)
total += blvlBlessedAim * 5
} else if (key === 'tohit') {
const pct = this.getModifierBonus('tohit_percent') + this.getModifierBonus('item_tohit_percent')
const blvlBlessedAim = this.getBaseSkillLevel(108)
const passiveArBonus = blvlBlessedAim * 5 + this.getModifierBonus('passive_ar_bonus_pct')
const pct =
this.getModifierBonus('tohit_percent') +
this.getModifierBonus('item_tohit_percent') +
passiveArBonus
if (pct !== 0) {
total = Math.trunc((total * (100 + pct)) / 100)
}

View File

@ -365,7 +365,9 @@ export class StateBus {
incomingPhys256?: number
incomingElem256?: number
attackerStateBus?: StateBus
attackerStatList?: UnitStatList
ownerUnitStatList?: UnitStatList
isPvP?: boolean
elemType?: string
isPoison?: boolean
isOpenWounds?: boolean
@ -525,7 +527,25 @@ export class StateBus {
})
}
// 7. Iron Maiden (`state = ironmaiden`, `auraeventfunc = 4`) on `damagedinmelee`
// 7. Thorns (`state = thorns`, `auraeventfunc = 4`) on `damagedinmelee`
if (params.event === 'damagedinmelee' && stateName === 'thorns' && remPhys256 > 0) {
const reflectPct = entry.stats['thorns_percent'] ?? (250 + (entry.slvl - 1) * 40)
let rawReflect = Math.trunc((remPhys256 * reflectPct) / 100)
if (params.isPvP) {
rawReflect = Math.trunc(rawReflect / 4)
}
if (params.attackerStatList) {
const physRes = params.attackerStatList.getAccruedStat('damageresist')
if (physRes >= 100) {
rawReflect = 0
} else {
rawReflect = Math.max(0, Math.trunc((rawReflect * (100 - physRes)) / 100))
}
}
reflectedPhys256 += rawReflect
}
// 8. Iron Maiden (`state = ironmaiden`, `auraeventfunc = 4`) on `damagedinmelee`
if (params.event === 'damagedinmelee' && stateName === 'ironmaiden' && remPhys256 > 0) {
const reflectPct = entry.stats['thorns_percent'] ?? (175 + entry.slvl * 25)
reflectedPhys256 += Math.trunc((remPhys256 * reflectPct) / 100)

View File

@ -6926,4 +6926,430 @@ export function calculateFistOfTheHeavensHolyBoltDamage(
return { holyBoltMin, holyBoltMax }
}
/** Helper 5-band scaling matching D2 1.13c Skills.txt (levels 1, 2..8, 9..16, 17..22, 23..28, 29+) */
function computeBand5(
lvl: number,
base: number,
lev1: number,
lev2: number,
lev3: number,
lev4: number,
lev5: number,
): number {
if (lvl <= 0) return 0
let total = base
if (lvl > 1) {
const b1 = Math.min(lvl, 8) - 1
total += b1 * lev1
}
if (lvl > 8) {
const b2 = Math.min(lvl, 16) - 8
total += b2 * lev2
}
if (lvl > 16) {
const b3 = Math.min(lvl, 22) - 16
total += b3 * lev3
}
if (lvl > 22) {
const b4 = Math.min(lvl, 28) - 22
total += b4 * lev4
}
if (lvl > 28) {
const b5 = lvl - 28
total += b5 * lev5
}
return total
}
// ============================================================================
// PALADIN OFFENSIVE AURAS (10 SKILLS) — 1.13c Ground Truth Calculators
// ============================================================================
/**
* 1. Might (Skill 98)
* - Party physical %ED: 40% + (slvl - 1) * 10%
* - Radius: ln12 subtiles (16 + (slvl - 1) * 2) -> (slvl * 2 + 8) * 2 / 3 yards
*/
export function calculateMightStats(slvl: number): {
damagePercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const damagePercent = 40 + (lvl - 1) * 10
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { damagePercent, radiusSubtiles, radiusYards }
}
/**
* 2. Holy Fire (Skill 102)
* - Radial fire pulse (every 50 ticks)
* - Weapon added fire damage: 6 * pulseMin .. 6 * pulseMax
* - Synergies: Resist Fire (+18%/blvl), Salvation (+6%/blvl)
* - 5-band scaling with hitShift = 7: EMin=2, EMax=6, Lev1..5=[1, 2, 3, 5, 7]
*/
export function calculateHolyFireStats(
slvl: number,
synergies?: { resistFire?: number; salvation?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponFireMin: number
weaponFireMax: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 5, 7)
const baseMax = computeBand5(lvl, 6, 1, 2, 3, 5, 7)
const rfBlvl = synergies?.resistFire ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rfBlvl * 18 + salvBlvl * 6
// hitShift = 7 -> 256 fixed point is (base << 7)
const rawMin256 = baseMin << 7
const rawMax256 = baseMax << 7
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 6 (weapon damage multiplier): enms * 6 / 256
const weaponFireMin = Math.trunc((pulseMin256 * 6) / 256)
const weaponFireMax = Math.trunc((pulseMax256 * 6) / 256)
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponFireMin,
weaponFireMax,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 3. Thorns (Skill 103)
* - Physical melee reflection: 250% + (slvl - 1) * 40%
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateThornsStats(slvl: number): {
reflectPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const reflectPct = 250 + (lvl - 1) * 40
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { reflectPct, radiusSubtiles, radiusYards }
}
/**
* 4. Blessed Aim (Skill 108)
* - Active party %AR: 75% + (slvl - 1) * 15%
* - Passive %AR: +5% per hard point (blvl * 5%) active even when inactive
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateBlessedAimStats(
slvl: number,
blvl?: number,
): {
attackRatingPct: number
passiveArBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const attackRatingPct = 75 + (lvl - 1) * 15
const hardPoints = blvl ?? lvl
const passiveArBonusPct = hardPoints * 5
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return { attackRatingPct, passiveArBonusPct, radiusSubtiles, radiusYards }
}
/**
* 5. Concentration (Skill 113)
* - Party physical %ED: 60% + (slvl - 1) * 15%
* - Uninterrupted attack chance: 20% (Param1 = 20)
* - Blessed Hammer synergy: +50% of active Concentration %ED (Math.trunc(damagePercent / 2))
* - Radius: 16 + (slvl - 1) * 2 subtiles
*/
export function calculateConcentrationStats(slvl: number): {
damagePercent: number
uninterruptedChancePct: number
blessedHammerBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const damagePercent = 60 + (lvl - 1) * 15
const uninterruptedChancePct = 20
const blessedHammerBonusPct = Math.trunc(damagePercent / 2)
const radiusSubtiles = 16 + (lvl - 1) * 2
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
damagePercent,
uninterruptedChancePct,
blessedHammerBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 6. Holy Freeze (Skill 114)
* - Continuous enemy slow: 25%..54% (dm34 / dm56), bypasses Cannot Be Frozen (CBF)
* - Radial cold pulse (every 50 ticks)
* - Weapon added cold damage: 5 * pulseMin .. 5 * pulseMax
* - Added chill length: 30 + (slvl - 1) * 10 frames
* - Synergies: Resist Cold (+15%/blvl), Salvation (+7%/blvl)
* - 5-band scaling with hitShift = 8: EMin=2, EMax=3, Lev1..5=[1, 2, 3, 4, 5]
*/
export function calculateHolyFreezeStats(
slvl: number,
synergies?: { resistCold?: number; salvation?: number },
): {
slowPct: number
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponColdMin: number
weaponColdMax: number
chillDurationFrames: number
chillDurationSeconds: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const slowPct = Math.min(54, 25 + Math.floor((lvl * 35) / (lvl + 6)))
const baseMin = computeBand5(lvl, 2, 1, 2, 3, 4, 5)
const baseMax = computeBand5(lvl, 3, 1, 2, 3, 4, 5)
const rcBlvl = synergies?.resistCold ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rcBlvl * 15 + salvBlvl * 7
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 5 (weapon damage multiplier)
const weaponColdMin = Math.trunc((pulseMin256 * 5) / 256)
const weaponColdMax = Math.trunc((pulseMax256 * 5) / 256)
const chillDurationFrames = 30 + (lvl - 1) * 10
const chillDurationSeconds = Number((chillDurationFrames / 25).toFixed(2))
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
slowPct,
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponColdMin,
weaponColdMax,
chillDurationFrames,
chillDurationSeconds,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 7. Holy Shock (Skill 118)
* - Radial lightning pulse (every 50 ticks): min strictly 1, max scales Lev1..5=[6, 8, 10, 12, 15]
* - Weapon added lightning damage: min 1, max 6 * pulseMax
* - Synergies: Resist Lightning (+12%/blvl), Salvation (+4%/blvl)
* - hitShift = 8
*/
export function calculateHolyShockStats(
slvl: number,
synergies?: { resistLightning?: number; salvation?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
weaponLightningMin: number
weaponLightningMax: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = 1
const baseMax = computeBand5(lvl, 10, 6, 8, 10, 12, 15)
const rlBlvl = synergies?.resistLightning ?? 0
const salvBlvl = synergies?.salvation ?? 0
const synergyBonusPct = rlBlvl * 12 + salvBlvl * 4
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
// Param5 = 6 (weapon damage multiplier)
const weaponLightningMin = 1
const weaponLightningMax = Math.trunc((pulseMax256 * 6) / 256)
const radiusSubtiles = 6 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
weaponLightningMin,
weaponLightningMax,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 8. Sanctuary (Skill 119)
* - Radial magic pulse + knockback every 50 ticks strictly vs Undead
* - Direct weapon attacks ignore Undead physical resistance (damageresist = 0%, piercing immunity)
* - Direct weapon %ED vs Undead: 150% + (slvl - 1) * 30%
* - Direct weapon AR vs Undead: 100 + (slvl - 1) * 50
* - Synergy: Cleansing (+7%/blvl magic damage)
* - hitShift = 8: EMin=8 Lev1..5=[4, 4, 5, 5, 6], EMax=16 Lev1..5=[4, 5, 6, 6, 7]
*/
export function calculateSanctuaryStats(
slvl: number,
synergies?: { cleansing?: number },
): {
pulseMin: number
pulseMax: number
pulseMin256: number
pulseMax256: number
undeadDamagePercent: number
undeadAttackRating: number
synergyBonusPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const baseMin = computeBand5(lvl, 8, 4, 4, 5, 5, 6)
const baseMax = computeBand5(lvl, 16, 4, 5, 6, 6, 7)
const cleansingBlvl = synergies?.cleansing ?? 0
const synergyBonusPct = cleansingBlvl * 7
// hitShift = 8
const rawMin256 = baseMin << 8
const rawMax256 = baseMax << 8
const pulseMin256 = Math.trunc((rawMin256 * (100 + synergyBonusPct)) / 100)
const pulseMax256 = Math.trunc((rawMax256 * (100 + synergyBonusPct)) / 100)
const pulseMin = pulseMin256 >> 8
const pulseMax = pulseMax256 >> 8
const undeadDamagePercent = 150 + (lvl - 1) * 30
const undeadAttackRating = 100 + (lvl - 1) * 50
const radiusSubtiles = 5 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
pulseMin,
pulseMax,
pulseMin256,
pulseMax256,
undeadDamagePercent,
undeadAttackRating,
synergyBonusPct,
radiusSubtiles,
radiusYards,
}
}
/**
* 9. Fanaticism (Skill 122)
* - Caster IAS: 14 + Math.floor((slvl * 25) / (slvl + 7)) % (17%..32%)
* - Attack Rating: 40% + (slvl - 1) * 5%
* - Caster physical %ED: 50% + (slvl - 1) * 17%
* - Party physical %ED: strictly HALF of caster %ED (Math.trunc(casterDamagePercent / 2))
* - Radius: 11 + (slvl - 1) * 1 subtiles
*/
export function calculateFanaticismStats(slvl: number): {
casterIas: number
attackRatingPct: number
casterDamagePercent: number
partyDamagePercent: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const casterIas = 14 + Math.floor((lvl * 25) / (lvl + 7))
const attackRatingPct = 40 + (lvl - 1) * 5
const casterDamagePercent = 50 + (lvl - 1) * 17
const partyDamagePercent = Math.trunc(casterDamagePercent / 2)
const radiusSubtiles = 11 + (lvl - 1) * 1
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
casterIas,
attackRatingPct,
casterDamagePercent,
partyDamagePercent,
radiusSubtiles,
radiusYards,
}
}
/**
* 10. Conviction (Skill 123)
* - Defense reduction %: -(30 + (slvl - 1) * 5)%, capped at -90%
* - Elemental resistance reduction (Fire, Cold, Lightning): -(30 + (slvl - 1) * 5)%, capped at -150%
* - 1.13c Immunity breaking: applies at 1/5th effectiveness (-6%..-30%) against elemental immunities
* - Does NOT affect Poison or Magic resistance!
* - Radius: fixed 20 subtiles
*/
export function calculateConvictionStats(slvl: number): {
defenseReductionPct: number
resistanceReductionPct: number
immunityBreakingReductionPct: number
radiusSubtiles: number
radiusYards: number
} {
const lvl = Math.max(1, slvl)
const defenseReductionPct = Math.min(90, 30 + (lvl - 1) * 5)
const resistanceReductionPct = Math.min(150, 30 + (lvl - 1) * 5)
const immunityBreakingReductionPct = Math.trunc(resistanceReductionPct / 5)
const radiusSubtiles = 20
const radiusYards = Number(((radiusSubtiles * 2) / 3).toFixed(2))
return {
defenseReductionPct,
resistanceReductionPct,
immunityBreakingReductionPct,
radiusSubtiles,
radiusYards,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,380 @@
/**
* Adversarial Stress & Edge Case Verification Suite — Paladin Offensive Auras (10 Skills).
*
* 5 Critical Edge Scenarios:
* 1. Conviction Immunity-Breaking Thresholds (100%, 110%, 140%, 160% resistance; zero poison/magic pierce)
* 2. Sanctuary vs Physical Immune Undead (0% effective phys res on Undead; living/demons untouched)
* 3. Thorns Overkill & PvP 1/4th Penalty (10,000 dmg, monster mitigation, PvP 75% cut, 0 on immune)
* 4. Holy Freeze CBF Bypass & 2:1 Isometric Geometry (dx=100, dy=50 in range vs dx=100, dy=100 out)
* 5. Fanaticism Asymmetric Party Scaling & Rapid Aura Switching (1 Pal + 3 allies, 100 frame swap loop)
*/
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'
describe('Paladin Offensive Auras — Adversarial Stress & Edge Cases', () => {
// --------------------------------------------------------------------------
// Scenario 1: Conviction Immunity-Breaking Thresholds
// --------------------------------------------------------------------------
it('Scenario 1: Conviction breaking thresholds (100%, 110%, 140%, 160%) and Poison/Magic exclusion', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
}
// Slvl 25 Conviction has -150% uncapped elemental resistance reduction
// Against immunities (baseRes >= 100%), applies at 1/5th effectiveness = -30%
const pierce25 = 150
// 1. Monster with 100% Fire Resistance
const res100 = computeEffectiveResistance({ baseRes: 100, convictionPierce: pierce25 })
expect(res100.isImmune).toBe(false)
expect(res100.effectiveRes).toBe(70) // 100 - 30 = 70%
// 2. Monster with 110% Lightning Resistance
const res110 = computeEffectiveResistance({ baseRes: 110, convictionPierce: pierce25 })
expect(res110.isImmune).toBe(false)
expect(res110.effectiveRes).toBe(80) // 110 - 30 = 80%
// 3. Monster with 140% Cold Resistance (Unbreakable!)
const res140 = computeEffectiveResistance({ baseRes: 140, convictionPierce: pierce25 })
expect(res140.isImmune).toBe(true)
expect(res140.effectiveRes).toBe(100) // 140 - 30 = 110% >= 100 -> Remains Immune!
// 4. Monster with 160% Fire Resistance (Unbreakable!)
const res160 = computeEffectiveResistance({ baseRes: 160, convictionPierce: pierce25 })
expect(res160.isImmune).toBe(true)
expect(res160.effectiveRes).toBe(100)
// 5. Conviction NEVER impacts Poison or Magic Resistance
const monsterStats = new UnitStatList(registry, {
maxhp: 1000 * FIXED_ONE,
hitpoints: 1000 * FIXED_ONE,
poisonresist: 50,
magicresist: 50,
})
const monsterBus = new StateBus(monsterStats, registry)
monsterBus.applyState({
stateNameOrId: 'conviction',
slvl: 25,
stats: { conviction_pierce: 150, item_armor_percent: -90 },
})
const monster: CombatUnitContext = {
id: 'monster_pois_mag',
name: 'Target',
statList: monsterStats,
stateBus: monsterBus,
unitType: 'monster',
}
// Poison attack with Conviction on defender
const poisOut = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
elemType: 'pois',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
// 50% poison resistance applies in full -> 50 damage taken (NOT pierced by Conviction!)
expect(poisOut.elemDamage256).toBe(50 * FIXED_ONE)
// Magic attack with Conviction on defender
const magOut = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'spell',
elemType: 'mag',
elemMin256: 100 * FIXED_ONE,
elemMax256: 100 * FIXED_ONE,
autoHit: true,
})
// 50% magic resistance applies in full -> 50 damage taken (NOT pierced by Conviction!)
expect(magOut.elemDamage256).toBe(50 * FIXED_ONE)
})
// --------------------------------------------------------------------------
// Scenario 2: Sanctuary vs Physical Immune Undead
// --------------------------------------------------------------------------
it('Scenario 2: Sanctuary pierces 100% and 150% Physical Immune Undead while leaving living/demons immune', async () => {
const registry = await getSharedDataRegistry()
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const palBus = new StateBus(palStats, registry)
palBus.applyState({ stateNameOrId: 'sanctuary', slvl: 10 }) // +420% ED vs Undead
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: palBus,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// 1. Ghost (100% Physical Immune Undead)
const ghostStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 100 })
const ghost: CombatUnitContext = {
id: 'ghost_100',
name: 'Ghost (100% Phys Res)',
statList: ghostStats,
stateBus: new StateBus(ghostStats, registry),
unitType: 'monster',
isUndead: true,
}
const outGhost100 = executeSUnitDmg(paladin, ghost, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outGhost100.immuneToPhys).toBe(false)
// 100 base * (100 + 420)% = 520 physical damage dealt with 0% resistance!
expect(outGhost100.physDamage256).toBe(520 * FIXED_ONE)
// 2. Stone Skin Ghost (150% Physical Immune Undead)
const stoneSkinGhostStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 150 })
const stoneSkinGhost: CombatUnitContext = {
id: 'ghost_150',
name: 'Stone Skin Ghost (150% Phys Res)',
statList: stoneSkinGhostStats,
stateBus: new StateBus(stoneSkinGhostStats, registry),
unitType: 'monster',
isUndead: true,
}
const outGhost150 = executeSUnitDmg(paladin, stoneSkinGhost, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outGhost150.immuneToPhys).toBe(false)
expect(outGhost150.physDamage256).toBe(520 * FIXED_ONE)
// 3. Stone Skin Demon (100% Phys Res, isDemon: true, isUndead: false) -> Sanctuary does NOT pierce!
const demonStats = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 100 })
const demon: CombatUnitContext = {
id: 'demon_100',
name: 'Demon (100% Phys Res)',
statList: demonStats,
stateBus: new StateBus(demonStats, registry),
unitType: 'monster',
isDemon: true,
isUndead: false,
}
const outDemon = executeSUnitDmg(paladin, demon, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outDemon.immuneToPhys).toBe(true)
expect(outDemon.physDamage256).toBe(0)
})
// --------------------------------------------------------------------------
// Scenario 3: Thorns Overkill & PvP 1/4th Penalty
// --------------------------------------------------------------------------
it('Scenario 3: Thorns massive overkill reflection, attacker physical resistance, and PvP 1/4th penalty', async () => {
const registry = await getSharedDataRegistry()
// Defender with Slvl 20 Thorns (1010% reflection)
const defStats = new UnitStatList(registry, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE, damageresist: 0 })
const defBus = new StateBus(defStats, registry)
defBus.applyState({ stateNameOrId: 'thorns', slvl: 20 })
const paladinDef: CombatUnitContext = {
id: 'paladin_thorns',
name: 'Thorns Paladin',
statList: defStats,
stateBus: defBus,
unitType: 'player',
}
// 1. Monster attacks for 10,000 melee physical damage
const atkStats = new UnitStatList(registry, { maxhp: 200000 * FIXED_ONE, hitpoints: 200000 * FIXED_ONE, damageresist: 0 })
const monsterAtk: CombatUnitContext = {
id: 'massive_monster',
name: 'Massive Monster',
statList: atkStats,
stateBus: new StateBus(atkStats, registry),
unitType: 'monster',
weaponMinPhys: 10000,
weaponMaxPhys: 10000,
}
const outMassive = executeSUnitDmg(monsterAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
// 10,000 * 1010% = 101,000 physical damage reflected!
expect(outMassive.reflectedPhys256).toBe(101000 * FIXED_ONE)
expect(monsterAtk.statList.getHp256()).toBe((200000 - 101000) * FIXED_ONE)
// 2. PvP Penalty: Player attacker attacks for 10,000 damage
const playerAtkStats = new UnitStatList(registry, { maxhp: 200000 * FIXED_ONE, hitpoints: 200000 * FIXED_ONE, damageresist: 0 })
const playerAtk: CombatUnitContext = {
id: 'player_atk',
name: 'Player Attacker',
statList: playerAtkStats,
stateBus: new StateBus(playerAtkStats, registry),
unitType: 'player',
weaponMinPhys: 10000,
weaponMaxPhys: 10000,
}
const outPvP = executeSUnitDmg(playerAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
// In PvP, 1/4th penalty applies: Math.trunc(101,000 / 4) = 25,250 reflected!
expect(outPvP.reflectedPhys256).toBe(25250 * FIXED_ONE)
// 3. Physical Immune Attacker: Takes strictly 0 reflected damage!
const immuneAtkStats = new UnitStatList(registry, { maxhp: 50000 * FIXED_ONE, hitpoints: 50000 * FIXED_ONE, damageresist: 100 })
const immuneAtk: CombatUnitContext = {
id: 'immune_atk',
name: 'Physical Immune Attacker',
statList: immuneAtkStats,
stateBus: new StateBus(immuneAtkStats, registry),
unitType: 'monster',
weaponMinPhys: 1000,
weaponMaxPhys: 1000,
}
const outImmune = executeSUnitDmg(immuneAtk, paladinDef, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outImmune.reflectedPhys256).toBeUndefined()
expect(immuneAtk.statList.getHp256()).toBe(50000 * FIXED_ONE)
})
// --------------------------------------------------------------------------
// Scenario 4: Holy Freeze CBF Bypass & 2:1 Isometric Geometry
// --------------------------------------------------------------------------
it('Scenario 4: Holy Freeze CBF bypass and 2:1 isometric ground space range check', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Paladin at origin (0, 0)
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
x: 0,
y: 0,
}
// Enemy 1: In range in 2:1 iso ground space: (dx=100, dy=50)
// Math.hypot(100, 50 * 2) = Math.hypot(100, 100) = 141.42 <= 200
const e1Stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, cannot_be_frozen: 1 })
const enemy1: CombatUnitContext = {
id: 'enemy_in_range',
name: 'In Range CBF Enemy',
statList: e1Stats,
stateBus: new StateBus(e1Stats, registry),
unitType: 'monster',
x: 100,
y: 50,
}
// Enemy 2: Out of range in 2:1 iso ground space: (dx=100, dy=100)
// Math.hypot(100, 100 * 2) = Math.hypot(100, 200) = 223.6 > 200
const e2Stats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, cannot_be_frozen: 1 })
const enemy2: CombatUnitContext = {
id: 'enemy_out_of_range',
name: 'Out of Range CBF Enemy',
statList: e2Stats,
stateBus: new StateBus(e2Stats, registry),
unitType: 'monster',
x: 100,
y: 100,
}
// Slvl 10 Holy Freeze: radiusSubtiles = 15 -> radiusPx = Math.round(15 * 40/3) = 200 px
const auraSource = scanner.setActiveAura(paladin, 114, 10)!
expect(auraSource).toBeDefined()
scanner.pulseAura(auraSource, 0, [paladin], [enemy1, enemy2])
// Enemy 1 is in range and slowed (bypassing CBF!)
expect(enemy1.stateBus.hasState('holywindcold')).toBe(true)
expect(enemy1.statList.getModifierBonus('item_slow')).toBe(46)
// Enemy 2 is out of range and NOT affected
expect(enemy2.stateBus.hasState('holywindcold')).toBe(false)
expect(enemy2.statList.getModifierBonus('item_slow')).toBe(0)
})
// --------------------------------------------------------------------------
// Scenario 5: Fanaticism Asymmetric Party Scaling & Rapid Aura Switching
// --------------------------------------------------------------------------
it('Scenario 5: Fanaticism asymmetric party scaling and rapid aura switching stability', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
// Paladin Caster
const palStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: palStats,
stateBus: new StateBus(palStats, registry),
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// 3 Allies
const allies: CombatUnitContext[] = [1, 2, 3].map(i => {
const s = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE })
return {
id: `ally_${i}`,
name: `Ally ${i}`,
statList: s,
stateBus: new StateBus(s, registry),
unitType: 'party',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
})
// Activate Slvl 20 Fanaticism (Caster: 373% ED, Allies: 186% ED)
const fanatAura = scanner.setActiveAura(paladin, 122, 20)!
scanner.pulseAura(fanatAura, 0, [paladin, ...allies], [])
// Asserts Paladin receives full %ED (373%) and allies receive exactly half %ED (186%)
expect(paladin.stateBus.hasState('fanaticism')).toBe(true)
for (const ally of allies) {
expect(ally.stateBus.hasState('fanaticism')).toBe(true)
expect(ally.statList.getModifierBonus('damagepercent')).toBe(186)
}
// Simulate switching between Fanaticism (122), Conviction (123), and Might (98) over 100 consecutive frames
const auraCycle = [122, 123, 98]
for (let frame = 1; frame <= 100; frame++) {
const nextAuraId = auraCycle[frame % 3]!
scanner.clearUnitAuras(paladin.id)
paladin.stateBus.removeState('fanaticism')
paladin.stateBus.removeState('conviction')
paladin.stateBus.removeState('might')
for (const ally of allies) {
ally.stateBus.removeState('fanaticism')
ally.stateBus.removeState('conviction')
ally.stateBus.removeState('might')
}
const active = scanner.setActiveAura(paladin, nextAuraId, 10, frame)!
scanner.pulseAura(active, frame, [paladin, ...allies], [])
}
// Final state check: last frame (100 % 3 = 1 -> Conviction 123)
// Paladin must have active Conviction visual without debuff
expect(paladin.stateBus.hasState('conviction')).toBe(true)
expect(paladin.statList.getModifierBonus('item_armor_percent')).toBe(0)
expect(paladin.statList.getModifierBonus('conviction_pierce')).toBe(0)
// No lingering Fanaticism or Might modifiers remain
expect(paladin.stateBus.hasState('fanaticism')).toBe(false)
expect(paladin.stateBus.hasState('might')).toBe(false)
for (const ally of allies) {
expect(ally.stateBus.hasState('fanaticism')).toBe(false)
expect(ally.stateBus.hasState('might')).toBe(false)
expect(ally.statList.getModifierBonus('damagepercent')).toBe(0)
}
})
})

View File

@ -0,0 +1,635 @@
/**
* Comprehensive 1.13c Unit Verification Suite — Paladin Offensive Auras Tree (10 Skills).
*
* Covers:
* - Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5
* - Might (98): Party physical %ED, radius scaling, combat integration
* - Holy Fire (102): Radial pulse, weapon fire damage, Resist Fire (+18%) & Salvation (+6%) synergies
* - Thorns (103): Melee physical reflection, physical resistance mitigation, PvP 1/4th penalty
* - Blessed Aim (108): Active party AR, +5% passive AR per hard point even when inactive
* - Concentration (113): Party %ED, 20% uninterrupted attack chance, +50% Blessed Hammer synergy
* - Holy Freeze (114): Continuous slow, CBF bypass, cold pulse, weapon cold damage & chill length, synergies
* - Holy Shock (118): Lightning pulse (min=1), weapon lightning damage, Resist Lightning (+12%) & Salvation (+4%) synergies
* - Sanctuary (119): Magic pulse & knockback vs Undead, sets Undead physical resistance to 0%, %ED vs Undead, Cleansing synergy
* - Fanaticism (122): IAS, %AR, caster physical %ED vs ally physical %ED (strictly half)
* - Conviction (123): Defense % reduction, elemental resistance reduction, 1/5th immunity breaking, zero impact on Poison/Magic, caster immunity
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
ISO_GROUND_ASPECT_RATIO,
calculateMightStats,
calculateHolyFireStats,
calculateThornsStats,
calculateBlessedAimStats,
calculateConcentrationStats,
calculateHolyFreezeStats,
calculateHolyShockStats,
calculateSanctuaryStats,
calculateFanaticismStats,
calculateConvictionStats,
calculateBlessedHammerDamage,
} 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 Offensive Auras — Milestone M8 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. Might (98)
// --------------------------------------------------------------------------
describe('Skill #098: Might', () => {
it('scales party physical %ED and radius at slvl 1, 10, and 20', () => {
const s1 = calculateMightStats(1)
expect(s1.damagePercent).toBe(40)
expect(s1.radiusSubtiles).toBe(16)
expect(s1.radiusYards).toBe(10.67)
const s10 = calculateMightStats(10)
expect(s10.damagePercent).toBe(130)
expect(s10.radiusSubtiles).toBe(34)
expect(s10.radiusYards).toBe(22.67)
const s20 = calculateMightStats(20)
expect(s20.damagePercent).toBe(230)
expect(s20.radiusSubtiles).toBe(54)
expect(s20.radiusYards).toBe(36.0)
})
it('boosts weapon physical damage in executeSUnitDmg when active', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'might', slvl: 10 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const statsM = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, damageresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 100 base damage with slvl 10 Might (+130% ED) = 230 damage
expect(out.physDamage256).toBe(230 * FIXED_ONE)
})
})
// --------------------------------------------------------------------------
// 2. Holy Fire (102)
// --------------------------------------------------------------------------
describe('Skill #102: Holy Fire', () => {
it('calculates pulse damage, weapon fire damage, and synergies at slvl 1, 10, and 20', () => {
const s1 = calculateHolyFireStats(1)
expect(s1.pulseMin).toBe(1)
expect(s1.pulseMax).toBe(3)
expect(s1.pulseMin256).toBe(256)
expect(s1.pulseMax256).toBe(768)
expect(s1.weaponFireMin).toBe(6) // 6 * pulseMin
expect(s1.weaponFireMax).toBe(18) // 6 * pulseMax
expect(s1.radiusSubtiles).toBe(6)
const s10 = calculateHolyFireStats(10)
expect(s10.pulseMin256).toBe(1664)
expect(s10.pulseMax256).toBe(2176)
expect(s10.weaponFireMin).toBe(39) // Math.trunc(1664 * 6 / 256)
expect(s10.weaponFireMax).toBe(51) // Math.trunc(2176 * 6 / 256)
const s20 = calculateHolyFireStats(20)
expect(s20.pulseMin256).toBe(4736)
expect(s20.pulseMax256).toBe(5248)
expect(s20.weaponFireMin).toBe(111)
expect(s20.weaponFireMax).toBe(123)
// Synergies: Resist Fire (+18%/blvl), Salvation (+6%/blvl)
const s20Syn = calculateHolyFireStats(20, { resistFire: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 18 + 20 * 6) // +480%
expect(s20Syn.pulseMin256).toBe(Math.trunc((4736 * (100 + 480)) / 100))
expect(s20Syn.pulseMax256).toBe(Math.trunc((5248 * (100 + 480)) / 100))
})
it('adds weapon fire damage to physical attacks in executeSUnitDmg', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
statsP.setBaseSkillLevel(100, 20) // Resist Fire 20 (+360%)
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'holyfire', slvl: 20 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 50,
weaponMaxPhys: 50,
}
const statsM = new UnitStatList(registry, { maxhp: 5000 * FIXED_ONE, hitpoints: 5000 * FIXED_ONE, fireresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
expect(out.physDamage256).toBe(50 * FIXED_ONE)
expect(out.elemDamage256).toBeGreaterThan(0)
})
})
// --------------------------------------------------------------------------
// 3. Thorns (103)
// --------------------------------------------------------------------------
describe('Skill #103: Thorns', () => {
it('scales damage reflection percentage at slvl 1, 10, and 20', () => {
expect(calculateThornsStats(1).reflectPct).toBe(250)
expect(calculateThornsStats(10).reflectPct).toBe(610)
expect(calculateThornsStats(20).reflectPct).toBe(1010)
})
it('reflects melee physical damage, mitigated by attacker physical resistance, with PvP 1/4th penalty', async () => {
const registry = await getSharedDataRegistry()
// 1. PvM Reflection: Attacker has 0% physical resistance
const statsDef = new UnitStatList(registry, { maxhp: 2000 * FIXED_ONE, hitpoints: 2000 * FIXED_ONE, damageresist: 0 })
const busDef = new StateBus(statsDef, registry)
busDef.applyState({ stateNameOrId: 'thorns', slvl: 1 }) // 250% reflection
const paladinDef: CombatUnitContext = {
id: 'pal_def',
name: 'Paladin Defender',
statList: statsDef,
stateBus: busDef,
unitType: 'player',
}
const statsAtk = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const busAtk = new StateBus(statsAtk, registry)
const monsterAtk: CombatUnitContext = {
id: 'monster_atk',
name: 'Monster Attacker',
statList: statsAtk,
stateBus: busAtk,
unitType: 'monster',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const outPvM = executeSUnitDmg(monsterAtk, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 100 phys damage dealt -> reflects 250% = 250 damage back to monster
expect(outPvM.reflectedPhys256).toBe(250 * FIXED_ONE)
expect(monsterAtk.statList.getHp256()).toBe(750 * FIXED_ONE)
// 2. Physical resistance mitigation: Attacker has 50% physical resistance
const statsAtk50 = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 50 })
const monsterAtk50: CombatUnitContext = {
id: 'monster_50',
name: 'Monster 50% Res',
statList: statsAtk50,
stateBus: new StateBus(statsAtk50, registry),
unitType: 'monster',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const out50 = executeSUnitDmg(monsterAtk50, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// 250 reflected * (100 - 50)% = 125 reflected
expect(out50.reflectedPhys256).toBe(125 * FIXED_ONE)
// 3. PvP 1/4th penalty: Player attacker vs Player defender
const statsPlayerAtk = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const playerAtk: CombatUnitContext = {
id: 'player_atk',
name: 'Player Attacker',
statList: statsPlayerAtk,
stateBus: new StateBus(statsPlayerAtk, registry),
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const outPvP = executeSUnitDmg(playerAtk, paladinDef, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// In PvP, 1/4th penalty applies: 250 * 256 / 4 = 16000 (62.5 damage in 256-fixed-point)
expect(outPvP.reflectedPhys256).toBe(Math.trunc((250 * FIXED_ONE) / 4))
})
})
// --------------------------------------------------------------------------
// 4. Blessed Aim (108)
// --------------------------------------------------------------------------
describe('Skill #108: Blessed Aim', () => {
it('scales active party %AR at slvl 1, 10, and 20', () => {
expect(calculateBlessedAimStats(1).attackRatingPct).toBe(75)
expect(calculateBlessedAimStats(10).attackRatingPct).toBe(210)
expect(calculateBlessedAimStats(20).attackRatingPct).toBe(360)
})
it('grants +5% passive Attack Rating per base hard point even when inactive', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList(registry, { tohit: 1000 })
expect(stats.getAccruedStat('tohit')).toBe(1000)
// Invest 5 hard points in Blessed Aim
stats.setBaseSkillLevel(108, 5)
expect(stats.getAccruedStat('passive_ar_bonus_pct')).toBe(25)
// +25% on 1000 base AR = 1250 AR
expect(stats.getAccruedStat('tohit')).toBe(1250)
// Adding +skills items (+10) must NOT increase passive bonus (hard points only!)
stats.setBonusSkillLevel(108, 10)
stats.setAllSkillsBonus(5)
expect(stats.getAccruedStat('tohit')).toBe(1250)
})
})
// --------------------------------------------------------------------------
// 5. Concentration (113)
// --------------------------------------------------------------------------
describe('Skill #113: Concentration', () => {
it('scales party physical %ED, uninterrupted chance, and Blessed Hammer synergy', () => {
const s1 = calculateConcentrationStats(1)
expect(s1.damagePercent).toBe(60)
expect(s1.uninterruptedChancePct).toBe(20)
expect(s1.blessedHammerBonusPct).toBe(30)
const s10 = calculateConcentrationStats(10)
expect(s10.damagePercent).toBe(195)
expect(s10.blessedHammerBonusPct).toBe(97) // Math.trunc(195 / 2)
const s20 = calculateConcentrationStats(20)
expect(s20.damagePercent).toBe(345)
expect(s20.blessedHammerBonusPct).toBe(172) // Math.trunc(345 / 2)
})
it('provides +50% effective synergy bonus to Blessed Hammer damage', () => {
const hammerBase = calculateBlessedHammerDamage(20, { blessedAim: 0, vigor: 0 }, 0)
const hammerConc = calculateBlessedHammerDamage(20, { blessedAim: 0, vigor: 0 }, 345) // slvl 20 Concentration (345% ED)
expect(hammerConc.concentrationBonusPct).toBe(172)
expect(hammerConc.min).toBe(Math.trunc((hammerBase.min * (100 + 172)) / 100))
})
it('reports uninterrupted attack in executeSUnitDmg when Concentration is active', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'concentration', slvl: 1 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 50,
weaponMaxPhys: 50,
}
const statsM = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const out = executeSUnitDmg(paladin, monster, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
rollUninterrupted: 10, // < 20%
})
expect(out.uninterrupted).toBe(true)
})
})
// --------------------------------------------------------------------------
// 6. Holy Freeze (114)
// --------------------------------------------------------------------------
describe('Skill #114: Holy Freeze', () => {
it('scales enemy slow % and cold pulse damage across slvl 1, 10, and 20', () => {
const s1 = calculateHolyFreezeStats(1)
expect(s1.slowPct).toBe(30) // 25 + floor(35/7) = 30%
expect(s1.pulseMin256).toBe(512)
expect(s1.pulseMax256).toBe(768)
expect(s1.weaponColdMin).toBe(10)
expect(s1.weaponColdMax).toBe(15)
const s10 = calculateHolyFreezeStats(10)
expect(s10.slowPct).toBe(46)
expect(s10.pulseMin256).toBe(3328)
expect(s10.pulseMax256).toBe(3584)
const s20 = calculateHolyFreezeStats(20)
expect(s20.slowPct).toBe(51)
expect(s20.pulseMin256).toBe(9472)
expect(s20.pulseMax256).toBe(9728)
// Synergies: Resist Cold (+15%/lvl) and Salvation (+7%/lvl)
const s20Syn = calculateHolyFreezeStats(20, { resistCold: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 15 + 20 * 7) // +440%
})
it('slows enemies via AuraScanner bypassing Cannot Be Frozen (CBF)', async () => {
const registry = await getSharedDataRegistry()
const scanner = new AuraScanner(registry)
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: new StateBus(statsP, registry),
unitType: 'player',
}
// Enemy has cannot_be_frozen = 1
const statsE = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE, cannot_be_frozen: 1 })
const enemy: CombatUnitContext = {
id: 'enemy',
name: 'CBF Enemy',
statList: statsE,
stateBus: new StateBus(statsE, registry),
unitType: 'monster',
}
const auraSource = scanner.setActiveAura(paladin, 114, 10)!
expect(auraSource).toBeDefined()
scanner.pulseAura(auraSource, 0, [paladin], [enemy])
expect(enemy.stateBus.hasState('holywindcold')).toBe(true)
expect(enemy.statList.getModifierBonus('item_slow')).toBe(46) // Slow is active!
})
})
// --------------------------------------------------------------------------
// 7. Holy Shock (118)
// --------------------------------------------------------------------------
describe('Skill #118: Holy Shock', () => {
it('calculates lightning pulse (min=1), weapon added lightning damage, and synergies', () => {
const s1 = calculateHolyShockStats(1)
expect(s1.pulseMin).toBe(1)
expect(s1.pulseMax).toBe(10)
expect(s1.pulseMin256).toBe(256)
expect(s1.pulseMax256).toBe(2560)
expect(s1.weaponLightningMin).toBe(1)
expect(s1.weaponLightningMax).toBe(60) // 6 * pulseMax
const s10 = calculateHolyShockStats(10)
expect(s10.pulseMin).toBe(1)
expect(s10.pulseMax).toBe(68)
expect(s10.weaponLightningMax).toBe(408)
const s20 = calculateHolyShockStats(20)
expect(s20.pulseMin).toBe(1)
expect(s20.pulseMax).toBe(156)
expect(s20.weaponLightningMax).toBe(936)
// Synergies: Resist Lightning (+12%/lvl) and Salvation (+4%/lvl)
const s20Syn = calculateHolyShockStats(20, { resistLightning: 20, salvation: 20 })
expect(s20Syn.synergyBonusPct).toBe(20 * 12 + 20 * 4) // +320%
})
})
// --------------------------------------------------------------------------
// 8. Sanctuary (119)
// --------------------------------------------------------------------------
describe('Skill #119: Sanctuary', () => {
it('scales pulse damage, %ED vs Undead, and AR vs Undead', () => {
const s1 = calculateSanctuaryStats(1)
expect(s1.pulseMin256).toBe(2048)
expect(s1.pulseMax256).toBe(4096)
expect(s1.undeadDamagePercent).toBe(150)
expect(s1.undeadAttackRating).toBe(100)
const s10 = calculateSanctuaryStats(10)
expect(s10.undeadDamagePercent).toBe(420)
expect(s10.undeadAttackRating).toBe(550)
const s20 = calculateSanctuaryStats(20)
expect(s20.undeadDamagePercent).toBe(720)
expect(s20.undeadAttackRating).toBe(1050)
})
it('zeros Undead physical resistance (piercing immunity) and pulses only vs Undead', async () => {
const registry = await getSharedDataRegistry()
const statsP = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busP = new StateBus(statsP, registry)
busP.applyState({ stateNameOrId: 'sanctuary', slvl: 1 })
const paladin: CombatUnitContext = {
id: 'paladin',
name: 'Paladin',
statList: statsP,
stateBus: busP,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// Physical Immune Undead (damageresist = 100)
const statsUndead = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 100 })
const undead: CombatUnitContext = {
id: 'ghost',
name: 'Ghost',
statList: statsUndead,
stateBus: new StateBus(statsUndead, registry),
unitType: 'monster',
isUndead: true,
}
const outUndead = executeSUnitDmg(paladin, undead, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
// Physical immunity pierced and set to 0%, +150% ED vs Undead applied = 250 damage dealt!
expect(outUndead.immuneToPhys).toBe(false)
expect(outUndead.physDamage256).toBe(250 * FIXED_ONE)
// Physical Immune Non-Undead (Demon, damageresist = 100) -> Still immune!
const statsDemon = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 100 })
const demon: CombatUnitContext = {
id: 'demon',
name: 'Demon',
statList: statsDemon,
stateBus: new StateBus(statsDemon, registry),
unitType: 'monster',
isDemon: true,
isUndead: false,
}
const outDemon = executeSUnitDmg(paladin, demon, {
skillId: 0,
attackKind: 'melee',
autoHit: true,
})
expect(outDemon.immuneToPhys).toBe(true)
expect(outDemon.physDamage256).toBe(0)
})
})
// --------------------------------------------------------------------------
// 9. Fanaticism (122)
// --------------------------------------------------------------------------
describe('Skill #122: Fanaticism', () => {
it('calculates caster IAS, %AR, and asymmetric caster vs party %ED', () => {
const s1 = calculateFanaticismStats(1)
expect(s1.casterIas).toBe(17)
expect(s1.attackRatingPct).toBe(40)
expect(s1.casterDamagePercent).toBe(50)
expect(s1.partyDamagePercent).toBe(25) // Strictly half
const s10 = calculateFanaticismStats(10)
expect(s10.casterIas).toBe(28)
expect(s10.attackRatingPct).toBe(85)
expect(s10.casterDamagePercent).toBe(203)
expect(s10.partyDamagePercent).toBe(101) // Math.trunc(203 / 2)
const s20 = calculateFanaticismStats(20)
expect(s20.casterIas).toBe(32)
expect(s20.attackRatingPct).toBe(135)
expect(s20.casterDamagePercent).toBe(373)
expect(s20.partyDamagePercent).toBe(186) // Math.trunc(373 / 2)
})
it('applies caster %ED to Paladin and half %ED to party ally in executeSUnitDmg', async () => {
const registry = await getSharedDataRegistry()
// Paladin Caster (slvl 1 Fanaticism: 50% ED)
const statsCaster = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busCaster = new StateBus(statsCaster, registry)
busCaster.applyState({ stateNameOrId: 'fanaticism', slvl: 1 })
const caster: CombatUnitContext = {
id: 'paladin_caster',
name: 'Paladin Caster',
statList: statsCaster,
stateBus: busCaster,
unitType: 'player',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
// Party Ally (slvl 1 Fanaticism: 25% ED)
const statsAlly = new UnitStatList(registry, { maxhp: 500 * FIXED_ONE, hitpoints: 500 * FIXED_ONE })
const busAlly = new StateBus(statsAlly, registry)
busAlly.applyState({ stateNameOrId: 'fanaticism', slvl: 1, stats: { is_ally: 1 } })
const ally: CombatUnitContext = {
id: 'party_ally',
name: 'Party Ally',
statList: statsAlly,
stateBus: busAlly,
unitType: 'party',
weaponMinPhys: 100,
weaponMaxPhys: 100,
}
const statsM = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 1000 * FIXED_ONE, damageresist: 0 })
const monster: CombatUnitContext = {
id: 'monster',
name: 'Target Monster',
statList: statsM,
stateBus: new StateBus(statsM, registry),
unitType: 'monster',
}
const outCaster = executeSUnitDmg(caster, monster, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outCaster.physDamage256).toBe(150 * FIXED_ONE) // 100 + 50% = 150
const outAlly = executeSUnitDmg(ally, monster, { skillId: 0, attackKind: 'melee', autoHit: true })
expect(outAlly.physDamage256).toBe(125 * FIXED_ONE) // 100 + 25% = 125
})
})
// --------------------------------------------------------------------------
// 10. Conviction (123)
// --------------------------------------------------------------------------
describe('Skill #123: Conviction', () => {
it('calculates defense and elemental resistance reduction with immunity breaking scaling', () => {
const s1 = calculateConvictionStats(1)
expect(s1.defenseReductionPct).toBe(30)
expect(s1.resistanceReductionPct).toBe(30)
expect(s1.immunityBreakingReductionPct).toBe(6) // 30 / 5 = 6
const s10 = calculateConvictionStats(10)
expect(s10.defenseReductionPct).toBe(75)
expect(s10.resistanceReductionPct).toBe(75)
expect(s10.immunityBreakingReductionPct).toBe(15)
const s20 = calculateConvictionStats(20)
expect(s20.defenseReductionPct).toBe(90) // Capped at 90%
expect(s20.resistanceReductionPct).toBe(125)
expect(s20.immunityBreakingReductionPct).toBe(25)
const s25 = calculateConvictionStats(25)
expect(s25.resistanceReductionPct).toBe(150) // Capped at 150%
expect(s25.immunityBreakingReductionPct).toBe(30)
})
it('breaks elemental immunities at 1/5th effectiveness and never impacts Poison or Magic', () => {
// 105% base fire resistance (Fire Immune)
// Slvl 25 Conviction has 150% pierce -> 1/5th = 30% against immunity
// Effective res = 105 - 30 = 75% (Immunity Broken!)
const resFire = computeEffectiveResistance({
baseRes: 105,
convictionPierce: 150,
})
expect(resFire.isImmune).toBe(false)
expect(resFire.effectiveRes).toBe(75)
// 140% base fire resistance -> 140 - 30 = 110% (Still immune!)
const resUnbreakable = computeEffectiveResistance({
baseRes: 140,
convictionPierce: 150,
})
expect(resUnbreakable.isImmune).toBe(true)
expect(resUnbreakable.effectiveRes).toBe(100)
})
})
})