From 48183d3b82830ac9735a5e8c57f8c0308568d4a1 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 13:04:12 +0000 Subject: [PATCH] feat(paladin): implement Milestone M7 Paladin Combat Skills Tree (10 skills) --- src/game/engine/combat-pipeline.ts | 109 +++- src/game/engine/missile-engine.ts | 67 ++- src/game/skills.ts | 447 +++++++++++++++ src/game/skills/registry.ts | 129 ++++- src/scene/act-scene.ts | 30 +- .../skills/pal/adv-pal-combat-stress.test.ts | 348 ++++++++++++ tests/skills/pal/combat.test.ts | 522 ++++++++++++++++++ 7 files changed, 1618 insertions(+), 34 deletions(-) create mode 100644 tests/skills/pal/adv-pal-combat-stress.test.ts create mode 100644 tests/skills/pal/combat.test.ts diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index 0912373..37a9179 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -306,6 +306,15 @@ export interface SUnitDmgPacket { readonly isOpenWounds?: boolean readonly returnFire?: boolean readonly isEnchant?: boolean + readonly selfDamagePct?: number | undefined + readonly multiElemPacket?: { + readonly firePct?: number | undefined + readonly coldPct?: number | undefined + readonly ltngPct?: number | undefined + readonly chillDurationFrames?: number | undefined + } | undefined + readonly stunDurationFrames?: number | undefined + readonly knockback?: boolean | undefined } export interface SUnitDmgOutcome { @@ -324,6 +333,7 @@ export interface SUnitDmgOutcome { readonly attackerColdDamage256?: number readonly reflectedPhys256?: number readonly attackerHealed256?: number + readonly selfDamageTaken256?: number readonly spawnedMissiles?: readonly string[] } @@ -416,12 +426,16 @@ export function executeSUnitDmg( } // 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation - const srcDam = packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0) + const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0) + const isSpellWithoutFlatPhys = attackKind === 'spell' && !hasFlatPhys + const srcDam = isSpellWithoutFlatPhys ? 0 : (packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0)) let rawPhys256 = 0 - if (srcDam > 0 || (packet.flatPhysMin256 ?? 0) > 0 || (packet.flatPhysMax256 ?? 0) > 0) { + if (!isSpellWithoutFlatPhys && (srcDam > 0 || hasFlatPhys)) { const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE - const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128) + const hasFlatPhys = (packet.flatPhysMin256 !== undefined && packet.flatPhysMin256 > 0) || (packet.flatPhysMax256 !== undefined && packet.flatPhysMax256 > 0) + const effectiveSrcDam = hasFlatPhys ? (packet.srcDam ?? 0) : srcDam + const avgWeap256 = effectiveSrcDam > 0 ? Math.trunc(((weapMin256 + weapMax256) / 2) * effectiveSrcDam / 128) : 0 const flatAvg256 = Math.trunc(((packet.flatPhysMin256 ?? 0) + (packet.flatPhysMax256 ?? 0)) / 2) const dmgPctBonus = attacker.statList.getModifierBonus('damagepercent') const curseDmgMod = @@ -594,7 +608,7 @@ export function executeSUnitDmg( ? 0 : Math.max(0, Math.trunc((remRawPhys256 * (100 - physResRec.effectiveRes)) / 100)) - let finalElem256 = isFireGolemAbsorb || elemResRec.isImmune + let finalElem256 = isFireGolemAbsorb || elemResRec.isImmune || defender.statList.getAccruedStat(resStatKey) >= 100 ? 0 : Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100)) @@ -625,6 +639,92 @@ export function executeSUnitDmg( } } + // Vengeance (111) multi-elemental packet evaluation (Fire, Cold, Lightning) + if (packet.multiElemPacket && rawPhys256 > 0) { + if (!packet.elemMin256 && !packet.elemMax256) { + finalElem256 = 0 + } + const mep = packet.multiElemPacket + const baseWpPhys256 = rawPhys256 + + if (mep.firePct && mep.firePct > 0) { + const rawFire256 = Math.trunc((baseWpPhys256 * mep.firePct) / 100) + const fireRes = computeEffectiveResistance({ + baseRes: defender.statList.getAccruedStat('fireresist'), + convictionPierce, + lowerResistPierce, + passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_fire_pierce'), + }) + if (!fireRes.isImmune) { + finalElem256 += Math.max(0, Math.trunc((rawFire256 * (100 - fireRes.effectiveRes)) / 100)) + } + } + + if (mep.coldPct && mep.coldPct > 0) { + const rawCold256 = Math.trunc((baseWpPhys256 * mep.coldPct) / 100) + const coldRes = computeEffectiveResistance({ + baseRes: defender.statList.getAccruedStat('coldresist'), + convictionPierce, + lowerResistPierce, + passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_cold_pierce'), + }) + if (!coldRes.isImmune) { + finalElem256 += Math.max(0, Math.trunc((rawCold256 * (100 - coldRes.effectiveRes)) / 100)) + if (!defender.statList.getAccruedStat('cannot_be_frozen')) { + const chillFrames = mep.chillDurationFrames ?? 50 + defender.stateBus.applyState({ + stateNameOrId: 'freeze', + sourceSkillId: packet.skillId, + slvl: 1, + durationFrames: chillFrames, + }) + } + } + } + + if (mep.ltngPct && mep.ltngPct > 0) { + const rawLtng256 = Math.trunc((baseWpPhys256 * mep.ltngPct) / 100) + const ltngRes = computeEffectiveResistance({ + baseRes: defender.statList.getAccruedStat('lightresist'), + convictionPierce, + lowerResistPierce, + passiveEnemyPierce: packet.passiveEnemyPierce ?? attacker.statList.getAccruedStat('passive_ltng_pierce'), + }) + if (!ltngRes.isImmune) { + finalElem256 += Math.max(0, Math.trunc((rawLtng256 * (100 - ltngRes.effectiveRes)) / 100)) + } + } + } + + // Smite (97) / generic stun application + if (packet.stunDurationFrames && packet.stunDurationFrames > 0) { + defender.stateBus.applyState({ + stateNameOrId: 'stun', + sourceSkillId: packet.skillId, + slvl: 1, + durationFrames: packet.stunDurationFrames, + }) + } + + // Knockback application + if (packet.knockback) { + defender.stateBus.applyState({ + stateNameOrId: 'knockback', + sourceSkillId: packet.skillId, + slvl: 1, + durationFrames: 10, + }) + } + + // Self-damage calculation (e.g. Sacrifice 96: 8% of damage dealt, clamped >= 1 HP) + let selfDamageTaken256 = 0 + if (packet.selfDamagePct && packet.selfDamagePct > 0 && finalPhys256 > 0) { + selfDamageTaken256 = Math.trunc((finalPhys256 * packet.selfDamagePct) / 100) + const curAttackerHp256 = attacker.statList.getHp256() + const newAttackerHp256 = Math.max(FIXED_ONE, curAttackerHp256 - selfDamageTaken256) + attacker.statList.setHp256(newAttackerHp256) + } + const totalDamage256 = finalPhys256 + finalElem256 const currentDefenderHp256 = defender.statList.getHp256() const hpAfter256 = Math.max(0, currentDefenderHp256 - totalDamage256) @@ -734,6 +834,7 @@ export function executeSUnitDmg( ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), ...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}), ...(attackerHealed256 > 0 ? { attackerHealed256 } : {}), + ...(selfDamageTaken256 > 0 ? { selfDamageTaken256 } : {}), ...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}), } } diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index ba5261d..177d5c9 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -644,6 +644,9 @@ export interface ActiveMissile { altitude?: number | undefined seed?: number | undefined jitterIndex?: number | undefined + originX?: number | undefined + originY?: number | undefined + spiralTheta?: number | undefined } export interface MissileTerrainCollision { @@ -903,6 +906,9 @@ export class MissileEngine { : params.seed !== undefined ? { jitterIndex: Math.abs(params.seed) % 32 } : {}), + originX: params.startX, + originY: params.startY, + spiralTheta: angleRad, } this.missiles.push(missile) @@ -1071,14 +1077,18 @@ export class MissileEngine { } // Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`) - // In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5. + // Authentic Archimedean spiral: r = a * theta, foreshortened by 0.5 on y-axis in 2:1 isometric space if (isTurretEmitter) { // Stationary turret does not move } else if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') { - m.angleRad += 0.24 - const radius = 14 + m.ageTicks * 4 - m.x += Math.cos(m.angleRad) * (radius * 0.25) - m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5) + const cx = m.originX ?? m.x + const cy = m.originY ?? m.y + m.spiralTheta = (m.spiralTheta ?? 0) + 0.16 + const theta = m.spiralTheta + // Radial expansion speed: a = 12..14 px per radian + const r = Math.max(12, 13 * theta) + m.x = cx + r * Math.cos(theta) + m.y = cy + r * Math.sin(theta) * ISO_GROUND_ASPECT_RATIO } else { // Authentic 1.13c Homing mechanics (Bone Spirit / Guided Arrow, pSrvDoFunc = 7): // Continuously tracks and steers toward closest active hostile target in 2:1 isometric perspective @@ -1256,12 +1266,24 @@ export class MissileEngine { continue } - // Holy Bolt (`skillId = 101` or `fistoftheheavensbolt` `234`): only damages Undead (or Demons if FoH main bolt) - if ( - (m.name.toLowerCase() === 'holybolt' || m.name.toLowerCase() === 'fistoftheheavensbolt') && - !target.isUndead - ) { - continue + // Holy Bolt (`skillId = 101` or `fistoftheheavensbolt` `234`): + // 1. Undead: Takes Magic damage, consumes missile. + // 2. Friendly Ally (player, mercenary, party, minion): Heals ally, consumes missile. + // 3. Living Non-Undead (Demon, Beast, Monster without isUndead): Passes through without collision! + const isHolyBoltMissile = + m.name.toLowerCase() === 'holybolt' || m.name.toLowerCase() === 'fistoftheheavensbolt' + const isFriendlyAlly = + target.unitType === 'player' || + target.unitType === 'mercenary' || + target.unitType === 'party' || + target.unitType === 'party_mercenary' || + target.unitType === 'minion' + + if (isHolyBoltMissile) { + if (!target.isUndead && !isFriendlyAlly) { + // Passes through living monsters/demons/beasts without collision! + continue + } } // Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), Meteor Center (pSrvHitFunc === 14 / meteorcenter), @@ -1302,6 +1324,26 @@ export class MissileEngine { this.nextDelayExpireByTarget.set(target.id, currentTick + m.nextDelayFrames) } + // Holy Bolt friendly heal + if (isHolyBoltMissile && isFriendlyAlly) { + const prayerBlvl = m.owner.statList.getBaseSkillLevel(99) + const rawHealMin = 1 + (m.slvl - 1) * 2 + const rawHealMax = 6 + (m.slvl - 1) * 4 + const prayerBonus = prayerBlvl * 15 + const healAmount = Math.trunc(((rawHealMin + rawHealMax) / 2) * (100 + prayerBonus) / 100) + const curHp = target.statList.getHp256() + const maxHp = target.statList.getMaxHp256() + target.statList.setHp256(Math.min(maxHp, curHp + healAmount * 256)) + hits.push({ + missileName: m.name, + targetId: target.id, + damage: 0, + immune: false, + }) + m.expired = true + break + } + const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket) hits.push({ missileName: m.name, @@ -1461,11 +1503,12 @@ export class MissileEngine { } } - // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning / Bone Spear) + // Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning / Bone Spear / Blessed Hammer) const isInfinitePierce = (m.name === 'lightningbolt' || m.name === 'lightning' || m.name === 'bonespear' || + m.name === 'blessedhammer' || (!m.record.collideKill && m.canPierce)) && !m.name.includes('explode') && !m.name.endsWith('explode') diff --git a/src/game/skills.ts b/src/game/skills.ts index e7d1218..d5de7fe 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -6338,3 +6338,450 @@ export function executeSkill( } } +// ============================================================================ +// Paladin Combat Skills (Tree 1 - 10 Skills) Standalone 1.13c Formulas +// ============================================================================ + +/** + * 1. Sacrifice (96) + * - %ED: 180 + (slvl - 1) * 15% + * - AR: 20 + (slvl - 1) * 7% + * - Synergies: Redemption (+15%/lvl), Fanaticism (+5%/lvl) + * - Self-damage: strictly 8% of damage dealt + */ +export function calculateSacrificeStats( + slvl: number, + synergies?: { redemption?: number; fanaticism?: number }, +): { toHitBonusPct: number; damagePct: number; selfDamagePct: number } { + const lvl = Math.max(1, slvl) + const toHitBonusPct = 20 + (lvl - 1) * 7 + const baseEd = 180 + (lvl - 1) * 15 + const redemptionBlvl = synergies?.redemption ?? 0 + const fanaticismBlvl = synergies?.fanaticism ?? 0 + const synBonus = redemptionBlvl * 15 + fanaticismBlvl * 5 + return { + toHitBonusPct, + damagePct: baseEd + synBonus, + selfDamagePct: 8, + } +} + +/** + * 2. Smite (97) + * - %ED: 15 + (slvl - 1) * 15% + * - Stun: Math.min(250, 15 + (slvl - 1) * 5) frames + * - Damage: shield base min/max + Holy Shield flat bonus + */ +export function calculateSmiteStats( + slvl: number, + shieldMin: number, + shieldMax: number, + holyShieldBonus?: { min: number; max: number }, +): { damagePct: number; stunDurationFrames: number; minDamage: number; maxDamage: number } { + const lvl = Math.max(1, slvl) + const damagePct = 15 + (lvl - 1) * 15 + const stunDurationFrames = Math.min(250, 15 + (lvl - 1) * 5) + const hsMin = holyShieldBonus?.min ?? 0 + const hsMax = holyShieldBonus?.max ?? 0 + const minDamage = shieldMin + hsMin + const maxDamage = shieldMax + hsMax + return { + damagePct, + stunDurationFrames, + minDamage, + maxDamage, + } +} + +/** + * 3. Holy Bolt (101) Magic Damage vs Undead + * 5-band scaling: + * EMin: 8, EMinLev: 8, 10, 13, 16, 20 + * EMax: 16, EMaxLev: 8, 11, 15, 18, 23 + * Synergies: Blessed Hammer (+50%/blvl), Fist of the Heavens (+50%/blvl) + */ +export function calculateHolyBoltDamage( + slvl: number, + synergies?: { blessedHammer?: number; fistOfTheHeavens?: number }, +): { min: number; max: number } { + const lvl = Math.max(1, slvl) + let min = 8 + let max = 16 + + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + min += b1 * 8 + max += b1 * 8 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + min += b2 * 10 + max += b2 * 11 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + min += b3 * 13 + max += b3 * 15 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + min += b4 * 16 + max += b4 * 18 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 20 + max += b5 * 23 + } + + const bhBlvl = synergies?.blessedHammer ?? 0 + const fohBlvl = synergies?.fistOfTheHeavens ?? 0 + const synBonusPct = (bhBlvl + fohBlvl) * 50 + const minFinal = Math.trunc((min * (100 + synBonusPct)) / 100) + const maxFinal = Math.trunc((max * (100 + synBonusPct)) / 100) + return { min: minFinal, max: maxFinal } +} + +/** + * 4. Holy Bolt (101) Friendly Heal + * Min heal: 1 + (slvl - 1) * 2 + * Max heal: 6 + (slvl - 1) * 4 + * Synergy: Prayer (+15%/blvl) + */ +export function calculateHolyBoltHeal( + slvl: number, + prayerBlvl: number = 0, +): { min: number; max: number } { + const lvl = Math.max(1, slvl) + const rawMin = 1 + (lvl - 1) * 2 + const rawMax = 6 + (lvl - 1) * 4 + const prayerBonus = prayerBlvl * 15 + const min = Math.trunc((rawMin * (100 + prayerBonus)) / 100) + const max = Math.trunc((rawMax * (100 + prayerBonus)) / 100) + return { min, max } +} + +/** + * 5. Zeal (106) + * - Hits: min(slvl + 1, 5) per par5=2, par6=5 -> min(2 + slvl - 1, 5) = min(slvl + 1, 5) (lvl 1 -> 2, lvl 4+ -> 5) + * - AR: 10 + (slvl - 1) * 10% + * - %ED: slvl >= 5 ? (slvl - 4) * 6% : 0 + * - Synergy: Sacrifice (+12%/blvl) + */ +export function calculateZealStats( + slvl: number, + synergies?: { sacrifice?: number }, +): { hits: number; toHitBonusPct: number; damagePct: number } { + const lvl = Math.max(1, slvl) + const hits = Math.min(5, Math.max(2, 1 + lvl)) + const toHitBonusPct = 10 + (lvl - 1) * 10 + const baseEd = lvl >= 5 ? (lvl - 4) * 6 : 0 + const sacBlvl = synergies?.sacrifice ?? 0 + const damagePct = baseEd + sacBlvl * 12 + return { hits, toHitBonusPct, damagePct } +} + +/** + * 6. Charge (107) + * - AR: 50 + (slvl - 1) * 15% + * - %ED: 100 + (slvl - 1) * 25% + * - Synergies: Vigor (+20%/blvl), Might (+20%/blvl) + * - Speed multiplier: 3.0 (300% base run velocity) + */ +export function calculateChargeStats( + slvl: number, + synergies?: { vigor?: number; might?: number }, +): { toHitBonusPct: number; damagePct: number; speedMultiplier: number } { + const lvl = Math.max(1, slvl) + const toHitBonusPct = 50 + (lvl - 1) * 15 + const baseEd = 100 + (lvl - 1) * 25 + const vigorBlvl = synergies?.vigor ?? 0 + const mightBlvl = synergies?.might ?? 0 + const synBonus = (vigorBlvl + mightBlvl) * 20 + return { + toHitBonusPct, + damagePct: baseEd + synBonus, + speedMultiplier: 3.0, + } +} + +/** + * 7. Vengeance (111) + * - AR: 20 + (slvl - 1) * 10% + * - Base Elemental %: 70 + (slvl - 1) * 6% + * - Synergies: + * - Fire %: + Resist Fire (+10%/blvl) + Salvation (+2%/blvl) + * - Cold %: + Resist Cold (+10%/blvl) + Salvation (+2%/blvl) + * - Lightning %: + Resist Lightning (+10%/blvl) + Salvation (+2%/blvl) + * - Cold Chill Duration: 30 + (slvl - 1) * 15 frames (1.2s + 0.6s/lvl) + */ +export function calculateVengeanceStats( + slvl: number, + synergies?: { + resistFire?: number + resistCold?: number + resistLightning?: number + salvation?: number + }, +): { + toHitBonusPct: number + firePct: number + coldPct: number + ltngPct: number + chillDurationFrames: number +} { + const lvl = Math.max(1, slvl) + const toHitBonusPct = 20 + (lvl - 1) * 10 + const baseElemPct = 70 + (lvl - 1) * 6 + const salvBonus = (synergies?.salvation ?? 0) * 2 + const firePct = baseElemPct + (synergies?.resistFire ?? 0) * 10 + salvBonus + const coldPct = baseElemPct + (synergies?.resistCold ?? 0) * 10 + salvBonus + const ltngPct = baseElemPct + (synergies?.resistLightning ?? 0) * 10 + salvBonus + const chillDurationFrames = 30 + (lvl - 1) * 15 + return { + toHitBonusPct, + firePct, + coldPct, + ltngPct, + chillDurationFrames, + } +} + +/** + * 8. Blessed Hammer (112) Magic Damage + * 5-band scaling: + * EMin: 12, EMinLev: 8, 12, 16, 20, 24 + * EMax: 16, EMaxLev: 8, 12, 16, 20, 24 + * Synergies: Blessed Aim (+14%/blvl), Vigor (+14%/blvl) + * Concentration: receives 50% of active Concentration %ED + */ +export function calculateBlessedHammerDamage( + slvl: number, + synergies?: { blessedAim?: number; vigor?: number }, + concentrationEd: number = 0, +): { min: number; max: number; concentrationBonusPct: number } { + const lvl = Math.max(1, slvl) + let min = 12 + let max = 16 + + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + min += b1 * 8 + max += b1 * 8 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + min += b2 * 12 + max += b2 * 12 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + min += b3 * 16 + max += b3 * 16 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + min += b4 * 20 + max += b4 * 20 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 24 + max += b5 * 24 + } + + const aimBlvl = synergies?.blessedAim ?? 0 + const vigorBlvl = synergies?.vigor ?? 0 + const synBonusPct = (aimBlvl + vigorBlvl) * 14 + const concBonusPct = Math.trunc(concentrationEd / 2) + const totalMultPct = 100 + synBonusPct + + let minFinal = Math.trunc((min * totalMultPct) / 100) + let maxFinal = Math.trunc((max * totalMultPct) / 100) + + if (concBonusPct > 0) { + minFinal = Math.trunc((minFinal * (100 + concBonusPct)) / 100) + maxFinal = Math.trunc((maxFinal * (100 + concBonusPct)) / 100) + } + + return { min: minFinal, max: maxFinal, concentrationBonusPct: concBonusPct } +} + +/** + * 9. Conversion (116) + * - Chance %: dm34 -> Math.floor((slvl * 50) / (slvl + 10)) + * - Duration: 400 frames (16.0s) + */ +export function calculateConversionStats(slvl: number): { + chancePct: number + durationFrames: number + durationSeconds: number +} { + const lvl = Math.max(1, slvl) + const chancePct = Math.floor((lvl * 50) / (lvl + 10)) + return { + chancePct, + durationFrames: 400, + durationSeconds: 16, + } +} + +/** + * 10. Holy Shield (117) + * - Duration: 750 + (slvl - 1) * 625 frames (30s + 25s/lvl) + * - Defense %: 25 + (slvl - 1) * 15% + Defiance (+15%/blvl) + * - Blocking %: 10 + Math.floor((slvl * 40) / (slvl + 10))% (dm56) + * - Flat Smite Damage: 3 + (slvl - 1) * 4 min / 6 + (slvl - 1) * 4 max (per 1.13c compute5BandScaling on Holy Shield min/maxDam) + */ +export function calculateHolyShieldStats( + slvl: number, + defianceBlvl: number = 0, +): { + durationFrames: number + defensePct: number + blockChancePct: number + smiteMinFlat: number + smiteMaxFlat: number +} { + const lvl = Math.max(1, slvl) + const durationFrames = 750 + (lvl - 1) * 625 + const defensePct = 25 + (lvl - 1) * 15 + defianceBlvl * 15 + const blockChancePct = 10 + Math.floor((lvl * 40) / (lvl + 10)) + + // Holy Shield flat smite min/max damage progression (Skills.txt: minDam=3, maxDam=6, levDam1=2..3 etc.) + // Let's use exact 5-band scaling: minDam=3, lev=2..3, maxDam=6, lev=2..3 + // From 1.13c Skills.txt: minDam=3, minLevDam1=2, minLevDam2=3, minLevDam3=3, minLevDam4=4, minLevDam5=4 + // maxDam=6, maxLevDam1=2, maxLevDam2=3, maxLevDam3=3, maxLevDam4=4, maxLevDam5=4 + let smiteMinFlat = 3 + let smiteMaxFlat = 6 + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + smiteMinFlat += b1 * 2 + smiteMaxFlat += b1 * 2 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + smiteMinFlat += b2 * 3 + smiteMaxFlat += b2 * 3 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + smiteMinFlat += b3 * 4 + smiteMaxFlat += b3 * 4 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + smiteMinFlat += b4 * 4 + smiteMaxFlat += b4 * 4 + } + if (lvl > 28) { + const b5 = lvl - 28 + smiteMinFlat += b5 * 4 + smiteMaxFlat += b5 * 4 + } + + return { + durationFrames, + defensePct, + blockChancePct, + smiteMinFlat, + smiteMaxFlat, + } +} + +/** + * 11. Fist of the Heavens (121) Celestial Lightning Damage + * 5-band scaling: + * EMin: 150, EMinLev: 15, 30, 45, 55, 65 + * EMax: 200, EMaxLev: 15, 30, 45, 55, 65 + * Synergy: Holy Shock (+7%/blvl) + */ +export function calculateFistOfTheHeavensDamage( + slvl: number, + synergies?: { holyShock?: number }, +): { lightningMin: number; lightningMax: number } { + const lvl = Math.max(1, slvl) + let min = 150 + let max = 200 + + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + min += b1 * 15 + max += b1 * 15 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + min += b2 * 30 + max += b2 * 30 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + min += b3 * 45 + max += b3 * 45 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + min += b4 * 55 + max += b4 * 55 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 65 + max += b5 * 65 + } + + const holyShockBlvl = synergies?.holyShock ?? 0 + const synBonusPct = holyShockBlvl * 7 + const lightningMin = Math.trunc((min * (100 + synBonusPct)) / 100) + const lightningMax = Math.trunc((max * (100 + synBonusPct)) / 100) + return { lightningMin, lightningMax } +} + +/** + * 12. Fist of the Heavens (121) Radial Holy Bolt Damage + * Base Holy Bolt from FoH: + * EMin: 8, EMinLev: 8, 10, 13, 16, 20 + * EMax: 16, EMaxLev: 8, 11, 15, 18, 23 + * Synergy: Holy Bolt (+15%/blvl) + */ +export function calculateFistOfTheHeavensHolyBoltDamage( + slvl: number, + holyBoltBlvl: number = 0, +): { holyBoltMin: number; holyBoltMax: number } { + const lvl = Math.max(1, slvl) + let min = 8 + let max = 16 + + if (lvl > 1) { + const b1 = Math.min(lvl, 8) - 1 + min += b1 * 8 + max += b1 * 8 + } + if (lvl > 8) { + const b2 = Math.min(lvl, 16) - 8 + min += b2 * 10 + max += b2 * 11 + } + if (lvl > 16) { + const b3 = Math.min(lvl, 22) - 16 + min += b3 * 13 + max += b3 * 15 + } + if (lvl > 22) { + const b4 = Math.min(lvl, 28) - 22 + min += b4 * 16 + max += b4 * 18 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 20 + max += b5 * 23 + } + + const synBonusPct = holyBoltBlvl * 15 + const holyBoltMin = Math.trunc((min * (100 + synBonusPct)) / 100) + const holyBoltMax = Math.trunc((max * (100 + synBonusPct)) / 100) + return { holyBoltMin, holyBoltMax } +} + + diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index dce67c3..67783b0 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -22,7 +22,7 @@ import { evaluateCalc, } from '../engine/calc-ast.ts' import { FIXED_ONE } from '../engine/stat-list.ts' -import { computeCorpseExplosion113c, executeSUnitDmg } from '../engine/combat-pipeline.ts' +import { computeCorpseExplosion113c, executeSUnitDmg, type CombatUnitContext } from '../engine/combat-pipeline.ts' import { calculateStaticFieldDamage, calculateStaticFieldRadiusPx, @@ -593,6 +593,18 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { stats['skill_armor_percent'] = evalResult.radiusSubtiles || (45 + (evalResult.slvl - 1) * 5) } + // Holy Shield (`117`): requires shield, applies holyshield state (+block, +def, +smiteDmg) + if (skill.id === 117) { + const defBlvl = caster.statList.getBaseSkillLevel(104) + const hsStats = { + item_armor_percent: 25 + (evalResult.slvl - 1) * 15 + defBlvl * 15, + toblock: 10 + Math.floor((evalResult.slvl * 40) / (evalResult.slvl + 10)), + smite_flat_min: evalResult.minPhysDmg, + smite_flat_max: evalResult.maxPhysDmg, + } + Object.assign(stats, hsStats) + } + caster.stateBus.applyState({ stateNameOrId: buffState, sourceUnitId: caster.id, @@ -636,6 +648,58 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } } + // 8. Conversion (`116` `srvDoFunc = 79`) + if (skill.id === 116 || effectiveDoFunc === 79) { + const primaryTarget = targets.find(t => t.statList.getHp256() > 0) + if (primaryTarget) { + const isBossOrChamp = Boolean( + primaryTarget.isBoss || + primaryTarget.isChampion || + primaryTarget.rank === 'champion' || + primaryTarget.rank === 'unique' || + (primaryTarget.statList.getAccruedStat('is_boss') > 0) + ) + if (!isBossOrChamp) { + const convChancePct = Math.floor((evalResult.slvl * 50) / (evalResult.slvl + 10)) + const roll = Math.floor(Math.random() * 100) + if (roll < convChancePct) { + primaryTarget.stateBus.applyState({ + stateNameOrId: 'conversion', + sourceUnitId: caster.id, + sourceSkillId: skill.id, + slvl: evalResult.slvl, + durationFrames: evalResult.durationFrames || 400, + currentFrame: currentTick, + }) + statesApplied.push('conversion') + } + } + + // Also deliver weapon strike damage + const out = executeSUnitDmg(caster, primaryTarget, { + skillId: skill.id, + attackKind: 'melee', + srcDam: skill.srcDam || 128, + physDamagePct: evalResult.physDamagePct, + }) + totalDamageDealt += out.totalDamage + } + + return { + skillId: skill.id, + name: skill.name, + executed: true, + srvDoFuncUsed: effectiveDoFunc, + manaSpent256: evalResult.manaCost256, + missilesSpawned, + statesApplied, + petsSummoned, + corpsesConsumed, + totalDamageDealt, + notes, + } + } + // 8. Teleport (`54`, `srvDoFunc = 27`) if (skill.id === 54 || effectiveDoFunc === 27) { notes.push(`teleported:${String(Math.round(targetX))},${String(Math.round(targetY))}`) @@ -990,36 +1054,67 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { statesApplied.push(chargeState) } + // Multi-hit combo hits (Zeal: min(slvl + 1, 5) per par5=2, par6=5; others min 5, max 2) const hitCount = - [7, 13, 42, 46, 70, 76].includes(effectiveDoFunc) - ? Math.min(5, Math.max(2, 1 + Math.trunc(evalResult.slvl / 4))) - : 1 + skill.id === 106 + ? Math.min(5, Math.max(2, 1 + evalResult.slvl)) + : [7, 13, 42, 46, 70, 76].includes(effectiveDoFunc) + ? Math.min(5, Math.max(2, 1 + Math.trunc(evalResult.slvl / 4))) + : 1 + // Smite (97): Shield base damage + Holy Shield flat damage + const isSmite = skill.id === 97 + let smiteMin256 = 0 + let smiteMax256 = 0 + if (isSmite) { + const shieldMin = caster.statList.getAccruedStat('shield_min_damage') || 2 + const shieldMax = caster.statList.getAccruedStat('shield_max_damage') || 5 + const hsMin = caster.statList.getAccruedStat('smite_flat_min') || 0 + const hsMax = caster.statList.getAccruedStat('smite_flat_max') || 0 + smiteMin256 = (shieldMin + hsMin) * FIXED_ONE + smiteMax256 = (shieldMax + hsMax) * FIXED_ONE + } + + // Vengeance (111): Multi-elemental packet (Fire, Cold, Lightning) + const isVengeance = skill.id === 111 + const vengeanceMultiPacket = isVengeance + ? { + firePct: 70 + (evalResult.slvl - 1) * 6 + caster.statList.getBaseSkillLevel(100) * 10 + caster.statList.getBaseSkillLevel(125) * 2, + coldPct: 70 + (evalResult.slvl - 1) * 6 + caster.statList.getBaseSkillLevel(105) * 10 + caster.statList.getBaseSkillLevel(125) * 2, + ltngPct: 70 + (evalResult.slvl - 1) * 6 + caster.statList.getBaseSkillLevel(110) * 10 + caster.statList.getBaseSkillLevel(125) * 2, + chillDurationFrames: 30 + (evalResult.slvl - 1) * 15, + } + : undefined + + let curTarget: CombatUnitContext | undefined = primaryTarget for (let h = 0; h < hitCount; h += 1) { - if (primaryTarget.statList.getHp256() <= 0) break - const out = executeSUnitDmg(caster, primaryTarget, { + // Sequential retargeting if current target died + if (!curTarget || curTarget.statList.getHp256() <= 0) { + curTarget = targets.find(t => t.statList.getHp256() > 0) + if (!curTarget) break + } + + const out = executeSUnitDmg(caster, curTarget, { skillId: skill.id, attackKind: 'melee', - srcDam: skill.srcDam || 128, + srcDam: isSmite ? 0 : (skill.srcDam || 128), physDamagePct: evalResult.physDamagePct, - flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE, - flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE, + flatPhysMin256: isSmite ? smiteMin256 : (evalResult.minPhysDmg * FIXED_ONE), + flatPhysMax256: isSmite ? smiteMax256 : (evalResult.maxPhysDmg * FIXED_ONE), elemType: evalResult.elemType, elemMin256: evalResult.minElemDmg256, elemMax256: evalResult.maxElemDmg256, physToElemPct: skill.id === 152 ? 100 : 0, // Berserk converts 100% physical to Magic - autoHit: skill.id === 97, // Smite (`97`) always hits - unblockable: skill.id === 97, + autoHit: isSmite, // Smite (`97`) always hits + unblockable: isSmite, + stunDurationFrames: isSmite ? Math.min(250, 15 + (evalResult.slvl - 1) * 5) : undefined, + knockback: isSmite || skill.id === 107, + multiElemPacket: vengeanceMultiPacket, + selfDamagePct: skill.id === 96 ? 8 : undefined, }) totalDamageDealt += out.totalDamage } - // Sacrifice (`96`): deducts 8% of damage dealt from caster HP - if (skill.id === 96 && totalDamageDealt > 0) { - const selfCost256 = Math.trunc((totalDamageDealt * 8 * FIXED_ONE) / 100) - caster.statList.setHp256(Math.max(FIXED_ONE, caster.statList.getHp256() - selfCost256)) - } - // Charged Strike (`24`) / Lightning Strike (`34`): also spawns radial/chaining lightning bolts on melee hit if ((skill.id === 24 || skill.id === 34) && evalResult.missileA) { const m = missileEngine.spawnMissile({ diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 1f0b5fb..38e85b1 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount } from '../game/skills.ts' +import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1254,6 +1254,18 @@ export function castSkill( return false } + // 1b. Shield requirement check for Smite (97) and Holy Shield (117) + if (skillId === 97 || skillId === 117) { + const shield = + (context as any).equippedShield ?? + hudManager?.inventory?.equipped?.weapon2 ?? + (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null) + if (!shield) { + status.textContent = skillId === 97 ? '重击需要装备盾牌!' : '圣盾需要装备盾牌!' + return false + } + } + // 2. Mana check & deduction let isFirstInfernoCast = false if (skillId === 41) { @@ -2742,6 +2754,22 @@ export function castSkill( damage = 50 delay = 30 skillName = '陨石' + } else if (skillId === 121) { + // Fist of the Heavens (121): 25-frame cast delay (1.0s cooldown), celestial lightning strike + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(121) ?? 1) + const holyShockBlvl = (hudManager as any)?.getSkillLevel?.(118) ?? 0 + const fohDmg = calculateFistOfTheHeavensDamage(slvl, { holyShock: holyShockBlvl }) + damage = Math.round((fohDmg.lightningMin + fohDmg.lightningMax) / 2) + speed = 0 + range = 40 + delay = 25 // 25-frame cast delay (1.0s) + skillName = '天堂之拳' + missileType = 'handofgod' + engine.spawnOverlay('handofgod', { x: targetX, y: targetY }) + // Radial holy bolts on impact + multiCount = 6 + spreadAngle = Math.PI * 2 + isUndeadOnly = true } else if (isBatch1Skill(skillId)) { const def = getBatch1SkillDef(skillId)! speed = def.speed ?? 450 diff --git a/tests/skills/pal/adv-pal-combat-stress.test.ts b/tests/skills/pal/adv-pal-combat-stress.test.ts new file mode 100644 index 0000000..e35efb9 --- /dev/null +++ b/tests/skills/pal/adv-pal-combat-stress.test.ts @@ -0,0 +1,348 @@ +/** + * Milestone M7: Paladin Combat Skills Tree Adversarial Stress Verification Suite + * + * Covers 5 mission-critical stress scenarios: + * 1. Rapid Hammer Spam Spiral Geometry: + * Spawns 20 hammers simultaneously; asserts zero NaN coordinates, valid 2:1 aspect ratio across all frames, + * and correct spiral radius bounds (r = a * theta). + * 2. Holy Bolt Multi-Target Density Discrimination: + * Line of mixed targets: [Undead, Demon, Beast, Friendly Player, Undead]. + * Asserts Holy Bolt damages first Undead, ignores Beast/Demon, and heals Player when friendly-targeted. + * 3. Sacrifice Extreme Overkill Self-Damage Safety: + * Paladin deals 50,000 damage with 10 HP remaining; asserts Paladin HP is clamped to 1, preventing death from self-damage. + * 4. Smite vs High-Defense Unblockable Invariant: + * Smite vs monster with 99,999 defense and 75% block rate; asserts 100% hit rate and 0% blocked attacks across 100 iterations. + * 5. Conversion Allegiance Cleanup: + * Converts 10 monsters; simulates 400 frames; asserts 100% of converted monsters cleanly return to non-converted state + * with zero state/memory leaks. + */ +import { describe, expect, it } from 'vitest' +import { + ISO_GROUND_ASPECT_RATIO, + calculateSacrificeStats, + calculateSmiteStats, + calculateConversionStats, +} from '../../../src/game/skills.ts' +import { + executeSUnitDmg, + type CombatUnitContext, +} from '../../../src/game/engine/combat-pipeline.ts' +import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../../src/game/engine/missile-engine.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' + +describe('Milestone M7 — Paladin Combat Skills Tree Adversarial Stress Suite', () => { + it('1. Rapid Hammer Spam Spiral Geometry: 20 simultaneous hammers, no NaN coords, 2:1 iso ratio', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + const caster: CombatUnitContext = { + id: 'hammer-spammer', + name: 'Paladin', + statList: casterStats, + stateBus: new StateBus(casterStats, registry), + } + + const spawnedHammers = [] + const originX = 100 + const originY = 200 + + // Spawn 20 hammers simultaneously with staggered initial angles + for (let i = 0; i < 20; i++) { + const angle = (i / 20) * Math.PI * 2 + const m = engine.spawnMissile({ + missileNameOrId: 'blessedhammer', + sourceSkillId: 112, + slvl: 20, + owner: caster, + startX: originX, + startY: originY, + targetX: originX + Math.cos(angle) * 100, + targetY: originY + Math.sin(angle) * 50, + dmgPacket: { + skillId: 112, + elemType: 'mag', + elemMin256: 500 * FIXED_ONE, + elemMax256: 600 * FIXED_ONE, + }, + }) + expect(m).toBeDefined() + if (m) { + m.spiralTheta = angle + spawnedHammers.push(m) + } + } + + expect(spawnedHammers.length).toBe(20) + + // Simulate 120 frames (standard hammer lifetime) + for (let frame = 1; frame <= 120; frame++) { + engine.tick(frame, [], new Map()) + + for (const h of spawnedHammers) { + expect(Number.isFinite(h.x)).toBe(true) + expect(Number.isFinite(h.y)).toBe(true) + expect(Number.isNaN(h.x)).toBe(false) + expect(Number.isNaN(h.y)).toBe(false) + + // Radial bounds test: radius expands monotonically as theta increases + const dx = h.x - originX + const dy = (h.y - originY) / ISO_GROUND_ASPECT_RATIO + const currentR = Math.hypot(dx, dy) + expect(currentR).toBeGreaterThanOrEqual(11.9) + expect(currentR).toBeLessThanOrEqual(Math.max(12, 13 * (h.spiralTheta ?? 0)) + 0.1) + } + } + }) + + it('2. Holy Bolt Multi-Target Density Discrimination: [Undead, Demon, Beast, Friendly Player, Undead]', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + casterStats.setBaseSkillLevel(99, 10) // Prayer synergy + const caster: CombatUnitContext = { + id: 'caster-paladin', + name: 'Paladin', + statList: casterStats, + stateBus: new StateBus(casterStats, registry), + } + + // Dense row of 5 units positioned along x axis: [Undead1, Demon, Beast, FriendlyAlly, Undead2] + const u1Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 }) + const u1: CombatUnitContext = { + id: 'undead-1', + name: 'Skeleton', + statList: u1Stats, + stateBus: new StateBus(u1Stats, registry), + isUndead: true, + unitType: 'monster', + } + + const demonStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + const demon: CombatUnitContext = { + id: 'demon-2', + name: 'Fallen', + statList: demonStats, + stateBus: new StateBus(demonStats, registry), + isUndead: false, + isDemon: true, + unitType: 'monster', + } + + const beastStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + const beast: CombatUnitContext = { + id: 'beast-3', + name: 'Quill Rat', + statList: beastStats, + stateBus: new StateBus(beastStats, registry), + isUndead: false, + unitType: 'monster', + } + + const allyStats = new UnitStatList(registry, { hitpoints: 200 * FIXED_ONE, maxhp: 1000 * FIXED_ONE }) + const ally: CombatUnitContext = { + id: 'ally-4', + name: 'Mercenary', + statList: allyStats, + stateBus: new StateBus(allyStats, registry), + unitType: 'mercenary', + } + + const u2Stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 }) + const u2: CombatUnitContext = { + id: 'undead-5', + name: 'Zombie', + statList: u2Stats, + stateBus: new StateBus(u2Stats, registry), + isUndead: true, + unitType: 'monster', + } + + const positions = new Map([ + ['undead-1', { x: 30, y: 0 }], + ['demon-2', { x: 60, y: 0 }], + ['beast-3', { x: 90, y: 0 }], + ['ally-4', { x: 120, y: 0 }], + ['undead-5', { x: 150, y: 0 }], + ]) + + // Scenario A: Holy bolt fired into the pack towards Undead 1 -> hits Undead 1 and consumes missile + const m1 = engine.spawnMissile({ + missileNameOrId: 'holybolt', + sourceSkillId: 101, + slvl: 10, + owner: caster, + startX: 0, + startY: 0, + targetX: 200, + targetY: 0, + dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE }, + })! + engine.tick(1, [u1, demon, beast, ally, u2], positions) + expect(u1Stats.getHp256()).toBeLessThan(1000 * FIXED_ONE) + expect(m1.expired).toBe(true) + + // Scenario B: Holy bolt fired starting AFTER Undead 1, through Demon and Beast, directly at Ally + const m2 = engine.spawnMissile({ + missileNameOrId: 'holybolt', + sourceSkillId: 101, + slvl: 10, + owner: caster, + startX: 40, + startY: 0, + targetX: 200, + targetY: 0, + dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 150 * FIXED_ONE, elemMax256: 150 * FIXED_ONE }, + })! + + // Tick over demon and beast (positions 60 and 90) + engine.tick(2, [demon, beast], positions) + expect(demonStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Demon! + expect(beastStats.getHp256()).toBe(1000 * FIXED_ONE) // Passed through Beast! + expect(m2.expired).toBe(false) // Not expired! + + // Position missile onto ally (x = 120) + m2.x = 120 + engine.tick(3, [ally, u2], positions) + expect(allyStats.getHp256()).toBeGreaterThan(200 * FIXED_ONE) // Healed ally! + expect(m2.expired).toBe(true) // Consumed by healing ally! + expect(u2Stats.getHp256()).toBe(1000 * FIXED_ONE) // Undead 2 behind ally untouched! + }) + + it('3. Sacrifice Extreme Overkill Self-Damage Safety: 50,000 damage with 10 HP clamped to 1 HP', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, { + hitpoints: 10 * FIXED_ONE, + maxhp: 1000 * FIXED_ONE, + }) + const attacker: CombatUnitContext = { + id: 'low-hp-paladin', + name: 'Paladin', + statList: attackerStats, + stateBus: new StateBus(attackerStats, registry), + weaponMinPhys: 50000, + weaponMaxPhys: 50000, + } + + const defenderStats = new UnitStatList(registry, { + hitpoints: 100000 * FIXED_ONE, + maxhp: 100000 * FIXED_ONE, + damageresist: 0, + }) + const defender: CombatUnitContext = { + id: 'meat-sponge', + name: 'Gargantuan Beast', + statList: defenderStats, + stateBus: new StateBus(defenderStats, registry), + } + + // 50,000 damage dealt -> 8% self damage = 4,000 damage + // Attacker only has 10 HP. Attacker HP MUST clamp to 1 HP (256 in FIXED_ONE), preventing suicide. + const out = executeSUnitDmg(attacker, defender, { + skillId: 96, + attackKind: 'melee', + srcDam: 128, + selfDamagePct: 8, + autoHit: true, + }) + + expect(out.hit).toBe(true) + expect(out.physDamage256).toBe(50000 * FIXED_ONE) + expect(out.selfDamageTaken256).toBe(4000 * FIXED_ONE) + expect(attackerStats.getHp256()).toBe(FIXED_ONE) // Exactly 1 HP remaining! + }) + + it('4. Smite vs High-Defense Unblockable Invariant: 99,999 def, 75% block -> 100% hits, 0% blocks', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, { + tohit: 100, // Very low attack rating + }) + const attacker: CombatUnitContext = { + id: 'smiter', + name: 'Paladin', + statList: attackerStats, + stateBus: new StateBus(attackerStats, registry), + } + + const defenderStats = new UnitStatList(registry, { + armorclass: 99999, // Extreme defense + toblock: 75, // Max block rate + hitpoints: 50000 * FIXED_ONE, + maxhp: 50000 * FIXED_ONE, + }) + const defender: CombatUnitContext = { + id: 'impenetrable-guardian', + name: 'Iron Golem Boss', + statList: defenderStats, + stateBus: new StateBus(defenderStats, registry), + hasShield: true, + } + + // Run 100 iterations of Smite strikes + let hits = 0 + let blocks = 0 + + for (let i = 0; i < 100; i++) { + const out = executeSUnitDmg(attacker, defender, { + skillId: 97, + attackKind: 'melee', + srcDam: 0, + flatPhysMin256: 40 * FIXED_ONE, + flatPhysMax256: 50 * FIXED_ONE, + autoHit: true, + unblockable: true, + stunDurationFrames: 25, + roll100: i % 100, // Explicit roll + }) + + if (out.hit) hits++ + if (out.avoidedReason === 'block') blocks++ + } + + expect(hits).toBe(100) + expect(blocks).toBe(0) + expect(defender.stateBus.hasState('stun')).toBe(true) + }) + + it('5. Conversion Allegiance Cleanup: converts 10 monsters; simulates 400 frames; asserts 100% reversion', async () => { + const registry = await getSharedDataRegistry() + const monsters: { id: string; stats: UnitStatList; bus: StateBus }[] = [] + + for (let i = 0; i < 10; i++) { + const stats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + const bus = new StateBus(stats, registry) + monsters.push({ id: `converted-${i}`, stats, bus }) + } + + // Apply conversion to all 10 monsters at frame 100 for 400 frames + for (const m of monsters) { + const res = m.bus.applyState({ + stateNameOrId: 'conversion', + sourceUnitId: 'paladin-converter', + sourceSkillId: 116, + slvl: 10, + currentFrame: 100, + durationFrames: 400, // Expires at frame 500 + }) + expect(res.applied).toBe(true) + expect(m.bus.hasState('conversion')).toBe(true) + } + + // Advance to frame 499: all 10 must still be converted + for (const m of monsters) { + m.bus.tick(499) + expect(m.bus.hasState('conversion')).toBe(true) + } + + // Advance to frame 500: all 10 must expire and revert cleanly + for (const m of monsters) { + const expired = m.bus.tick(500) + expect(expired).toContain('conversion') + expect(m.bus.hasState('conversion')).toBe(false) + expect(m.bus.getActiveEntries().length).toBe(0) // Clean state bus, zero memory leaks! + } + }) +}) diff --git a/tests/skills/pal/combat.test.ts b/tests/skills/pal/combat.test.ts new file mode 100644 index 0000000..81fe3ff --- /dev/null +++ b/tests/skills/pal/combat.test.ts @@ -0,0 +1,522 @@ +/** + * Milestone M7: Paladin Combat Skills Tree (10 Skills) Comprehensive Verification Suite + * + * Verifies exact 1.13c formulas, synergies, mechanics, and runtime behaviors for: + * 1. Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5. + * 2. Sacrifice (96): %ED, AR, Redemption/Fanaticism synergies, 8% net physical self-damage clamped >= 1 HP. + * 3. Smite (97): autoHit=true, unblockable=true, shield base + Holy Shield flat damage, stun duration scaling. + * 4. Holy Bolt (101): Undead magic damage + synergies, friendly ally heal + Prayer synergy, living non-undead passthrough. + * 5. Zeal (106): hit count min(slvl + 1, 5), %ED scaling, sequential retargeting on target death. + * 6. Charge (107): 300% movement velocity, 2:1 isometric vector, %ED, knockback on hit. + * 7. Vengeance (111): Physical base + Fire%/Cold%/Ltng% multi-element packet, cold chill duration, independent resistances. + * 8. Blessed Hammer (112): Archimedean spiral in 2:1 iso, Concentration +50% synergy, 1.13c magic resistance invariance. + * 9. Conversion (116): dm34 conversion roll, boss/champion immunity, 16s allegiance shift. + * 10. Holy Shield (117): Duration, defense% + Defiance synergy, dm56 block%, flat Smite damage addition. + * 11. Fist of the Heavens (121): 25-frame cast delay, celestial lightning + Holy Shock synergy, radial holy bolts damaging only undead. + */ +import { describe, expect, it } from 'vitest' +import { + BATCH1_SKILLS, + ISO_GROUND_ASPECT_RATIO, + calculateSacrificeStats, + calculateSmiteStats, + calculateHolyBoltDamage, + calculateHolyBoltHeal, + calculateZealStats, + calculateChargeStats, + calculateVengeanceStats, + calculateBlessedHammerDamage, + calculateConversionStats, + calculateHolyShieldStats, + calculateFistOfTheHeavensDamage, + calculateFistOfTheHeavensHolyBoltDamage, +} from '../../../src/game/skills.ts' +import { + executeSUnitDmg, + computeEffectiveResistance, + type CombatUnitContext, +} from '../../../src/game/engine/combat-pipeline.ts' +import { FIXED_ONE, UnitStatList } from '../../../src/game/engine/stat-list.ts' +import { StateBus } from '../../../src/game/engine/state-bus.ts' +import { MissileEngine } from '../../../src/game/engine/missile-engine.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' + +describe('Milestone M7 — Paladin Combat Skills Tree Unit Suite', () => { + it('1. Architecture Invariants: BATCH1_SKILLS length === 38, ISO_GROUND_ASPECT_RATIO === 0.5', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) + }) + + describe('2. Sacrifice (96)', () => { + it('calculates %ED, AR, and synergies accurately across levels', () => { + // slvl 1 + const s1 = calculateSacrificeStats(1) + expect(s1.toHitBonusPct).toBe(20) + expect(s1.damagePct).toBe(180) + expect(s1.selfDamagePct).toBe(8) + + // slvl 10 with synergies (Redemption lvl 5 = +75%, Fanaticism lvl 10 = +50%) + const s10 = calculateSacrificeStats(10, { redemption: 5, fanaticism: 10 }) + expect(s10.toHitBonusPct).toBe(20 + 9 * 7) // 83% + expect(s10.damagePct).toBe(180 + 9 * 15 + 75 + 50) // 440% + expect(s10.selfDamagePct).toBe(8) + + // slvl 20 + const s20 = calculateSacrificeStats(20) + expect(s20.toHitBonusPct).toBe(20 + 19 * 7) // 153% + expect(s20.damagePct).toBe(180 + 19 * 15) // 465% + }) + + it('inflicts strictly 8% self-damage on Paladin HP clamped to minimum 1 HP', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, { + hitpoints: 50 * FIXED_ONE, + maxhp: 1000 * FIXED_ONE, + }) + const attackerBus = new StateBus(attackerStats, registry) + const attacker: CombatUnitContext = { + id: 'paladin-sacrifice', + name: 'Paladin', + statList: attackerStats, + stateBus: attackerBus, + weaponMinPhys: 500, + weaponMaxPhys: 500, + } + + const defenderStats = new UnitStatList(registry, { + hitpoints: 10000 * FIXED_ONE, + maxhp: 10000 * FIXED_ONE, + damageresist: 0, + }) + const defenderBus = new StateBus(defenderStats, registry) + const defender: CombatUnitContext = { + id: 'monster-sacrifice', + name: 'Monster', + statList: defenderStats, + stateBus: defenderBus, + } + + // Deal 500 base physical damage with Sacrifice + const out = executeSUnitDmg(attacker, defender, { + skillId: 96, + attackKind: 'melee', + srcDam: 0, + flatPhysMin256: 500 * FIXED_ONE, + flatPhysMax256: 500 * FIXED_ONE, + selfDamagePct: 8, + autoHit: true, + }) + + expect(out.hit).toBe(true) + expect(out.physDamage256).toBe(500 * FIXED_ONE) + // 8% of 500 = 40 damage. Attacker had 50 HP, should have 10 HP left. + expect(out.selfDamageTaken256).toBe(40 * FIXED_ONE) + expect(attackerStats.getHp256()).toBe(10 * FIXED_ONE) + + // Now deal another 500 damage when attacker has only 10 HP. + // 40 self-damage would reduce HP to -30, but it MUST be clamped to 1 HP (FIXED_ONE)! + executeSUnitDmg(attacker, defender, { + skillId: 96, + attackKind: 'melee', + srcDam: 0, + flatPhysMin256: 500 * FIXED_ONE, + flatPhysMax256: 500 * FIXED_ONE, + selfDamagePct: 8, + autoHit: true, + }) + expect(attackerStats.getHp256()).toBe(FIXED_ONE) // Exactly 1 HP, cannot commit suicide! + }) + }) + + describe('3. Smite (97)', () => { + it('calculates %ED, stun duration, and shield + Holy Shield damage', () => { + // slvl 1: shield min 2, max 5, holy shield bonus min 3, max 6 + const s1 = calculateSmiteStats(1, 2, 5, { min: 3, max: 6 }) + expect(s1.damagePct).toBe(15) + expect(s1.stunDurationFrames).toBe(15) + expect(s1.minDamage).toBe(5) + expect(s1.maxDamage).toBe(11) + + // slvl 20: stun duration = min(250, 15 + 19 * 5) = 110 frames + const s20 = calculateSmiteStats(20, 10, 20, { min: 57, max: 60 }) + expect(s20.damagePct).toBe(15 + 19 * 15) // 300% + expect(s20.stunDurationFrames).toBe(110) + expect(s20.minDamage).toBe(67) + expect(s20.maxDamage).toBe(80) + }) + + it('enforces autoHit: true and unblockable: true against 95% defense and 75% block target', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, { level: 20, tohit: 100 }) + const attackerBus = new StateBus(attackerStats, registry) + const attacker: CombatUnitContext = { + id: 'smiter', + name: 'Paladin', + statList: attackerStats, + stateBus: attackerBus, + hasShield: true, + } + + // Defender with 99,999 defense, 75% block rate, higher level + const defenderStats = new UnitStatList(registry, { + level: 99, + armorclass: 99999, + toblock: 75, + hitpoints: 5000 * FIXED_ONE, + }) + const defenderBus = new StateBus(defenderStats, registry) + const defender: CombatUnitContext = { + id: 'high-def-target', + name: 'Iron Maiden', + statList: defenderStats, + stateBus: defenderBus, + hasShield: true, + } + + // Smite attack with autoHit and unblockable + const out = executeSUnitDmg(attacker, defender, { + skillId: 97, + attackKind: 'melee', + srcDam: 0, + flatPhysMin256: 50 * FIXED_ONE, + flatPhysMax256: 60 * FIXED_ONE, + autoHit: true, + unblockable: true, + stunDurationFrames: 50, + roll100: 10, // Normally would be blocked by 75% block + }) + + expect(out.hit).toBe(true) + expect(out.avoidedReason).toBe('none') + expect(out.totalDamage).toBeGreaterThan(0) + expect(defenderBus.hasState('stun')).toBe(true) + expect(defenderBus.getState('stun')?.durationFrames).toBe(50) + }) + }) + + describe('4. Holy Bolt (101)', () => { + it('calculates 5-band magic damage vs Undead with Blessed Hammer and FoH synergies', () => { + // slvl 1 + const dmg1 = calculateHolyBoltDamage(1) + expect(dmg1.min).toBe(8) + expect(dmg1.max).toBe(16) + + // slvl 10 (base min: 8 + 7*8 + 2*10 = 84, base max: 16 + 7*8 + 2*11 = 94) + const dmg10 = calculateHolyBoltDamage(10) + expect(dmg10.min).toBe(84) + expect(dmg10.max).toBe(94) + + // slvl 20 with Blessed Hammer (10 pts) and FoH (10 pts) -> 20 * 50% = +1000% synergy + const dmg20Syn = calculateHolyBoltDamage(20, { blessedHammer: 10, fistOfTheHeavens: 10 }) + // Base at lvl 20: min = 8 + 7*8 + 8*10 + 4*13 = 196, max = 16 + 7*8 + 8*11 + 4*15 = 220 + // With +1000% syn: 196 * 11 = 2156, 220 * 11 = 2420 + expect(dmg20Syn.min).toBe(196 * 11) + expect(dmg20Syn.max).toBe(220 * 11) + }) + + it('calculates friendly heal scaling and Prayer synergy', () => { + const h1 = calculateHolyBoltHeal(1) + expect(h1.min).toBe(1) + expect(h1.max).toBe(6) + + const h10Prayer5 = calculateHolyBoltHeal(10, 5) // Prayer lvl 5 = +75% + // Raw min: 1 + 9*2 = 19 -> 19 * 1.75 = 33 + // Raw max: 6 + 9*4 = 42 -> 42 * 1.75 = 73 + expect(h10Prayer5.min).toBe(Math.trunc(19 * 1.75)) + expect(h10Prayer5.max).toBe(Math.trunc(42 * 1.75)) + }) + + it('discriminator: damages Undead, heals friendly ally, and passes through Living Demon', async () => { + const registry = await getSharedDataRegistry() + const engine = new MissileEngine(registry) + + const casterStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + casterStats.setBaseSkillLevel(99, 10) // Prayer synergy + const caster: CombatUnitContext = { + id: 'paladin-holybolt', + name: 'Paladin', + statList: casterStats, + stateBus: new StateBus(casterStats, registry), + } + + // Target 1: Demon (living) + const demonStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE }) + const demon: CombatUnitContext = { + id: 'demon-target', + name: 'Demon', + statList: demonStats, + stateBus: new StateBus(demonStats, registry), + isUndead: false, + isDemon: true, + unitType: 'monster', + } + + // Target 2: Friendly Ally injured + const allyStats = new UnitStatList(registry, { hitpoints: 200 * FIXED_ONE, maxhp: 1000 * FIXED_ONE }) + const ally: CombatUnitContext = { + id: 'ally-target', + name: 'Mercenary', + statList: allyStats, + stateBus: new StateBus(allyStats, registry), + unitType: 'mercenary', + } + + // Target 3: Undead Monster + const undeadStats = new UnitStatList(registry, { hitpoints: 1000 * FIXED_ONE, magicresist: 0 }) + const undead: CombatUnitContext = { + id: 'undead-target', + name: 'Skeleton', + statList: undeadStats, + stateBus: new StateBus(undeadStats, registry), + isUndead: true, + unitType: 'monster', + } + + // 1. Holy Bolt fired at Demon -> Demon must be ignored and bolt continues + const m1 = engine.spawnMissile({ + missileNameOrId: 'holybolt', + sourceSkillId: 101, + slvl: 10, + owner: caster, + startX: 0, + startY: 0, + targetX: 50, + targetY: 0, + dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, + })! + const step1 = engine.tick(1, [demon], new Map([['demon-target', { x: 10, y: 0 }]])) + expect(demonStats.getHp256()).toBe(1000 * FIXED_ONE) // No damage! + expect(m1.expired).toBe(false) // Missile passed through! + + // 2. Holy Bolt fired at Ally -> heals ally and expires + const m2 = engine.spawnMissile({ + missileNameOrId: 'holybolt', + sourceSkillId: 101, + slvl: 10, + owner: caster, + startX: 0, + startY: 0, + targetX: 50, + targetY: 0, + dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, + })! + const step2 = engine.tick(2, [ally], new Map([['ally-target', { x: 10, y: 0 }]])) + expect(allyStats.getHp256()).toBeGreaterThan(200 * FIXED_ONE) // Healed! + expect(m2.expired).toBe(true) // Consumed on heal! + + // 3. Holy Bolt fired at Undead -> damages undead and expires + const m3 = engine.spawnMissile({ + missileNameOrId: 'holybolt', + sourceSkillId: 101, + slvl: 10, + owner: caster, + startX: 0, + startY: 0, + targetX: 50, + targetY: 0, + dmgPacket: { skillId: 101, elemType: 'mag', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE }, + })! + const step3 = engine.tick(3, [undead], new Map([['undead-target', { x: 10, y: 0 }]])) + expect(undeadStats.getHp256()).toBeLessThan(1000 * FIXED_ONE) // Damaged! + expect(m3.expired).toBe(true) // Consumed on impact! + }) + }) + + describe('5. Zeal (106)', () => { + it('calculates swing count formula min(slvl + 1, 5) and %ED scaling', () => { + expect(calculateZealStats(1).hits).toBe(2) + expect(calculateZealStats(2).hits).toBe(3) + expect(calculateZealStats(3).hits).toBe(4) + expect(calculateZealStats(4).hits).toBe(5) + expect(calculateZealStats(20).hits).toBe(5) + + // %ED starts at slvl 5: (slvl - 4) * 6% + expect(calculateZealStats(1).damagePct).toBe(0) + expect(calculateZealStats(4).damagePct).toBe(0) + expect(calculateZealStats(5).damagePct).toBe(6) + expect(calculateZealStats(10, { sacrifice: 10 }).damagePct).toBe((10 - 4) * 6 + 10 * 12) // 36 + 120 = 156% + }) + }) + + describe('6. Charge (107)', () => { + it('calculates 300% movement velocity and Vigor/Might synergy', () => { + const c1 = calculateChargeStats(1) + expect(c1.speedMultiplier).toBe(3.0) + expect(c1.damagePct).toBe(100) + expect(c1.toHitBonusPct).toBe(50) + + const c10 = calculateChargeStats(10, { vigor: 5, might: 5 }) + expect(c10.damagePct).toBe(100 + 9 * 25 + (5 + 5) * 20) // 325 + 200 = 525% + expect(c10.toHitBonusPct).toBe(50 + 9 * 15) // 185% + }) + }) + + describe('7. Vengeance (111)', () => { + it('calculates 3-way elemental percentages and cold chill duration', () => { + const v1 = calculateVengeanceStats(1) + expect(v1.firePct).toBe(70) + expect(v1.coldPct).toBe(70) + expect(v1.ltngPct).toBe(70) + expect(v1.chillDurationFrames).toBe(30) + + // slvl 10 with Resist Fire 10, Salvation 5 + const v10 = calculateVengeanceStats(10, { resistFire: 10, salvation: 5 }) + // Base elem: 70 + 9*6 = 124% + // Fire: 124 + 10*10 + 5*2 = 234% + expect(v10.firePct).toBe(234) + expect(v10.coldPct).toBe(124 + 10) // 134% (salvation only) + expect(v10.chillDurationFrames).toBe(30 + 9 * 15) // 165 frames (6.6s) + }) + + it('evaluates multi-elemental damage packet with independent resistances', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, {}) + const attacker: CombatUnitContext = { + id: 'avenger', + name: 'Paladin', + statList: attackerStats, + stateBus: new StateBus(attackerStats, registry), + } + + // Defender: Immune to Fire (110%), 50% Cold, 0% Lightning, 0% Physical + const defenderStats = new UnitStatList(registry, { + hitpoints: 10000 * FIXED_ONE, + damageresist: 0, + fireresist: 110, + coldresist: 50, + lightresist: 0, + }) + const defender: CombatUnitContext = { + id: 'tri-res-target', + name: 'Monster', + statList: defenderStats, + stateBus: new StateBus(defenderStats, registry), + } + + const out = executeSUnitDmg(attacker, defender, { + skillId: 111, + attackKind: 'melee', + srcDam: 0, + flatPhysMin256: 100 * FIXED_ONE, + flatPhysMax256: 100 * FIXED_ONE, + multiElemPacket: { + firePct: 100, // 100 fire raw -> resisted 110% -> 0 + coldPct: 100, // 100 cold raw -> resisted 50% -> 50 + ltngPct: 100, // 100 ltng raw -> resisted 0% -> 100 + chillDurationFrames: 50, + }, + autoHit: true, + }) + + expect(out.hit).toBe(true) + expect(out.physDamage256).toBe(100 * FIXED_ONE) + // Fire (0) + Cold (50) + Lightning (100) = 150 elemental damage + expect(out.elemDamage256).toBe(150 * FIXED_ONE) + expect(out.totalDamage).toBe(250) + expect(defender.stateBus.hasState('freeze')).toBe(true) + }) + }) + + describe('8. Blessed Hammer (112)', () => { + it('calculates 5-band magic damage, synergies, and Concentration +50% synergy', () => { + const h1 = calculateBlessedHammerDamage(1) + expect(h1.min).toBe(12) + expect(h1.max).toBe(16) + + // Concentration aura +300% ED -> gives +150% ED to Blessed Hammer + const h1Conc = calculateBlessedHammerDamage(1, undefined, 300) + expect(h1Conc.concentrationBonusPct).toBe(150) + expect(h1Conc.min).toBe(Math.trunc(12 * 2.5)) + expect(h1Conc.max).toBe(Math.trunc(16 * 2.5)) + }) + + it('CRITICAL 1.13c Rule: Magic Immune monsters take 0 damage from Blessed Hammer', async () => { + const registry = await getSharedDataRegistry() + const attackerStats = new UnitStatList(registry, {}) + const attacker: CombatUnitContext = { + id: 'hammerdin', + name: 'Paladin', + statList: attackerStats, + stateBus: new StateBus(attackerStats, registry), + } + + // 1.13c Invariant: Undead with magicresist >= 100 is IMMUNE to Blessed Hammer! + const immuneUndeadStats = new UnitStatList(registry, { + maxhp: 5000 * FIXED_ONE, + hitpoints: 5000 * FIXED_ONE, + magicresist: 100, + }) + const immuneUndead: CombatUnitContext = { + id: 'baal-wave-undead', + name: 'Achmel the Cursed Minion', + statList: immuneUndeadStats, + stateBus: new StateBus(immuneUndeadStats, registry), + isUndead: true, + } + + const out = executeSUnitDmg(attacker, immuneUndead, { + skillId: 112, + attackKind: 'spell', + elemType: 'mag', + elemMin256: 500 * FIXED_ONE, + elemMax256: 500 * FIXED_ONE, + autoHit: true, + }) + + expect(out.hit).toBe(true) + expect(out.immuneToElem).toBe(true) + expect(out.totalDamage).toBe(0) + expect(immuneUndeadStats.getHp256()).toBe(5000 * FIXED_ONE) // Zero damage taken! + }) + }) + + describe('9. Conversion (116)', () => { + it('calculates dm34 conversion roll chance and duration', () => { + // dm34 formula: Math.floor((slvl * 50) / (slvl + 10)) + expect(calculateConversionStats(1).chancePct).toBe(Math.floor(50 / 11)) // 4% + expect(calculateConversionStats(10).chancePct).toBe(Math.floor(500 / 20)) // 25% + expect(calculateConversionStats(20).chancePct).toBe(Math.floor(1000 / 30)) // 33% + expect(calculateConversionStats(1).durationFrames).toBe(400) // 16s + }) + }) + + describe('10. Holy Shield (117)', () => { + it('calculates duration, defense bonus, dm56 block chance, and flat Smite damage', () => { + const hs1 = calculateHolyShieldStats(1) + expect(hs1.durationFrames).toBe(750) // 30s + expect(hs1.defensePct).toBe(25) + expect(hs1.blockChancePct).toBe(10 + Math.floor(40 / 11)) // 13% + expect(hs1.smiteMinFlat).toBe(3) + expect(hs1.smiteMaxFlat).toBe(6) + + const hs20Defiance10 = calculateHolyShieldStats(20, 10) + expect(hs20Defiance10.durationFrames).toBe(750 + 19 * 625) // 12,625 frames (~505s) + expect(hs20Defiance10.defensePct).toBe(25 + 19 * 15 + 10 * 15) // 310 + 150 = 460% + expect(hs20Defiance10.blockChancePct).toBe(10 + Math.floor(800 / 30)) // 10 + 26 = 36% + expect(hs20Defiance10.smiteMinFlat).toBe(57) + expect(hs20Defiance10.smiteMaxFlat).toBe(60) + }) + }) + + describe('11. Fist of the Heavens (121)', () => { + it('calculates celestial lightning damage, Holy Shock synergy, and radial Holy Bolt damage', () => { + const foh1 = calculateFistOfTheHeavensDamage(1) + expect(foh1.lightningMin).toBe(150) + expect(foh1.lightningMax).toBe(200) + + // slvl 10 with Holy Shock 10 -> +70% synergy + const foh10Shock10 = calculateFistOfTheHeavensDamage(10, { holyShock: 10 }) + // Base at lvl 10: 150 + 7*15 + 2*30 = 315 min, 200 + 7*15 + 2*30 = 365 max + // With +70% syn: 315 * 1.7 = 535, 365 * 1.7 = 620 + expect(foh10Shock10.lightningMin).toBe(Math.trunc(315 * 1.7)) + expect(foh10Shock10.lightningMax).toBe(Math.trunc(365 * 1.7)) + + // Radial Holy Bolt at lvl 10 with Holy Bolt synergy 10 -> +150% synergy + const hb10 = calculateFistOfTheHeavensHolyBoltDamage(10, 10) + // Base HB min: 84, max: 94 + // With +150% syn: 84 * 2.5 = 210, 94 * 2.5 = 235 + expect(hb10.holyBoltMin).toBe(Math.trunc(84 * 2.5)) + expect(hb10.holyBoltMax).toBe(Math.trunc(94 * 2.5)) + }) + }) +})