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:
parent
967a545116
commit
ff2d091254
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateMightStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateHolyFireStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateThornsStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateBlessedAimStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateConcentrationStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateHolyFreezeStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateHolyShockStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateSanctuaryStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateFanaticismStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -41,4 +41,5 @@ export const skillModule: SkillModule = {
|
|||
},
|
||||
}
|
||||
|
||||
export { calculateConvictionStats } from '../../../skills.ts'
|
||||
export default skillModule
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue