diff --git a/src/game/engine/aura-scanner.ts b/src/game/engine/aura-scanner.ts index c2b4793..3291d1d 100644 --- a/src/game/engine/aura-scanner.ts +++ b/src/game/engine/aura-scanner.ts @@ -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 diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index 6611cb5..d1730f2 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -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 } : {}), diff --git a/src/game/engine/data-registry.ts b/src/game/engine/data-registry.ts index e1cc33c..d8dfd48 100644 --- a/src/game/engine/data-registry.ts +++ b/src/game/engine/data-registry.ts @@ -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'), diff --git a/src/game/engine/stat-list.ts b/src/game/engine/stat-list.ts index 3267fc7..be079ba 100644 --- a/src/game/engine/stat-list.ts +++ b/src/game/engine/stat-list.ts @@ -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) } diff --git a/src/game/engine/state-bus.ts b/src/game/engine/state-bus.ts index 4d781b4..eaa766a 100644 --- a/src/game/engine/state-bus.ts +++ b/src/game/engine/state-bus.ts @@ -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) diff --git a/src/game/skills.ts b/src/game/skills.ts index f936484..6b89c4f 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -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, + } +} + + diff --git a/src/game/skills/impl/pal/skill-098-might.ts b/src/game/skills/impl/pal/skill-098-might.ts index e8e9f5d..2e77274 100644 --- a/src/game/skills/impl/pal/skill-098-might.ts +++ b/src/game/skills/impl/pal/skill-098-might.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateMightStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-102-holy-fire.ts b/src/game/skills/impl/pal/skill-102-holy-fire.ts index 8b96c84..0170a1c 100644 --- a/src/game/skills/impl/pal/skill-102-holy-fire.ts +++ b/src/game/skills/impl/pal/skill-102-holy-fire.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateHolyFireStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-103-thorns.ts b/src/game/skills/impl/pal/skill-103-thorns.ts index aa6e13a..c321cb4 100644 --- a/src/game/skills/impl/pal/skill-103-thorns.ts +++ b/src/game/skills/impl/pal/skill-103-thorns.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateThornsStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-108-blessed-aim.ts b/src/game/skills/impl/pal/skill-108-blessed-aim.ts index 57bbf60..32914f0 100644 --- a/src/game/skills/impl/pal/skill-108-blessed-aim.ts +++ b/src/game/skills/impl/pal/skill-108-blessed-aim.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateBlessedAimStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-113-concentration.ts b/src/game/skills/impl/pal/skill-113-concentration.ts index 628d3f6..1d08be0 100644 --- a/src/game/skills/impl/pal/skill-113-concentration.ts +++ b/src/game/skills/impl/pal/skill-113-concentration.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateConcentrationStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-114-holy-freeze.ts b/src/game/skills/impl/pal/skill-114-holy-freeze.ts index 6bf53ad..09050f7 100644 --- a/src/game/skills/impl/pal/skill-114-holy-freeze.ts +++ b/src/game/skills/impl/pal/skill-114-holy-freeze.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateHolyFreezeStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-118-holy-shock.ts b/src/game/skills/impl/pal/skill-118-holy-shock.ts index 8f34251..f7c7518 100644 --- a/src/game/skills/impl/pal/skill-118-holy-shock.ts +++ b/src/game/skills/impl/pal/skill-118-holy-shock.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateHolyShockStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-119-sanctuary.ts b/src/game/skills/impl/pal/skill-119-sanctuary.ts index 5a32ae7..3252483 100644 --- a/src/game/skills/impl/pal/skill-119-sanctuary.ts +++ b/src/game/skills/impl/pal/skill-119-sanctuary.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateSanctuaryStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-122-fanaticism.ts b/src/game/skills/impl/pal/skill-122-fanaticism.ts index b02b7b2..ced2da6 100644 --- a/src/game/skills/impl/pal/skill-122-fanaticism.ts +++ b/src/game/skills/impl/pal/skill-122-fanaticism.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateFanaticismStats } from '../../../skills.ts' export default skillModule diff --git a/src/game/skills/impl/pal/skill-123-conviction.ts b/src/game/skills/impl/pal/skill-123-conviction.ts index 08bc54c..76b544d 100644 --- a/src/game/skills/impl/pal/skill-123-conviction.ts +++ b/src/game/skills/impl/pal/skill-123-conviction.ts @@ -41,4 +41,5 @@ export const skillModule: SkillModule = { }, } +export { calculateConvictionStats } from '../../../skills.ts' export default skillModule diff --git a/tests/skills/pal/adv-pal-auras-stress.test.ts b/tests/skills/pal/adv-pal-auras-stress.test.ts new file mode 100644 index 0000000..dd2b3b4 --- /dev/null +++ b/tests/skills/pal/adv-pal-auras-stress.test.ts @@ -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) + } + }) +}) diff --git a/tests/skills/pal/offensive-auras.test.ts b/tests/skills/pal/offensive-auras.test.ts new file mode 100644 index 0000000..031961c --- /dev/null +++ b/tests/skills/pal/offensive-auras.test.ts @@ -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) + }) + }) +})