Compare commits

...

2 Commits

7 changed files with 1620 additions and 34 deletions

View File

@ -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,15 @@ 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 effectiveSrcDam = 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 =
@ -625,6 +638,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 +833,7 @@ export function executeSUnitDmg(
...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}),
...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}),
...(attackerHealed256 > 0 ? { attackerHealed256 } : {}),
...(selfDamageTaken256 > 0 ? { selfDamageTaken256 } : {}),
...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}),
}
}

View File

@ -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')

View File

@ -5243,7 +5243,7 @@ export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.fr
74: { id: 74, name: 'Corpse Explosion', classCode: 'nec', charClass: 'necromancer', type: 'area', mana: 15, lvlmana: 1, minmana: 1, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
// Paladin (6 skills)
96: { id: 96, name: 'Sacrifice', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
96: { id: 96, name: 'Sacrifice', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 0, lvlmana: 0, minmana: 0, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128, castOverlay: 'blood1' },
97: { id: 97, name: 'Smite', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 0 },
101: { id: 101, name: 'Holy Bolt', classCode: 'pal', charClass: 'paladin', type: 'projectile', mana: 16, lvlmana: 1, minmana: 1, manashift: 5, delay: 0, hitshift: 8, emin: 8, emax: 16, elev1: 4, elev2: 8, elev3: 12, elev4: 16, elev5: 20, srcDamage: 0, range: 450, speed: 450 },
106: { id: 106, name: 'Zeal', classCode: 'pal', charClass: 'paladin', type: 'melee', mana: 2, lvlmana: 0, minmana: 2, manashift: 8, delay: 0, hitshift: 8, emin: 0, emax: 0, elev1: 0, elev2: 0, elev3: 0, elev4: 0, elev5: 0, srcDamage: 128 },
@ -5466,6 +5466,9 @@ export function getSkillCooldownTicks(skillId: number): number {
if (skillId === 64) {
return 25
}
if (skillId === 121) {
return 25
}
const def = getBatch1SkillDef(skillId)
return def?.delay ?? 0
}
@ -6338,3 +6341,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 }
}

View File

@ -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({

View File

@ -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

View File

@ -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<string, { x: number; y: number }>([
['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!
}
})
})

View File

@ -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))
})
})
})