feat(necro): implement Milestone M3 Necromancer Curses Tree (10 skills)

- Implement core curse formulas (radius, duration, debuff stats, immunity breaking)
- Enforce 1.13c ground truth invariants:
  * Amplify Damage (-100% phys res, or -20% on immune)
  * Decrepify (-50% phys res / -10% on immune, -50% speed, -50% attack rate, -50% damage dealt)
  * Lower Resist (-dm56% on fire/lightning/cold/poison, 1/5th penalty on immune)
  * Life Tap (50% physical life leech on hit)
  * Iron Maiden (reflects 200%..675% physical melee damage back to attacker)
  * Weaken (-33% damage dealt)
  * Attract (cursetype = 2, immune to overwrite, cannot recast on attracted unit)
  * Mental curse immunity for Bosses & Champions (Dim Vision, Terror, Confuse, Attract)
  * 2:1 isometric radius targeting and overhead animated overlays
- Add primary verification suite (tests/skills/nec/curses.test.ts)
- Add adversarial challenger stress suite (tests/skills/nec/adv-curses-stress.test.ts)
This commit is contained in:
troytt 2026-09-24 09:21:20 +00:00
parent 8a9970aa31
commit bfc1164d9b
9 changed files with 2042 additions and 18 deletions

View File

@ -42,6 +42,7 @@ import {
calculateFireMasteryBonus, calculateFireMasteryBonus,
calculateEnchantAttackDamage, calculateEnchantAttackDamage,
} from './engine/missile-engine.ts' } from './engine/missile-engine.ts'
import { computeEffectiveResistance } from './engine/combat-pipeline.ts'
export { export {
allocatePacksToRooms, allocatePacksToRooms,
@ -187,10 +188,24 @@ export interface Monster {
chillTicks?: number | undefined chillTicks?: number | undefined
/** Ticks remaining poisoned (damage over time). */ /** Ticks remaining poisoned (damage over time). */
poisonTicks?: number | undefined poisonTicks?: number | undefined
/** Active Necromancer curse debuff state. */
curse?: MonsterCurseState | undefined
/** Whether drops have already been rolled for this monster instance. */ /** Whether drops have already been rolled for this monster instance. */
dropRolled?: boolean | undefined dropRolled?: boolean | undefined
} }
export interface MonsterCurseState {
name: string
slvl: number
curseType: number
ticksRemaining: number
overlayName?: string | undefined
casterX?: number | undefined
casterY?: number | undefined
fleeTarget?: { x: number; y: number } | undefined
returnPct?: number | undefined
}
/** The player's combat-relevant state. */ /** The player's combat-relevant state. */
export interface CombatPlayer { export interface CombatPlayer {
/** World x. */ /** World x. */
@ -756,6 +771,10 @@ export function damageMonster(world: CombatWorld, monsterIndex: number, amount:
monster.hp -= amount monster.hp -= amount
monster.hitFlash = 4 monster.hitFlash = 4
world.events.push({ kind: 'monsterHit', x: monster.x, y: monster.y, amount }) world.events.push({ kind: 'monsterHit', x: monster.x, y: monster.y, amount })
if (monster.curse?.name === 'lifetap' && amount > 0 && attacker) {
const heal = Math.floor(amount * 0.50)
attacker.hp = Math.min(attacker.maxHp ?? 1000, attacker.hp + heal)
}
if (monster.hp > 0) return false if (monster.hp > 0) return false
monster.state = 'dead' monster.state = 'dead'
monster.corpseTicks = 100 monster.corpseTicks = 100
@ -905,7 +924,12 @@ function tickPlayer(
world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
} else { } else {
player.facing = facingOf(target.x - player.x, target.y - player.y) player.facing = facingOf(target.x - player.x, target.y - player.y)
let totalDamage = options.playerDamage const basePhysRes = (target.stats as any).resistances?.physical ?? (target.stats as any).resists?.physical ?? 0
let physPierce = 0
if (target.curse?.name === 'amplifydamage') physPierce = 100
else if (target.curse?.name === 'decrepify') physPierce = 50
const physRes = computeEffectiveResistance({ baseRes: basePhysRes, convictionPierce: physPierce })
let totalDamage = physRes.isImmune ? 0 : Math.max(0, Math.round((options.playerDamage * (100 - physRes.effectiveRes)) / 100))
if (player.enchantTicks && player.enchantTicks > 0 && player.enchantDamage) { if (player.enchantTicks && player.enchantTicks > 0 && player.enchantDamage) {
const enchantRes = calculateEnchantAttackDamage({ const enchantRes = calculateEnchantAttackDamage({
enchantDamage: player.enchantDamage, enchantDamage: player.enchantDamage,
@ -919,6 +943,10 @@ function tickPlayer(
target.hp -= totalDamage target.hp -= totalDamage
target.hitFlash = 4 target.hitFlash = 4
world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: totalDamage }) world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: totalDamage })
if (target.curse?.name === 'lifetap' && totalDamage > 0) {
const heal = Math.floor(totalDamage * 0.50)
player.hp = Math.min(player.maxHp, player.hp + heal)
}
if (target.hp <= 0) { if (target.hp <= 0) {
target.state = 'dead' target.state = 'dead'
target.corpseTicks = 100 target.corpseTicks = 100
@ -978,6 +1006,13 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain
} }
} }
if (monster.curse !== undefined && monster.curse.ticksRemaining > 0) {
monster.curse.ticksRemaining -= 1
if (monster.curse.ticksRemaining <= 0) {
monster.curse = undefined
}
}
if (options.disableMonsterAggro) { if (options.disableMonsterAggro) {
monster.state = 'idle' monster.state = 'idle'
continue continue
@ -988,11 +1023,85 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain
monster.state = 'idle' monster.state = 'idle'
continue continue
} }
// Terror flee behavior: flee directly opposite caster/player
if (monster.curse?.name === 'terror') {
const fleeFromX = monster.curse.casterX ?? player.x
const fleeFromY = monster.curse.casterY ?? player.y
const fdx = monster.x - fleeFromX
const fdy = monster.y - fleeFromY
const flen = Math.hypot(fdx, fdy) || 1
monster.facing = facingOf(fdx, fdy)
const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1
const step = (monster.stats.speed / 25) * chillFactor
const moved = moveWithCollision(monster, (fdx / flen) * step, (fdy / flen) * step, terrain)
monster.x = moved.x
monster.y = moved.y
monster.state = 'chase'
continue
}
const sightRadius = monster.curse?.name === 'dimvision' ? 40 : monster.stats.aggroRadius
// Attract & Confuse target redirection:
let targetMonster: Monster | null = null
let bestTargetDist = sightRadius
for (const other of world.monsters) {
if (other.index === monster.index || other.state === 'dead' || other.hp <= 0) continue
if (other.curse?.name === 'attract') {
const d = Math.hypot(other.x - monster.x, other.y - monster.y)
if (d <= bestTargetDist) {
bestTargetDist = d
targetMonster = other
}
}
}
if (monster.curse?.name === 'confuse' && targetMonster === null) {
for (const other of world.monsters) {
if (other.index === monster.index || other.state === 'dead' || other.hp <= 0) continue
const d = Math.hypot(other.x - monster.x, other.y - monster.y)
if (d <= bestTargetDist) {
bestTargetDist = d
targetMonster = other
}
}
}
if (targetMonster !== null) {
const mdx = targetMonster.x - monster.x
const mdy = targetMonster.y - monster.y
const mdist = Math.hypot(mdx, mdy)
monster.facing = facingOf(mdx, mdy)
if (mdist <= monster.stats.reach) {
monster.state = 'attack'
if (monster.cooldown === 0) {
monster.cooldown = monster.curse?.name === 'decrepify' ? monster.stats.cooldownTicks * 2 : monster.stats.cooldownTicks
let dmg = monster.stats.damage
if (monster.curse?.name === 'weaken') dmg = Math.floor(dmg * 0.67)
if (monster.curse?.name === 'decrepify') dmg = Math.floor(dmg * 0.50)
damageMonster(world, targetMonster.index, dmg, player)
}
continue
}
monster.state = 'chase'
const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1
const decrepFactor = monster.curse?.name === 'decrepify' ? 0.5 : 1
const step = (monster.stats.speed / 25) * chillFactor * decrepFactor
const moved = moveWithCollision(monster, (mdx / mdist) * step, (mdy / mdist) * step, terrain)
monster.x = moved.x
monster.y = moved.y
continue
}
const dx = player.x - monster.x const dx = player.x - monster.x
const dy = player.y - monster.y const dy = player.y - monster.y
const distance = Math.hypot(dx, dy) const distance = Math.hypot(dx, dy)
if (distance > monster.stats.aggroRadius) { if (distance > sightRadius) {
monster.state = 'idle'
continue
}
if (monster.curse?.name === 'dimvision' && monster.stats.reach > 40) {
monster.state = 'idle' monster.state = 'idle'
continue continue
} }
@ -1000,10 +1109,23 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain
if (distance <= monster.stats.reach) { if (distance <= monster.stats.reach) {
monster.state = 'attack' monster.state = 'attack'
if (monster.cooldown === 0) { if (monster.cooldown === 0) {
monster.cooldown = monster.stats.cooldownTicks monster.cooldown = monster.curse?.name === 'decrepify' ? monster.stats.cooldownTicks * 2 : monster.stats.cooldownTicks
let dmg = monster.stats.damage let dmg = monster.stats.damage
if (monster.curse?.name === 'weaken') dmg = Math.floor(dmg * 0.67)
if (monster.curse?.name === 'decrepify') dmg = Math.floor(dmg * 0.50)
const isRanged = monster.stats.reach > 40 const isRanged = monster.stats.reach > 40
// Iron Maiden reflection on melee attack
if (monster.curse?.name === 'ironmaiden' && !isRanged && dmg > 0) {
const returnPct = monster.curse.returnPct ?? (200 + (monster.curse.slvl - 1) * 25)
const physRes = monster.stats.resistances?.physical ?? monster.stats.resists?.physical ?? 0
const rawReflect = Math.floor((dmg * returnPct) / 100)
const effReflect = physRes >= 100 ? 0 : Math.max(0, Math.floor((rawReflect * (100 - physRes)) / 100))
if (effReflect > 0) {
damageMonster(world, monster.index, effReflect, player)
}
}
// Cold Armor: Shiver Armor (Skill 50) proactive melee defense // Cold Armor: Shiver Armor (Skill 50) proactive melee defense
if (!isRanged && player.coldArmorType === 'shiverarmor') { if (!isRanged && player.coldArmorType === 'shiverarmor') {
const slvl = player.coldArmorSlvl ?? 1 const slvl = player.coldArmorSlvl ?? 1
@ -1072,7 +1194,8 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain
} }
monster.state = 'chase' monster.state = 'chase'
const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1 const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1
const step = (monster.stats.speed / 25) * chillFactor const decrepFactor = monster.curse?.name === 'decrepify' ? 0.5 : 1
const step = (monster.stats.speed / 25) * chillFactor * decrepFactor
const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain) const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain)
monster.x = movedMonster.x monster.x = movedMonster.x
monster.y = movedMonster.y monster.y = movedMonster.y

View File

@ -19,6 +19,7 @@
*/ */
import { FIXED_ONE, UnitStatList } from './stat-list.ts' import { FIXED_ONE, UnitStatList } from './stat-list.ts'
import { StateBus } from './state-bus.ts' import { StateBus } from './state-bus.ts'
import { calculateLowerResistReduction, calculateIronMaidenReturnPct } from './missile-engine.ts'
export interface CombatUnitContext { export interface CombatUnitContext {
readonly id: string readonly id: string
@ -35,6 +36,9 @@ export interface CombatUnitContext {
readonly weaponPoisonBitrate256?: number readonly weaponPoisonBitrate256?: number
readonly weaponPoisonFrames?: number readonly weaponPoisonFrames?: number
readonly unitType?: 'player' | 'mercenary' | 'party' | 'party_mercenary' | 'minion' | 'monster' readonly unitType?: 'player' | 'mercenary' | 'party' | 'party_mercenary' | 'minion' | 'monster'
readonly isBoss?: boolean
readonly isChampion?: boolean
readonly rank?: 'normal' | 'champion' | 'unique' | 'minion'
} }
export interface ResistanceResult { export interface ResistanceResult {
@ -319,6 +323,7 @@ export interface SUnitDmgOutcome {
readonly attackerStatesApplied?: readonly string[] readonly attackerStatesApplied?: readonly string[]
readonly attackerColdDamage256?: number readonly attackerColdDamage256?: number
readonly reflectedPhys256?: number readonly reflectedPhys256?: number
readonly attackerHealed256?: number
readonly spawnedMissiles?: readonly string[] readonly spawnedMissiles?: readonly string[]
} }
@ -418,9 +423,14 @@ export function executeSUnitDmg(
const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE
const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128) const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128)
const flatAvg256 = Math.trunc(((packet.flatPhysMin256 ?? 0) + (packet.flatPhysMax256 ?? 0)) / 2) const flatAvg256 = Math.trunc(((packet.flatPhysMin256 ?? 0) + (packet.flatPhysMax256 ?? 0)) / 2)
const dmgPctBonus = attacker.statList.getModifierBonus('damagepercent')
const curseDmgMod =
(dmgPctBonus === 0 && attacker.stateBus.hasState('weaken') ? -33 : 0) +
(dmgPctBonus === 0 && attacker.stateBus.hasState('decrepify') ? -50 : 0)
const edPct = const edPct =
(packet.physDamagePct ?? 0) + (packet.physDamagePct ?? 0) +
attacker.statList.getModifierBonus('damagepercent') + dmgPctBonus +
curseDmgMod +
attacker.statList.getModifierBonus('skill_damage_percent') attacker.statList.getModifierBonus('skill_damage_percent')
rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100)) rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100))
} }
@ -494,9 +504,19 @@ export function executeSUnitDmg(
} }
// 6. Resistances & 1/5 Immunity Breaking // 6. Resistances & 1/5 Immunity Breaking
const ampPierceBonus = defender.statList.getModifierBonus('amplify_phys_pierce')
const curseAmpPierce =
ampPierceBonus > 0
? ampPierceBonus
: defender.stateBus.hasState('amplifydamage')
? 100
: defender.stateBus.hasState('decrepify')
? 50
: 0
const physResRec = computeEffectiveResistance({ const physResRec = computeEffectiveResistance({
baseRes: defender.statList.getAccruedStat('damageresist'), baseRes: defender.statList.getAccruedStat('damageresist'),
convictionPierce: defender.statList.getModifierBonus('amplify_phys_pierce'), convictionPierce: curseAmpPierce,
}) })
const resStatKey = const resStatKey =
@ -513,11 +533,19 @@ export function executeSUnitDmg(
const isElemPiercableByConviction = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' const isElemPiercableByConviction = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold'
const isElemPiercableByLR = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' || elemType === 'pois' const isElemPiercableByLR = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' || elemType === 'pois'
const lrPierceBonus = defender.statList.getModifierBonus('lowerresist_pierce')
const curseLrPierce =
lrPierceBonus > 0
? lrPierceBonus
: defender.stateBus.hasState('lowerresist')
? calculateLowerResistReduction(defender.stateBus.getState('lowerresist')?.slvl ?? 1)
: 0
const convictionPierce = isElemPiercableByConviction const convictionPierce = isElemPiercableByConviction
? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce')) ? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce'))
: 0 : 0
const lowerResistPierce = isElemPiercableByLR const lowerResistPierce = isElemPiercableByLR
? (packet.lowerResistPierce ?? defender.statList.getModifierBonus('lowerresist_pierce')) ? (packet.lowerResistPierce ?? curseLrPierce)
: 0 : 0
const passivePierce = const passivePierce =
packet.passiveEnemyPierce ?? packet.passiveEnemyPierce ??
@ -562,6 +590,15 @@ export function executeSUnitDmg(
? 0 ? 0
: Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100)) : Math.max(0, Math.trunc((remRawElem256 * (100 - elemResRec.effectiveRes)) / 100))
// Life Tap: on physical damage dealt, attacker heals 50% of physical damage dealt
let attackerHealed256 = 0
const hasLifeTap = defender.stateBus.hasState('lifetap') || defender.statList.getModifierBonus('lifetap_leech_pct') > 0
if (hasLifeTap && finalPhys256 > 0) {
attackerHealed256 = Math.trunc(finalPhys256 * 50 / 100)
const curHp = attacker.statList.getHp256()
attacker.statList.setHp256(curHp + attackerHealed256)
}
// Static Field (`skillId = 42`): reduces target HP by 25% mitigated by Lightning Resistance // Static Field (`skillId = 42`): reduces target HP by 25% mitigated by Lightning Resistance
if (packet.skillId === 42 && !elemResRec.isImmune) { if (packet.skillId === 42 && !elemResRec.isImmune) {
const rawStatic256 = Math.trunc((hpBefore256 * 25) / 100) const rawStatic256 = Math.trunc((hpBefore256 * 25) / 100)
@ -606,6 +643,26 @@ export function executeSUnitDmg(
const curHp = attacker.statList.getHp256() const curHp = attacker.statList.getHp256()
attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.reflectedPhys256)) attacker.statList.setHp256(Math.max(0, curHp - reactiveDamage.reflectedPhys256))
} }
// Iron Maiden: reflect physical melee damage back to cursed attacker
const hasIronMaiden = attacker.stateBus.hasState('ironmaiden') || attacker.statList.getModifierBonus('ironmaiden_return_pct') > 0
if (hasIronMaiden && finalPhys256 > 0) {
const imSlvl = attacker.stateBus.getState('ironmaiden')?.slvl ?? 1
const vsPlayer = defender.unitType === 'player' && attacker.unitType === 'player'
const returnPct = attacker.statList.getModifierBonus('ironmaiden_return_pct') || calculateIronMaidenReturnPct(imSlvl, vsPlayer)
const rawReflected256 = Math.trunc((finalPhys256 * returnPct) / 100)
const attackerPhysRes = computeEffectiveResistance({
baseRes: attacker.statList.getAccruedStat('damageresist'),
})
const imReflected256 = attackerPhysRes.isImmune
? 0
: Math.max(0, Math.trunc((rawReflected256 * (100 - attackerPhysRes.effectiveRes)) / 100))
if (imReflected256 > 0) {
reflectedPhys256 += imReflected256
const curHp = attacker.statList.getHp256()
attacker.statList.setHp256(Math.max(0, curHp - imReflected256))
}
}
} else if (attackKind === 'missile') { } else if (attackKind === 'missile') {
const reactiveMissile = defender.stateBus.triggerReactiveEvents({ const reactiveMissile = defender.stateBus.triggerReactiveEvents({
event: 'hitbymissile', event: 'hitbymissile',
@ -638,6 +695,7 @@ export function executeSUnitDmg(
...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}),
...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}),
...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}), ...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}),
...(attackerHealed256 > 0 ? { attackerHealed256 } : {}),
...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}), ...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}),
} }
} }

View File

@ -19,7 +19,7 @@ import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './
import { getBlizzardDropOffset } from './blizzard-table.ts' import { getBlizzardDropOffset } from './blizzard-table.ts'
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts' import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
import { calculateMeteorResidualFireDamage } from '../skills.ts' import { calculateMeteorResidualFireDamage } from '../skills.ts'
import { compute5BandScaling } from './calc-ast.ts' import { compute5BandScaling, computeDiminishingReturns } from './calc-ast.ts'
export const ISO_GROUND_ASPECT_RATIO = 0.5 export const ISO_GROUND_ASPECT_RATIO = 0.5
@ -3634,3 +3634,337 @@ export function calculateBoneSpiritDamage(
return { min, max } return { min, max }
} }
export interface CurseOpts {
readonly vsPlayer?: boolean | undefined
readonly difficulty?: 'normal' | 'nightmare' | 'hell' | undefined
}
export interface CurseSpec {
readonly skillId: number
readonly skillName: string
readonly stateName: string
readonly curseType: number
readonly radiusYards: number
readonly radiusPx: number
readonly durationFrames: number
readonly durationSeconds: number
readonly debuffStats: Readonly<Record<string, number>>
}
/**
* Calculates authentic 1.13c Necromancer curse radius in yards.
* Formula: subtiles * (2 / 3).
*
* Skill 66 (Amplify Damage): 3 + (slvl - 1) * 1 subtiles (2.0y to 14.67y)
* Skill 71 (Dim Vision): 4 + (slvl - 1) * 1 subtiles (2.67y to 15.33y)
* Skill 72 (Weaken): 9 + (slvl - 1) * 1 subtiles (6.0y to 18.67y)
* Skill 76 (Iron Maiden): Fixed 7 subtiles = 4.67y (187 px)
* Skill 77 (Terror): Fixed 4 subtiles = 2.67y (107 px)
* Skill 81 (Confuse): 6 + (slvl - 1) * 1 subtiles (4.0y to 16.67y)
* Skill 82 (Life Tap): 4 + (slvl - 1) * 1 subtiles (2.67y to 15.33y)
* Skill 86 (Attract): Fixed 6.0y single-target search radius (240 px)
* Skill 87 (Decrepify): Fixed 6 subtiles = 4.0y (160 px)
* Skill 91 (Lower Resist): 7 + (slvl - 1) * 1 subtiles (4.67y to 17.33y)
*/
export function calculateCurseRadiusYards(skillId: number, slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let subtiles = 0
switch (skillId) {
case 66: // Amplify Damage
subtiles = 3 + (lvl - 1) * 1
break
case 71: // Dim Vision
subtiles = 4 + (lvl - 1) * 1
break
case 72: // Weaken
subtiles = 9 + (lvl - 1) * 1
break
case 76: // Iron Maiden (fixed 7 subtiles)
return 4.67
case 77: // Terror (fixed 4 subtiles)
return 2.67
case 81: // Confuse
subtiles = 6 + (lvl - 1) * 1
break
case 82: // Life Tap
subtiles = 4 + (lvl - 1) * 1
break
case 86: // Attract (fixed 6.0y)
return 6.0
case 87: // Decrepify (fixed 6 subtiles)
return 4.0
case 91: // Lower Resist
subtiles = 7 + (lvl - 1) * 1
break
default:
return 0
}
return Math.round((subtiles * (2 / 3)) * 100) / 100
}
/**
* Calculates authentic 1.13c Necromancer curse radius in pixels on 2D ground plane (40 px / yard).
*/
export function calculateCurseRadiusPx(skillId: number, slvl: number): number {
return Math.round(calculateCurseRadiusYards(skillId, slvl) * 40)
}
/**
* Calculates authentic 1.13c Necromancer curse duration in frames (25 fps).
*
* Skill 66 (Amplify Damage): 200 + (slvl - 1) * 75 frames (8.0s to 65.0s @ lvl 20)
* Skill 71 (Dim Vision): 175 + (slvl - 1) * 50 frames (reduced by 50% in NM, 75% in Hell)
* Skill 72 (Weaken): 350 + (slvl - 1) * 60 frames (14.0s to 59.6s @ lvl 20)
* Skill 76 (Iron Maiden): 300 + (slvl - 1) * 60 frames (12.0s to 57.6s @ lvl 20)
* Skill 77 (Terror): 200 + (slvl - 1) * 25 frames (8.0s to 27.0s @ lvl 20)
* Skill 81 (Confuse): 250 + (slvl - 1) * 50 frames (10.0s to 48.0s @ lvl 20)
* Skill 82 (Life Tap): 400 + (slvl - 1) * 60 frames (16.0s to 61.6s @ lvl 20)
* Skill 86 (Attract): 300 + (slvl - 1) * 90 frames (12.0s to 80.4s @ lvl 20)
* Skill 87 (Decrepify): 100 + (slvl - 1) * 15 frames (4.0s to 15.4s @ lvl 20)
* Skill 91 (Lower Resist): 500 + (slvl - 1) * 50 frames (20.0s to 58.0s @ lvl 20)
*/
export function calculateCurseDurationFrames(
skillId: number,
slvl: number,
difficulty?: 'normal' | 'nightmare' | 'hell',
): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
let frames = 0
switch (skillId) {
case 66:
frames = 200 + (lvl - 1) * 75
break
case 71: {
const base = 175 + (lvl - 1) * 50
if (difficulty === 'nightmare') {
frames = Math.trunc(base * 0.5)
} else if (difficulty === 'hell') {
frames = Math.trunc(base * 0.25)
} else {
frames = base
}
break
}
case 72:
frames = 350 + (lvl - 1) * 60
break
case 76:
frames = 300 + (lvl - 1) * 60
break
case 77:
frames = 200 + (lvl - 1) * 25
break
case 81:
frames = 250 + (lvl - 1) * 50
break
case 82:
frames = 400 + (lvl - 1) * 60
break
case 86:
frames = 300 + (lvl - 1) * 90
break
case 87:
frames = 100 + (lvl - 1) * 15
break
case 91:
frames = 500 + (lvl - 1) * 50
break
default:
frames = 0
}
return frames
}
/**
* Calculates Amplify Damage debuff values:
* -100% physical resistance (`damageresist = -100`, `amplify_phys_pierce = 100`).
*/
export function calculateAmplifyDamageDebuff(): { damageresist: number; amplify_phys_pierce: number } {
return { damageresist: -100, amplify_phys_pierce: 100 }
}
/**
* Calculates Weaken debuff values:
* -33% damage dealt by affected targets (`damagepercent = -33`).
*/
export function calculateWeakenDebuff(): { damagepercent: number } {
return { damagepercent: -33 }
}
/**
* Calculates Iron Maiden melee reflection percentage:
* 200 + (slvl - 1) * 25 % (slvl 1: 200%, 10: 425%, 20: 675%).
* Divided by 4 in PvP.
*/
export function calculateIronMaidenReturnPct(slvl: number, vsPlayer = false): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const basePct = 200 + (lvl - 1) * 25
return vsPlayer ? Math.floor(basePct / 4) : basePct
}
/**
* Calculates Terror fleeing distance in subtiles:
* 24 + (slvl - 1) * 2 subtiles.
*/
export function calculateTerrorDistance(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return 24 + (lvl - 1) * 2
}
/**
* Calculates Decrepify tri-debuff values:
* -50% walk/run speed, -50% attack speed, -50% damage dealt, -50% physical resistance.
*/
export function calculateDecrepifyDebuff(): {
velocitypercent: number
attackrate: number
damagepercent: number
damageresist: number
amplify_phys_pierce: number
item_slow: number
} {
return {
velocitypercent: -50,
attackrate: -50,
damagepercent: -50,
damageresist: -50,
amplify_phys_pierce: 50,
item_slow: 50,
}
}
/**
* Calculates Lower Resist reduction percentage on Fire, Lightning, Cold, Poison resistances:
* dm56(25, 70): slvl 1: -32%, 10: -55%, 20: -63%.
*/
export function calculateLowerResistReduction(slvl: number): number {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
return computeDiminishingReturns(25, 70, lvl)
}
/**
* Evaluates 1.13c immunity breaking rule:
* When base resistance >= 100% (immune), piercing applies at 1/5th effectiveness (floor(piercePct / 5)).
* Immunity is broken if effective resistance drops below 100%.
*/
export function computeImmunityBreak(
baseRes: number,
piercePct: number,
): { effectiveRes: number; wasImmune: boolean; broken: boolean } {
const bRes = Math.trunc(baseRes)
const rawPierce = Math.max(0, Math.trunc(piercePct))
if (bRes >= 100) {
const wasImmune = true
const appliedPierce = Math.floor(rawPierce / 5)
const effectiveRes = bRes - appliedPierce
const broken = effectiveRes < 100
return { effectiveRes, wasImmune, broken }
}
const wasImmune = false
const effectiveRes = Math.max(-100, bRes - rawPierce)
return { effectiveRes, wasImmune, broken: false }
}
/**
* Calculates comprehensive curse specification for a given skill ID and slvl.
*/
export function calculateCurseEffect(
skillId: number,
slvl: number,
opts: CurseOpts = {},
): CurseSpec {
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
const radiusYards = calculateCurseRadiusYards(skillId, lvl)
const radiusPx = calculateCurseRadiusPx(skillId, lvl)
const durationFrames = calculateCurseDurationFrames(skillId, lvl, opts.difficulty)
const durationSeconds = Math.round((durationFrames / 25) * 10) / 10
let skillName = ''
let stateName = ''
let curseType = 1
let debuffStats: Record<string, number> = {}
switch (skillId) {
case 66:
skillName = 'Amplify Damage'
stateName = 'amplifydamage'
curseType = 1
debuffStats = calculateAmplifyDamageDebuff()
break
case 71:
skillName = 'Dim Vision'
stateName = 'dimvision'
curseType = 1
debuffStats = { sight_radius: 1, stop_ranged: 1 }
break
case 72:
skillName = 'Weaken'
stateName = 'weaken'
curseType = 1
debuffStats = calculateWeakenDebuff()
break
case 76:
skillName = 'Iron Maiden'
stateName = 'ironmaiden'
curseType = 1
debuffStats = { ironmaiden_return_pct: calculateIronMaidenReturnPct(lvl, opts.vsPlayer) }
break
case 77:
skillName = 'Terror'
stateName = 'terror'
curseType = 1
debuffStats = { terror_flee_distance: calculateTerrorDistance(lvl) }
break
case 81:
skillName = 'Confuse'
stateName = 'confuse'
curseType = 1
debuffStats = { confuse_targeting: 1 }
break
case 82:
skillName = 'Life Tap'
stateName = 'lifetap'
curseType = 1
debuffStats = { lifetap_leech_pct: 50 }
break
case 86:
skillName = 'Attract'
stateName = 'attract'
curseType = 2
debuffStats = { attract_target: 1 }
break
case 87:
skillName = 'Decrepify'
stateName = 'decrepify'
curseType = 1
debuffStats = calculateDecrepifyDebuff()
break
case 91: {
skillName = 'Lower Resist'
stateName = 'lowerresist'
curseType = 1
const red = calculateLowerResistReduction(lvl)
debuffStats = {
lowerresist_pierce: red,
fireresist: -red,
lightresist: -red,
coldresist: -red,
poisonresist: -red,
}
break
}
}
return {
skillId,
skillName,
stateName,
curseType,
radiusYards,
radiusPx,
durationFrames,
durationSeconds,
debuffStats,
}
}

View File

@ -128,8 +128,9 @@ export class StateBus {
const isAura = params.isAuraOverride ?? stateRec?.aura ?? false const isAura = params.isAuraOverride ?? stateRec?.aura ?? false
// CRITICAL 1.13c RULE: `STATE_ATTRACT` (`cursetype === 2`) cannot be overwritten by // CRITICAL 1.13c RULE: `STATE_ATTRACT` (`cursetype === 2`) cannot be overwritten by
// any standard `cursetype === 1` curse! // any standard `cursetype === 1` curse! Furthermore, casting Attract on an already
if (curseType === 1) { // Attracted monster cannot reset or duplicate.
if (curseType === 1 || curseType === 2 || stateName === 'attract') {
const existingAttract = this.entries.find(e => e.curseType === 2 || e.stateName === 'attract') const existingAttract = this.entries.find(e => e.curseType === 2 || e.stateName === 'attract')
if (existingAttract) { if (existingAttract) {
return { return {
@ -145,7 +146,7 @@ export class StateBus {
let evictedState: string | undefined let evictedState: string | undefined
// Standard `cursetype === 1` exclusivity: remove any existing `cursetype === 1` state // Standard `cursetype === 1` exclusivity: remove any existing `cursetype === 1` state
if (curseType === 1) { if (curseType === 1 || curseType === 2) {
for (let i = this.entries.length - 1; i >= 0; i -= 1) { for (let i = this.entries.length - 1; i >= 0; i -= 1) {
const prev = this.entries[i]! const prev = this.entries[i]!
if (prev.curseType === 1) { if (prev.curseType === 1) {

View File

@ -109,6 +109,19 @@ import {
calculateBoneSpiritDamage, calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier, calculateBoneSpiritSynergyMultiplier,
spawnPoisonNovaRing, spawnPoisonNovaRing,
calculateCurseRadiusYards,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateAmplifyDamageDebuff,
calculateWeakenDebuff,
calculateIronMaidenReturnPct,
calculateTerrorDistance,
calculateDecrepifyDebuff,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
type CurseOpts,
type CurseSpec,
type DamageRange, type DamageRange,
type PoisonDamageSpec, type PoisonDamageSpec,
type SynergyState, type SynergyState,
@ -195,6 +208,19 @@ export {
calculateBoneSpiritDamage, calculateBoneSpiritDamage,
calculateBoneSpiritSynergyMultiplier, calculateBoneSpiritSynergyMultiplier,
spawnPoisonNovaRing, spawnPoisonNovaRing,
calculateCurseRadiusYards,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateAmplifyDamageDebuff,
calculateWeakenDebuff,
calculateIronMaidenReturnPct,
calculateTerrorDistance,
calculateDecrepifyDebuff,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
type CurseOpts,
type CurseSpec,
type DamageRange, type DamageRange,
type PoisonDamageSpec, type PoisonDamageSpec,
type SynergyState, type SynergyState,
@ -3482,6 +3508,198 @@ export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> =
xOffset: 0, xOffset: 0,
yOffset: 0, yOffset: 0,
}, },
cursecast: {
overlay: 'cursecast',
filename: 'CurseCast',
frames: 25,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 9,
red: 255,
green: 128,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseamplifydamage: {
overlay: 'curseamplifydamage',
filename: 'CurseAmplifyDamageEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursedimvision: {
overlay: 'cursedimvision',
filename: 'CurseDimVisionEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseweaken: {
overlay: 'curseweaken',
filename: 'CurseWeakenEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseironmaiden: {
overlay: 'curseironmaiden',
filename: 'CurseIronMaidenEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseterror: {
overlay: 'curseterror',
filename: 'CurseTerrorEffect',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseconfuse: {
overlay: 'curseconfuse',
filename: 'CurseConfuseEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursereversevampire: {
overlay: 'cursereversevampire',
filename: 'CurseReverseVampireEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
steallife: {
overlay: 'cursereversevampire',
filename: 'CurseReverseVampireEffect',
frames: 19,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curseattract: {
overlay: 'curseattract',
filename: 'CurseAttractHarmEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cursedecrepify: {
overlay: 'cursedecrepify',
filename: 'CurseDecrepifyEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
curselowerresist: {
overlay: 'curselowerresist',
filename: 'CurseLowerResistEffect',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
}) })
/** /**
@ -5231,6 +5449,9 @@ export function calculateSkillDamage(
} }
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId) const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(numericSkillId)) {
return { min: 0, max: 0 }
}
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
if (!def) { if (!def) {
if (weaponDamage) { if (weaponDamage) {

View File

@ -27,6 +27,7 @@ import {
calculateStaticFieldDamage, calculateStaticFieldDamage,
calculateStaticFieldRadiusPx, calculateStaticFieldRadiusPx,
hasLineOfSight, hasLineOfSight,
calculateCurseEffect,
} from '../engine/missile-engine.ts' } from '../engine/missile-engine.ts'
const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>() const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>()
@ -490,7 +491,12 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
// 6. Curses & AoE Debuffs (`srvDoFunc = 6, 30, 47, 59, 61, 71`) // 6. Curses & AoE Debuffs (`srvDoFunc = 6, 30, 47, 59, 61, 71`)
if ([6, 30, 47, 59, 61, 71].includes(effectiveDoFunc)) { if ([6, 30, 47, 59, 61, 71].includes(effectiveDoFunc)) {
const curseEffect = [66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(skill.id)
? calculateCurseEffect(skill.id, evalResult.slvl)
: undefined
const curseState = const curseState =
curseEffect?.stateName ||
skill.auraTgtState || skill.auraTgtState ||
skill.auraState || skill.auraState ||
(skill.id === 86 ? 'attract' : skill.id === 81 ? 'confuse' : skill.name.toLowerCase().replace(/\s+/g, '')) (skill.id === 86 ? 'attract' : skill.id === 81 ? 'confuse' : skill.name.toLowerCase().replace(/\s+/g, ''))
@ -503,24 +509,37 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id), getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id),
getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id), getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id),
} }
const debuffStats: Record<string, number> = {} const debuffStats: Record<string, number> = curseEffect ? { ...curseEffect.debuffStats } : {}
if (skill.id === 66) debuffStats['amplify_phys_pierce'] = 100 if (skill.id === 66 && !debuffStats['amplify_phys_pierce']) debuffStats['amplify_phys_pierce'] = 100
if (skill.id === 87) { if (skill.id === 87 && !debuffStats['amplify_phys_pierce']) {
debuffStats['amplify_phys_pierce'] = 50 debuffStats['amplify_phys_pierce'] = 50
debuffStats['item_slow'] = 50 debuffStats['item_slow'] = 50
} }
if (skill.id === 91) { if (skill.id === 91 && !debuffStats['lowerresist_pierce']) {
debuffStats['lowerresist_pierce'] = evaluateCalc('dm56', calcCtx) debuffStats['lowerresist_pierce'] = evaluateCalc('dm56', calcCtx)
} }
for (const t of targets) { for (const t of targets) {
if (t.statList.getHp256() <= 0) continue if (t.statList.getHp256() <= 0) continue
// 1.13c Rule: Bosses & Champions are IMMUNE to mental curses (Dim Vision 71, Terror 77, Confuse 81, Attract 86)
const isBossOrChamp = Boolean(
t.isBoss ||
t.isChampion ||
t.rank === 'champion' ||
t.rank === 'unique' ||
(t.statList.getAccruedStat('is_boss') > 0)
)
if ([71, 77, 81, 86].includes(skill.id) && isBossOrChamp) {
continue
}
const res = t.stateBus.applyState({ const res = t.stateBus.applyState({
stateNameOrId: curseState, stateNameOrId: curseState,
sourceUnitId: caster.id, sourceUnitId: caster.id,
sourceSkillId: skill.id, sourceSkillId: skill.id,
slvl: evalResult.slvl, slvl: evalResult.slvl,
durationFrames: evalResult.durationFrames || 250, durationFrames: evalResult.durationFrames || (curseEffect ? curseEffect.durationFrames : 250),
currentFrame: currentTick, currentFrame: currentTick,
stats: debuffStats, stats: debuffStats,
curseTypeOverride: skill.id === 86 ? 2 : 1, curseTypeOverride: skill.id === 86 ? 2 : 1,
@ -528,7 +547,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome {
if (res.applied) { if (res.applied) {
statesApplied.push(res.stateName) statesApplied.push(res.stateName)
} }
if (skill.id === 86) break // Attract targets a single monster if (skill.id === 86 && res.applied) break // Attract targets a single monster
} }
return { return {

View File

@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
import { npcDialog } from '../game/quests.ts' import { npcDialog } from '../game/quests.ts'
import type { NpcDef } 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, 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 } 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, 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 } from '../game/skills.ts'
import type { Projectile, ActiveOverlay } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts'
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
import { import {
@ -2106,6 +2106,135 @@ export function castSkill(
return true return true
} }
if ([66, 71, 72, 76, 77, 81, 82, 86, 87, 91].includes(skillId)) {
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(skillId) ?? 1)
const curseEffect = calculateCurseEffect(skillId, slvl)
// Spawn authentic cast overlay 'cursecast' at targeted location
engine.spawnOverlay('cursecast', { x: targetX, y: targetY })
const getCurseOverhead = (id: number): string => {
switch (id) {
case 66: return 'curseamplifydamage'
case 71: return 'cursedimvision'
case 72: return 'curseweaken'
case 76: return 'curseironmaiden'
case 77: return 'curseterror'
case 81: return 'curseconfuse'
case 82: return 'cursereversevampire'
case 86: return 'curseattract'
case 87: return 'cursedecrepify'
case 91: return 'curselowerresist'
default: return 'curseamplifydamage'
}
}
if (skillId === 86) {
// Attract (Skill 86): single-target curse (search radius 6.0y / 240px)
let targetMonster: Monster | null = null
let bestDist = curseEffect.radiusPx // 240 px
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO)
if (dist <= bestDist) {
bestDist = dist
targetMonster = monster
}
}
if (!targetMonster) {
status.textContent = '需要有效的目标!'
return false
}
const isBossOrChamp = Boolean(
(targetMonster.stats as any).isBoss ||
(targetMonster.stats as any).type === 'boss' ||
targetMonster.stats.rank === 'champion' ||
targetMonster.stats.rank === 'unique'
)
if (isBossOrChamp) {
status.textContent = '首领和头目免疫吸引!'
return false
}
if (targetMonster.curse?.curseType === 2 || targetMonster.curse?.name === 'attract') {
status.textContent = '目标已被吸引!'
return false
}
targetMonster.curse = {
name: 'attract',
slvl,
curseType: 2,
ticksRemaining: curseEffect.durationFrames,
overlayName: 'curseattract',
}
engine.spawnOverlay('curseattract', { x: targetMonster.x, y: targetMonster.y }, `monster-${targetMonster.index}`, { loop: true })
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `吸引(持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中目标`
return true
}
// AoE Curses: 66, 71, 72, 76, 77, 81, 82, 87, 91
let affectedCount = 0
const overheadOverlay = getCurseOverhead(skillId)
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' || monster.hp <= 0) continue
const dist = Math.hypot(monster.x - targetX, (monster.y - targetY) / ISO_GROUND_ASPECT_RATIO)
if (dist <= curseEffect.radiusPx) {
// 1.13c Rule: Attract (cursetype = 2) CANNOT be overwritten by any curse!
if (monster.curse?.curseType === 2 || monster.curse?.name === 'attract') {
continue
}
// 1.13c Rule: Bosses & Champions are IMMUNE to mental curses (Dim Vision 71, Terror 77, Confuse 81)
const isBossOrChamp = Boolean(
(monster.stats as any).isBoss ||
(monster.stats as any).type === 'boss' ||
monster.stats.rank === 'champion' ||
monster.stats.rank === 'unique'
)
if ([71, 77, 81].includes(skillId) && isBossOrChamp) {
continue
}
monster.curse = {
name: curseEffect.stateName,
slvl,
curseType: 1,
ticksRemaining: curseEffect.durationFrames,
overlayName: overheadOverlay,
casterX: player.x,
casterY: player.y,
returnPct: skillId === 76 ? calculateIronMaidenReturnPct(slvl) : undefined,
}
if (skillId === 77) {
const fleeDx = monster.x - player.x
const fleeDy = monster.y - player.y
const fleeLen = Math.hypot(fleeDx, fleeDy) || 1
const fleeDistPx = calculateTerrorDistance(slvl) * (40 * (2 / 3))
monster.curse.fleeTarget = {
x: monster.x + (fleeDx / fleeLen) * fleeDistPx,
y: monster.y + (fleeDy / fleeLen) * fleeDistPx,
}
}
engine.spawnOverlay(overheadOverlay, { x: monster.x, y: monster.y }, `monster-${monster.index}`, { loop: true })
affectedCount++
}
}
player.cooldown = 0
engine.metrics.casts += 1
status.textContent = `${curseEffect.skillName}(范围 ${(curseEffect.radiusPx / 40).toFixed(1)} 码,持续 ${curseEffect.durationSeconds.toFixed(1)} 秒)命中 ${affectedCount} 个目标`
return true
}
// Projectile Skills // Projectile Skills
let speed = 450 let speed = 450
let range = 450 let range = 450

View File

@ -0,0 +1,502 @@
/**
* Adversarial Challenger Stress & Boundary Suite: Necromancer Curses Tree
* Milestone M3: Skills 66, 71, 72, 76, 77, 81, 82, 86, 87, 91 (1.13c Ground Truth)
*
* Verifies:
* 1. High-density multi-monster curse overwrite cascades (50 monsters in 2:1 isometric ellipses).
* 2. Multi-curse rapid alternating application (Amp -> Decrep -> Weaken -> Lower Resist) across 100 cycles with 0 stat leakage.
* 3. Attract inviolability under 100 continuous attempts of competing curses.
* 4. Borderline physical immunity threshold transitions (99%, 100%, 109%, 110%, 119%, 120%, 130%).
* 5. Iron Maiden lethal melee reflection scenarios against high-damage and physical-immune monsters.
* 6. Difficulty duration scaling under Nightmare (50%) and Hell (25%) for Dim Vision.
* 7. Boss and Champion mental curse rejection in ActScene (Dim Vision, Terror, Confuse, Attract).
*/
import { describe, expect, it, beforeEach } from 'vitest'
import {
ISO_GROUND_ASPECT_RATIO,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateIronMaidenReturnPct,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
} from '../../../src/game/skills.ts'
import {
computeEffectiveResistance,
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 { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { GameEngine, type GameEngineOptions } from '../../../src/game/engine.ts'
import { castSkill } from '../../../src/scene/act-scene.ts'
import type { Monster, CombatTerrain } from '../../../src/game/combat.ts'
describe('Adversarial Challenger: Necromancer Curses Stress & Boundary Suite', () => {
let engine: GameEngine
let statusElem: HTMLElement
const dummyRuntime: any = { map: null }
const dummyTerrain: any = { widthPx: 1000, heightPx: 1000, overlap: () => 0 }
beforeEach(() => {
const dummyOpts: GameEngineOptions = {
spawn: { x: 200, y: 200 },
stats: [],
xpTable: [0, 100],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
monsterCount: 0,
combatOptions: {
playerSpeed: 100,
playerReach: 50,
playerCooldownTicks: 0,
playerDamage: 25,
playerManaPerAttack: 0,
respawnTicks: 100,
},
talkRadius: 80,
pickupRadius: 40,
inventoryCols: 10,
inventoryRows: 4,
}
engine = new GameEngine(dummyTerrain, dummyOpts)
engine.world.player.x = 200
engine.world.player.y = 200
engine.world.player.cooldown = 0
engine.world.player.mana = 1000
statusElem = { textContent: '' } as any
})
// =========================================================================
// Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Applications
// =========================================================================
describe('Challenge 1: High-Density 50-Monster 2:1 Isometric AoE Curse Targeting', () => {
it('afflicts exactly monsters inside the 2:1 ground ellipse and excludes those outside', () => {
const radiusPx = calculateCurseRadiusPx(66, 1) // slvl 1 Amplify Damage: 80 px
const monsters: Monster[] = []
// Generate 50 monsters in radial bands
for (let i = 0; i < 50; i++) {
// Vary distance from 20 px to 160 px
const dist = 20 + i * 2.8
const angle = (i * 0.45)
const mx = 200 + Math.cos(angle) * dist
const my = 200 + Math.sin(angle) * (dist * ISO_GROUND_ASPECT_RATIO)
monsters.push({
index: i,
x: mx,
y: my,
hp: 500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: `fallen_${i}`, name: 'Fallen', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5 },
})
}
engine.world.monsters = monsters
// Cast Amplify Damage (66) at (200, 200)
const castSuccess = castSkill(66, 200, 200, {
engine,
runtime: dummyRuntime,
hudManager: null,
status: statusElem,
})
expect(castSuccess).toBe(true)
// Verify each monster's curse state against 2:1 ground geometry
let afflictedCount = 0
for (const m of monsters) {
const isoDist = Math.hypot(m.x - 200, (m.y - 200) / ISO_GROUND_ASPECT_RATIO)
if (isoDist <= radiusPx) {
expect(m.curse?.name).toBe('amplifydamage')
expect(m.curse?.curseType).toBe(1)
afflictedCount++
} else {
expect(m.curse).toBeUndefined()
}
}
expect(afflictedCount).toBeGreaterThan(15)
expect(afflictedCount).toBeLessThan(50)
})
})
// =========================================================================
// Challenge 2: Rapid Alternating Multi-Curse Overwrites (100 Cycles)
// =========================================================================
describe('Challenge 2: 100 Cycles of Rapid Alternating Curse Overwrites with Zero Stat Leakage', () => {
it('alternates Amp -> Decrep -> Weaken -> Lower Resist 100 times without residual modifier accumulation', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
for (let cycle = 0; cycle < 100; cycle++) {
// Step 1: Amplify Damage
const r1 = bus.applyState({
stateNameOrId: 'amplifydamage',
slvl: 20,
stats: { damageresist: -100, amplify_phys_pierce: 100 },
curseTypeOverride: 1,
})
expect(r1.applied).toBe(true)
expect(bus.hasState('amplifydamage')).toBe(true)
expect(stats.getModifierBonus('damageresist')).toBe(-100)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(100)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
// Step 2: Decrepify
const r2 = bus.applyState({
stateNameOrId: 'decrepify',
slvl: 20,
stats: { damageresist: -50, velocitypercent: -50, damagepercent: -50 },
curseTypeOverride: 1,
})
expect(r2.applied).toBe(true)
expect(bus.hasState('decrepify')).toBe(true)
expect(bus.hasState('amplifydamage')).toBe(false)
expect(stats.getModifierBonus('damageresist')).toBe(-50)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(-50)
expect(stats.getModifierBonus('damagepercent')).toBe(-50)
// Step 3: Weaken
const r3 = bus.applyState({
stateNameOrId: 'weaken',
slvl: 20,
stats: { damagepercent: -33 },
curseTypeOverride: 1,
})
expect(r3.applied).toBe(true)
expect(bus.hasState('weaken')).toBe(true)
expect(bus.hasState('decrepify')).toBe(false)
expect(stats.getModifierBonus('damageresist')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
expect(stats.getModifierBonus('damagepercent')).toBe(-33)
// Step 4: Lower Resist
const r4 = bus.applyState({
stateNameOrId: 'lowerresist',
slvl: 20,
stats: { lowerresist_pierce: 63, fireresist: -63, lightresist: -63, coldresist: -63, poisonresist: -63 },
curseTypeOverride: 1,
})
expect(r4.applied).toBe(true)
expect(bus.hasState('lowerresist')).toBe(true)
expect(bus.hasState('weaken')).toBe(false)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
expect(stats.getModifierBonus('fireresist')).toBe(-63)
}
// Final audit: only Lower Resist remains
expect(bus.getActiveEntries().length).toBe(1)
expect(bus.hasState('lowerresist')).toBe(true)
expect(stats.getModifierBonus('lowerresist_pierce')).toBe(63)
expect(stats.getModifierBonus('damageresist')).toBe(0)
expect(stats.getModifierBonus('amplify_phys_pierce')).toBe(0)
expect(stats.getModifierBonus('damagepercent')).toBe(0)
expect(stats.getModifierBonus('velocitypercent')).toBe(0)
})
})
// =========================================================================
// Challenge 3: Attract Inviolability Under 100 Competing Attacks
// =========================================================================
describe('Challenge 3: Attract Inviolability Under 100 Competing Curse Attempts', () => {
it('defends Attract from 100 continuous attempts by competing curses and duplicates', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
// Apply initial Attract
const initAttract = bus.applyState({
stateNameOrId: 'attract',
slvl: 10,
curseTypeOverride: 2,
durationFrames: 1110,
})
expect(initAttract.applied).toBe(true)
const competingCurses = [
'amplifydamage',
'decrepify',
'weaken',
'ironmaiden',
'terror',
'confuse',
'lifetap',
'lowerresist',
'dimvision',
'attract',
]
for (let i = 0; i < 100; i++) {
const curseToTest = competingCurses[i % competingCurses.length]!
const result = bus.applyState({
stateNameOrId: curseToTest,
slvl: 20,
curseTypeOverride: curseToTest === 'attract' ? 2 : 1,
})
expect(result.applied).toBe(false)
expect(result.blockedByAttract).toBe(true)
expect(bus.hasState('attract')).toBe(true)
}
// Attract retains original slvl
const activeAttract = bus.getState('attract')
expect(activeAttract).toBeDefined()
expect(activeAttract?.slvl).toBe(10)
expect(activeAttract?.durationFrames).toBe(1110)
})
})
// =========================================================================
// Challenge 4: Borderline Physical Immunity Threshold Transitions
// =========================================================================
describe('Challenge 4: Borderline Physical Immunity Threshold Transitions', () => {
it('verifies exact threshold boundaries under Amplify Damage (-100% / -20% on immune)', () => {
// 99%: non-immune -> 99 - 100 = -1% (not immune, full pierce)
const b99 = computeImmunityBreak(99, 100)
expect(b99.wasImmune).toBe(false)
expect(b99.effectiveRes).toBe(-1)
expect(b99.broken).toBe(false)
// 100%: immune -> 100 - 20 = 80% (broken!)
const b100 = computeImmunityBreak(100, 100)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(80)
expect(b100.broken).toBe(true)
// 109%: immune -> 109 - 20 = 89% (broken!)
const b109 = computeImmunityBreak(109, 100)
expect(b109.effectiveRes).toBe(89)
expect(b109.broken).toBe(true)
// 110%: immune -> 110 - 20 = 90% (broken!)
const b110 = computeImmunityBreak(110, 100)
expect(b110.effectiveRes).toBe(90)
expect(b110.broken).toBe(true)
// 119%: immune -> 119 - 20 = 99% (broken!)
const b119 = computeImmunityBreak(119, 100)
expect(b119.effectiveRes).toBe(99)
expect(b119.broken).toBe(true)
// 120%: immune -> 120 - 20 = 100% (remains immune!)
const b120 = computeImmunityBreak(120, 100)
expect(b120.wasImmune).toBe(true)
expect(b120.effectiveRes).toBe(100)
expect(b120.broken).toBe(false)
// 130%: immune -> 130 - 20 = 110% (remains immune!)
const b130 = computeImmunityBreak(130, 100)
expect(b130.wasImmune).toBe(true)
expect(b130.effectiveRes).toBe(110)
expect(b130.broken).toBe(false)
})
it('verifies exact threshold boundaries under Decrepify (-50% / -10% on immune)', () => {
// 99%: non-immune -> 99 - 50 = 49%
const b99 = computeImmunityBreak(99, 50)
expect(b99.wasImmune).toBe(false)
expect(b99.effectiveRes).toBe(49)
// 100%: immune -> 100 - 10 = 90% (broken!)
const b100 = computeImmunityBreak(100, 50)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(90)
expect(b100.broken).toBe(true)
// 109%: immune -> 109 - 10 = 99% (broken!)
const b109 = computeImmunityBreak(109, 50)
expect(b109.effectiveRes).toBe(99)
expect(b109.broken).toBe(true)
// 110%: immune -> 110 - 10 = 100% (remains immune!)
const b110 = computeImmunityBreak(110, 50)
expect(b110.effectiveRes).toBe(100)
expect(b110.broken).toBe(false)
})
})
// =========================================================================
// Challenge 5: Iron Maiden Lethal Reflection Scenarios
// =========================================================================
describe('Challenge 5: Iron Maiden Lethal Melee Reflection Scenarios', () => {
it('kills a high-damage monster on melee attack via lethal reflected damage', () => {
const monsterStats = new UnitStatList()
monsterStats.setHp256(500 * FIXED_ONE)
const monsterBus = new StateBus(monsterStats)
monsterBus.applyState({
stateNameOrId: 'ironmaiden',
slvl: 20, // 675% reflection
curseTypeOverride: 1,
})
const attacker: CombatUnitContext = {
id: 'lethal_monster',
name: 'Moon Lord',
statList: monsterStats,
stateBus: monsterBus,
unitType: 'monster',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(2000 * FIXED_ONE)
const defenderBus = new StateBus(defenderStats)
const defender: CombatUnitContext = {
id: 'hero',
name: 'Paladin',
statList: defenderStats,
stateBus: defenderBus,
unitType: 'player',
}
// Moon Lord attacks Paladin for 200 physical melee damage
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 200 * FIXED_ONE,
flatPhysMax256: 200 * FIXED_ONE,
autoHit: true,
})
// Raw reflection: 200 * 6.75 = 1350 damage!
// Attacker had 500 HP, so HP drops to 0!
expect(outcome.reflectedPhys256).toBe(1350 * FIXED_ONE)
expect(monsterStats.getHp256()).toBe(0)
})
it('inflicts 0 reflected damage on a 100% physical immune monster', () => {
const immuneMonsterStats = new UnitStatList()
immuneMonsterStats.setHp256(1000 * FIXED_ONE)
immuneMonsterStats.addStat('damageresist', 100) // Physical Immune!
const monsterBus = new StateBus(immuneMonsterStats)
monsterBus.applyState({
stateNameOrId: 'ironmaiden',
slvl: 20,
curseTypeOverride: 1,
})
const attacker: CombatUnitContext = {
id: 'ghost',
name: 'Ghost',
statList: immuneMonsterStats,
stateBus: monsterBus,
unitType: 'monster',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(2000 * FIXED_ONE)
const defender: CombatUnitContext = {
id: 'hero',
name: 'Paladin',
statList: defenderStats,
stateBus: new StateBus(defenderStats),
unitType: 'player',
}
// Ghost attacks Paladin
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
autoHit: true,
})
// Since ghost is 100% physical immune, reflected damage is 0!
expect(outcome.reflectedPhys256 ?? 0).toBe(0)
expect(immuneMonsterStats.getHp256()).toBe(1000 * FIXED_ONE)
})
})
// =========================================================================
// Challenge 6: Boss and Champion Mental Curse Immunity in ActScene
// =========================================================================
describe('Challenge 6: Boss and Champion Mental Curse Immunity in ActScene', () => {
it('afflicts normal monsters but skips bosses and champions for Dim Vision, Terror, Confuse, Attract', () => {
const normalMonster: Monster = {
index: 0,
x: 210,
y: 200,
hp: 500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie_normal', name: 'Zombie', hp: 500, damage: 10, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 5, rank: 'normal' },
}
const champMonster: Monster = {
index: 1,
x: 215,
y: 200,
hp: 1500,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'zombie_champ', name: 'Zombie Champion', hp: 1500, damage: 20, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 20, rank: 'champion' },
}
const bossMonster: Monster = {
index: 2,
x: 220,
y: 200,
hp: 5000,
cooldown: 0,
hitFlash: 0,
corpseTicks: 0,
state: 'idle',
facing: 0,
stats: { id: 'andariel', name: 'Andariel', hp: 5000, damage: 50, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 100, rank: 'unique', type: 'boss', isBoss: true } as any,
}
engine.world.monsters = [normalMonster, champMonster, bossMonster]
// 1. Cast Dim Vision (71)
castSkill(71, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('dimvision')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 2. Cast Terror (77)
castSkill(77, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('terror')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 3. Cast Confuse (81)
castSkill(81, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(normalMonster.curse?.name).toBe('confuse')
expect(champMonster.curse).toBeUndefined()
expect(bossMonster.curse).toBeUndefined()
// 4. Attempt to cast Attract (86) on the boss
const attractBoss = castSkill(86, 220, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(attractBoss).toBe(false)
expect(bossMonster.curse).toBeUndefined()
// 5. Cast Decrepify (87) — Decrepify affects Act Bosses in Diablo II!
const decrepSuccess = castSkill(87, 200, 200, { engine, runtime: dummyRuntime, hudManager: null, status: statusElem })
expect(decrepSuccess).toBe(true)
expect(normalMonster.curse?.name).toBe('decrepify')
expect(champMonster.curse?.name).toBe('decrepify')
expect(bossMonster.curse?.name).toBe('decrepify') // Boss IS affected by Decrepify!
})
})
})

View File

@ -0,0 +1,637 @@
/**
* Diablo II: Lord of Destruction v1.13c — Necromancer Curses Tree Verification Suite
* Milestone M3: Skills 66, 71, 72, 76, 77, 81, 82, 86, 87, 91
*
* Verifies:
* 1. Architecture invariants:
* - BATCH1_SKILLS length strictly 38.
* - ISO_GROUND_ASPECT_RATIO = 0.5.
* - Decoupled standalone curse formulas.
* - calculateSkillDamage returns { min: 0, max: 0 } for all 10 curses.
* 2. Exact 1.13c radius formulas (yards and pixels) at slvl 1, 10, 20.
* 3. Exact 1.13c duration formulas (frames and seconds) at slvl 1, 10, 20 with difficulty scaling.
* 4. Physical immunity breaking with Amplify Damage (100% -> 80%, 110% -> 90%, 120% -> 100%).
* 5. Physical immunity breaking with Decrepify (100% -> 90%, 105% -> 95%, 110% -> 100%).
* 6. Elemental immunity breaking with Lower Resist (-dm56% with 1/5th penalty on immune).
* 7. Attract immunity to overwrite: Amplify Damage, Decrepify, Weaken cannot replace Attract.
* 8. Standard curse mutual exclusivity: Amplify Damage overwrites Weaken/Decrepify.
* 9. Life Tap 50% physical life leech on hit.
* 10. Iron Maiden physical melee damage reflection scaling (200% to 675%).
* 11. Weaken -33% damage reduction.
* 12. Decrepify tri-debuff (-50% speed, -50% attack rate, -50% damage, -50% phys res).
* 13. Dim Vision sight reduction and boss immunity.
* 14. Terror monster fleeing distance and boss immunity.
* 15. Confuse target chaos and boss immunity.
*/
import { describe, expect, it } from 'vitest'
import {
BATCH1_SKILLS,
calculateSkillDamage,
ISO_GROUND_ASPECT_RATIO,
calculateCurseRadiusYards,
calculateCurseRadiusPx,
calculateCurseDurationFrames,
calculateAmplifyDamageDebuff,
calculateWeakenDebuff,
calculateIronMaidenReturnPct,
calculateTerrorDistance,
calculateDecrepifyDebuff,
calculateLowerResistReduction,
computeImmunityBreak,
calculateCurseEffect,
} from '../../../src/game/skills.ts'
import {
computeEffectiveResistance,
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 { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
describe('Milestone M3: Necromancer Curses Tree (1.13c Ground Truth)', () => {
// =========================================================================
// 1. Architecture Invariants
// =========================================================================
describe('1. Architecture Invariants', () => {
it('preserves BATCH1_SKILLS length strictly at 38 skills', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('enforces ISO_GROUND_ASPECT_RATIO === 0.5', () => {
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
})
it('returns { min: 0, max: 0 } from calculateSkillDamage for all 10 curses', () => {
const curseIds = [66, 71, 72, 76, 77, 81, 82, 86, 87, 91]
for (const id of curseIds) {
const dmg = calculateSkillDamage(id, 20)
expect(dmg).toEqual({ min: 0, max: 0 })
}
})
})
// =========================================================================
// 2. Exact Radius Formulas (Yards & Pixels)
// =========================================================================
describe('2. Exact Radius Formulas (Yards & Pixels)', () => {
it('calculates Amplify Damage (66) radius: 3 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(66, 1)).toBe(2.0)
expect(calculateCurseRadiusPx(66, 1)).toBe(80)
expect(calculateCurseRadiusYards(66, 10)).toBe(8.0)
expect(calculateCurseRadiusPx(66, 10)).toBe(320)
expect(calculateCurseRadiusYards(66, 20)).toBe(14.67)
expect(calculateCurseRadiusPx(66, 20)).toBe(587)
})
it('calculates Dim Vision (71) radius: 4 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(71, 1)).toBe(2.67)
expect(calculateCurseRadiusPx(71, 1)).toBe(107)
expect(calculateCurseRadiusYards(71, 10)).toBe(8.67)
expect(calculateCurseRadiusPx(71, 10)).toBe(347)
expect(calculateCurseRadiusYards(71, 20)).toBe(15.33)
expect(calculateCurseRadiusPx(71, 20)).toBe(613)
})
it('calculates Weaken (72) radius: 9 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(72, 1)).toBe(6.0)
expect(calculateCurseRadiusPx(72, 1)).toBe(240)
expect(calculateCurseRadiusYards(72, 10)).toBe(12.0)
expect(calculateCurseRadiusPx(72, 10)).toBe(480)
expect(calculateCurseRadiusYards(72, 20)).toBe(18.67)
expect(calculateCurseRadiusPx(72, 20)).toBe(747)
})
it('calculates Iron Maiden (76) fixed radius: 7 * 2/3 = 4.67y (187 px)', () => {
expect(calculateCurseRadiusYards(76, 1)).toBe(4.67)
expect(calculateCurseRadiusPx(76, 1)).toBe(187)
expect(calculateCurseRadiusYards(76, 10)).toBe(4.67)
expect(calculateCurseRadiusPx(76, 10)).toBe(187)
expect(calculateCurseRadiusYards(76, 20)).toBe(4.67)
expect(calculateCurseRadiusPx(76, 20)).toBe(187)
})
it('calculates Terror (77) fixed radius: 4 * 2/3 = 2.67y (107 px)', () => {
expect(calculateCurseRadiusYards(77, 1)).toBe(2.67)
expect(calculateCurseRadiusPx(77, 1)).toBe(107)
expect(calculateCurseRadiusYards(77, 10)).toBe(2.67)
expect(calculateCurseRadiusPx(77, 10)).toBe(107)
expect(calculateCurseRadiusYards(77, 20)).toBe(2.67)
expect(calculateCurseRadiusPx(77, 20)).toBe(107)
})
it('calculates Confuse (81) radius: 6 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(81, 1)).toBe(4.0)
expect(calculateCurseRadiusPx(81, 1)).toBe(160)
expect(calculateCurseRadiusYards(81, 10)).toBe(10.0)
expect(calculateCurseRadiusPx(81, 10)).toBe(400)
expect(calculateCurseRadiusYards(81, 20)).toBe(16.67)
expect(calculateCurseRadiusPx(81, 20)).toBe(667)
})
it('calculates Life Tap (82) radius: 4 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(82, 1)).toBe(2.67)
expect(calculateCurseRadiusPx(82, 1)).toBe(107)
expect(calculateCurseRadiusYards(82, 10)).toBe(8.67)
expect(calculateCurseRadiusPx(82, 10)).toBe(347)
expect(calculateCurseRadiusYards(82, 20)).toBe(15.33)
expect(calculateCurseRadiusPx(82, 20)).toBe(613)
})
it('calculates Attract (86) fixed search radius: 6.0y (240 px)', () => {
expect(calculateCurseRadiusYards(86, 1)).toBe(6.0)
expect(calculateCurseRadiusPx(86, 1)).toBe(240)
expect(calculateCurseRadiusYards(86, 10)).toBe(6.0)
expect(calculateCurseRadiusPx(86, 10)).toBe(240)
expect(calculateCurseRadiusYards(86, 20)).toBe(6.0)
expect(calculateCurseRadiusPx(86, 20)).toBe(240)
})
it('calculates Decrepify (87) fixed radius: 6 * 2/3 = 4.0y (160 px)', () => {
expect(calculateCurseRadiusYards(87, 1)).toBe(4.0)
expect(calculateCurseRadiusPx(87, 1)).toBe(160)
expect(calculateCurseRadiusYards(87, 10)).toBe(4.0)
expect(calculateCurseRadiusPx(87, 10)).toBe(160)
expect(calculateCurseRadiusYards(87, 20)).toBe(4.0)
expect(calculateCurseRadiusPx(87, 20)).toBe(160)
})
it('calculates Lower Resist (91) radius: 7 + (slvl - 1) * 1 subtiles * 2/3', () => {
expect(calculateCurseRadiusYards(91, 1)).toBe(4.67)
expect(calculateCurseRadiusPx(91, 1)).toBe(187)
expect(calculateCurseRadiusYards(91, 10)).toBe(10.67)
expect(calculateCurseRadiusPx(91, 10)).toBe(427)
expect(calculateCurseRadiusYards(91, 20)).toBe(17.33)
expect(calculateCurseRadiusPx(91, 20)).toBe(693)
})
})
// =========================================================================
// 3. Exact Duration Formulas (Frames & Seconds)
// =========================================================================
describe('3. Exact Duration Formulas (Frames & Seconds)', () => {
it('calculates exact curse durations across all 10 curses at slvl 1, 10, 20', () => {
// 66 Amplify Damage: 200 + (slvl-1)*75
expect(calculateCurseDurationFrames(66, 1)).toBe(200) // 8.0s
expect(calculateCurseDurationFrames(66, 10)).toBe(875) // 35.0s
expect(calculateCurseDurationFrames(66, 20)).toBe(1625) // 65.0s
// 71 Dim Vision: 175 + (slvl-1)*50
expect(calculateCurseDurationFrames(71, 1)).toBe(175) // 7.0s
expect(calculateCurseDurationFrames(71, 10)).toBe(625) // 25.0s
expect(calculateCurseDurationFrames(71, 20)).toBe(1125) // 45.0s
// 72 Weaken: 350 + (slvl-1)*60
expect(calculateCurseDurationFrames(72, 1)).toBe(350) // 14.0s
expect(calculateCurseDurationFrames(72, 10)).toBe(890) // 35.6s
expect(calculateCurseDurationFrames(72, 20)).toBe(1490) // 59.6s
// 76 Iron Maiden: 300 + (slvl-1)*60
expect(calculateCurseDurationFrames(76, 1)).toBe(300) // 12.0s
expect(calculateCurseDurationFrames(76, 10)).toBe(840) // 33.6s
expect(calculateCurseDurationFrames(76, 20)).toBe(1440) // 57.6s
// 77 Terror: 200 + (slvl-1)*25
expect(calculateCurseDurationFrames(77, 1)).toBe(200) // 8.0s
expect(calculateCurseDurationFrames(77, 10)).toBe(425) // 17.0s
expect(calculateCurseDurationFrames(77, 20)).toBe(675) // 27.0s
// 81 Confuse: 250 + (slvl-1)*50
expect(calculateCurseDurationFrames(81, 1)).toBe(250) // 10.0s
expect(calculateCurseDurationFrames(81, 10)).toBe(700) // 28.0s
expect(calculateCurseDurationFrames(81, 20)).toBe(1200) // 48.0s
// 82 Life Tap: 400 + (slvl-1)*60
expect(calculateCurseDurationFrames(82, 1)).toBe(400) // 16.0s
expect(calculateCurseDurationFrames(82, 10)).toBe(940) // 37.6s
expect(calculateCurseDurationFrames(82, 20)).toBe(1540) // 61.6s
// 86 Attract: 300 + (slvl-1)*90
expect(calculateCurseDurationFrames(86, 1)).toBe(300) // 12.0s
expect(calculateCurseDurationFrames(86, 10)).toBe(1110) // 44.4s
expect(calculateCurseDurationFrames(86, 20)).toBe(2010) // 80.4s
// 87 Decrepify: 100 + (slvl-1)*15
expect(calculateCurseDurationFrames(87, 1)).toBe(100) // 4.0s
expect(calculateCurseDurationFrames(87, 10)).toBe(235) // 9.4s
expect(calculateCurseDurationFrames(87, 20)).toBe(385) // 15.4s
// 91 Lower Resist: 500 + (slvl-1)*50
expect(calculateCurseDurationFrames(91, 1)).toBe(500) // 20.0s
expect(calculateCurseDurationFrames(91, 10)).toBe(950) // 38.0s
expect(calculateCurseDurationFrames(91, 20)).toBe(1450) // 58.0s
})
it('scales Dim Vision duration by difficulty (50% NM, 25% Hell)', () => {
// Normal: 175f / 1125f
expect(calculateCurseDurationFrames(71, 1, 'normal')).toBe(175)
expect(calculateCurseDurationFrames(71, 20, 'normal')).toBe(1125)
// Nightmare: 50%
expect(calculateCurseDurationFrames(71, 1, 'nightmare')).toBe(87)
expect(calculateCurseDurationFrames(71, 20, 'nightmare')).toBe(562)
// Hell: 25%
expect(calculateCurseDurationFrames(71, 1, 'hell')).toBe(43)
expect(calculateCurseDurationFrames(71, 20, 'hell')).toBe(281)
})
})
// =========================================================================
// 4. Physical Immunity Breaking with Amplify Damage
// =========================================================================
describe('4. Physical Immunity Breaking with Amplify Damage', () => {
it('applies 1/5th penalty on immune monster (100% -> 80%, 110% -> 90%, 120% -> 100%)', () => {
// 100% phys res: 100 - floor(100/5) = 100 - 20 = 80% (broken!)
const b100 = computeImmunityBreak(100, 100)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(80)
expect(b100.broken).toBe(true)
// 110% phys res: 110 - 20 = 90% (broken!)
const b110 = computeImmunityBreak(110, 100)
expect(b110.wasImmune).toBe(true)
expect(b110.effectiveRes).toBe(90)
expect(b110.broken).toBe(true)
// 120% phys res: 120 - 20 = 100% (remains immune!)
const b120 = computeImmunityBreak(120, 100)
expect(b120.wasImmune).toBe(true)
expect(b120.effectiveRes).toBe(100)
expect(b120.broken).toBe(false)
})
it('applies full -100% physical resistance to non-immune monster (50% -> -50%, 0% -> -100%)', () => {
const b50 = computeImmunityBreak(50, 100)
expect(b50.wasImmune).toBe(false)
expect(b50.effectiveRes).toBe(-50)
expect(b50.broken).toBe(false)
const b0 = computeImmunityBreak(0, 100)
expect(b0.wasImmune).toBe(false)
expect(b0.effectiveRes).toBe(-100)
expect(b0.broken).toBe(false)
})
it('verifies computeEffectiveResistance under Amplify Damage physical pierce', () => {
const res = computeEffectiveResistance({
baseRes: 110,
convictionPierce: 100, // 1/5th penalty applies
})
expect(res.isImmune).toBe(false)
expect(res.effectiveRes).toBe(90)
expect(res.convictionAndLRApplied).toBe(20)
})
})
// =========================================================================
// 5. Physical Immunity Breaking with Decrepify
// =========================================================================
describe('5. Physical Immunity Breaking with Decrepify', () => {
it('applies 1/5th penalty on immune monster (100% -> 90%, 105% -> 95%, 110% -> 100%)', () => {
// 100% phys res: 100 - floor(50/5) = 100 - 10 = 90% (broken!)
const b100 = computeImmunityBreak(100, 50)
expect(b100.wasImmune).toBe(true)
expect(b100.effectiveRes).toBe(90)
expect(b100.broken).toBe(true)
// 105% phys res: 105 - 10 = 95% (broken!)
const b105 = computeImmunityBreak(105, 50)
expect(b105.wasImmune).toBe(true)
expect(b105.effectiveRes).toBe(95)
expect(b105.broken).toBe(true)
// 110% phys res: 110 - 10 = 100% (remains immune!)
const b110 = computeImmunityBreak(110, 50)
expect(b110.wasImmune).toBe(true)
expect(b110.effectiveRes).toBe(100)
expect(b110.broken).toBe(false)
})
it('applies full -50% physical resistance to non-immune monster (50% -> 0%, 0% -> -50%)', () => {
const b50 = computeImmunityBreak(50, 50)
expect(b50.wasImmune).toBe(false)
expect(b50.effectiveRes).toBe(0)
const b0 = computeImmunityBreak(0, 50)
expect(b0.wasImmune).toBe(false)
expect(b0.effectiveRes).toBe(-50)
})
})
// =========================================================================
// 6. Elemental Immunity Breaking with Lower Resist
// =========================================================================
describe('6. Elemental Immunity Breaking with Lower Resist', () => {
it('evaluates exact dm56 curve: slvl 1: -32%, slvl 10: -55%, slvl 20: -63%', () => {
expect(calculateLowerResistReduction(1)).toBe(32)
expect(calculateLowerResistReduction(10)).toBe(55)
expect(calculateLowerResistReduction(20)).toBe(63)
})
it('breaks elemental immunities at 1/5th penalty', () => {
// slvl 1: 32% pierce -> floor(32/5) = 6% on 100% res -> 94% (broken!)
const b1 = computeImmunityBreak(100, 32)
expect(b1.wasImmune).toBe(true)
expect(b1.effectiveRes).toBe(94)
expect(b1.broken).toBe(true)
// slvl 20: 63% pierce -> floor(63/5) = 12% on 100% res -> 88% (broken!)
const b20 = computeImmunityBreak(100, 63)
expect(b20.wasImmune).toBe(true)
expect(b20.effectiveRes).toBe(88)
expect(b20.broken).toBe(true)
// 110% Lightning Res with slvl 20 LR: 110 - 12 = 98% (broken!)
const b110 = computeImmunityBreak(110, 63)
expect(b110.wasImmune).toBe(true)
expect(b110.effectiveRes).toBe(98)
expect(b110.broken).toBe(true)
// 115% Fire Res with slvl 20 LR: 115 - 12 = 103% (remains immune!)
const b115 = computeImmunityBreak(115, 63)
expect(b115.wasImmune).toBe(true)
expect(b115.effectiveRes).toBe(103)
expect(b115.broken).toBe(false)
})
})
// =========================================================================
// 7. Attract Overwrite Immunity
// =========================================================================
describe('7. Attract Overwrite Immunity (cursetype = 2)', () => {
it('rejects Amplify Damage, Decrepify, Weaken, and duplicate Attract when Attract is active', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
// 1. Apply Attract (cursetype = 2)
const resAttract = bus.applyState({
stateNameOrId: 'attract',
slvl: 10,
curseTypeOverride: 2,
durationFrames: 1110,
})
expect(resAttract.applied).toBe(true)
expect(bus.hasState('attract')).toBe(true)
// 2. Attempt to cast Amplify Damage (cursetype = 1) -> must be blocked!
const resAmp = bus.applyState({
stateNameOrId: 'amplifydamage',
slvl: 20,
curseTypeOverride: 1,
})
expect(resAmp.applied).toBe(false)
expect(resAmp.blockedByAttract).toBe(true)
expect(bus.hasState('amplifydamage')).toBe(false)
expect(bus.hasState('attract')).toBe(true)
// 3. Attempt to cast Decrepify (cursetype = 1) -> must be blocked!
const resDec = bus.applyState({
stateNameOrId: 'decrepify',
slvl: 20,
curseTypeOverride: 1,
})
expect(resDec.applied).toBe(false)
expect(resDec.blockedByAttract).toBe(true)
// 4. Attempt to recast Attract on already attracted monster -> must be blocked / no reset!
const resAttract2 = bus.applyState({
stateNameOrId: 'attract',
slvl: 20,
curseTypeOverride: 2,
})
expect(resAttract2.applied).toBe(false)
expect(resAttract2.blockedByAttract).toBe(true)
})
})
// =========================================================================
// 8. Standard Curse Mutual Exclusivity
// =========================================================================
describe('8. Standard Curse Mutual Exclusivity (cursetype = 1)', () => {
it('overwrites previous cursetype=1 curses cleanly without stat leakage', async () => {
const registry = await getSharedDataRegistry()
const stats = new UnitStatList()
const bus = new StateBus(stats, registry)
// 1. Apply Weaken
const r1 = bus.applyState({
stateNameOrId: 'weaken',
slvl: 1,
stats: { damagepercent: -33 },
curseTypeOverride: 1,
})
expect(r1.applied).toBe(true)
expect(bus.hasState('weaken')).toBe(true)
expect(stats.getModifierBonus('damagepercent')).toBe(-33)
// 2. Overwrite with Amplify Damage
const r2 = bus.applyState({
stateNameOrId: 'amplifydamage',
slvl: 1,
stats: { damageresist: -100, amplify_phys_pierce: 100 },
curseTypeOverride: 1,
})
expect(r2.applied).toBe(true)
expect(r2.evictedState).toBe('weaken')
expect(bus.hasState('weaken')).toBe(false)
expect(bus.hasState('amplifydamage')).toBe(true)
expect(stats.getModifierBonus('damagepercent')).toBe(0) // Cleanly removed!
expect(stats.getModifierBonus('damageresist')).toBe(-100)
// 3. Overwrite with Decrepify
const r3 = bus.applyState({
stateNameOrId: 'decrepify',
slvl: 1,
stats: { damageresist: -50, velocitypercent: -50 },
curseTypeOverride: 1,
})
expect(r3.applied).toBe(true)
expect(r3.evictedState).toBe('amplifydamage')
expect(bus.hasState('amplifydamage')).toBe(false)
expect(bus.hasState('decrepify')).toBe(true)
expect(stats.getModifierBonus('damageresist')).toBe(-50)
expect(stats.getModifierBonus('velocitypercent')).toBe(-50)
})
})
// =========================================================================
// 9. Life Tap 50% Leech Hook
// =========================================================================
describe('9. Life Tap 50% Physical Leech Hook', () => {
it('grants 50% physical life leech to attackers on hit', () => {
const attackerStats = new UnitStatList()
attackerStats.setHp256(50 * FIXED_ONE)
attackerStats.addStat('maxhp', 100)
const attackerBus = new StateBus(attackerStats)
const attacker: CombatUnitContext = {
id: 'attacker',
name: 'Hero',
statList: attackerStats,
stateBus: attackerBus,
unitType: 'player',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(1000 * FIXED_ONE)
const defenderBus = new StateBus(defenderStats)
defenderBus.applyState({
stateNameOrId: 'lifetap',
slvl: 10,
curseTypeOverride: 1,
})
const defender: CombatUnitContext = {
id: 'defender',
name: 'Skeleton',
statList: defenderStats,
stateBus: defenderBus,
unitType: 'monster',
}
// Attack dealing 100 physical damage
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
autoHit: true,
})
expect(outcome.hit).toBe(true)
expect(outcome.physDamage256).toBe(100 * FIXED_ONE)
expect(outcome.attackerHealed256).toBe(50 * FIXED_ONE) // 50% of 100 = 50 HP
expect(attackerStats.getHp256()).toBe(100 * FIXED_ONE) // 50 + 50 = 100 HP!
})
})
// =========================================================================
// 10. Iron Maiden Damage Reflection
// =========================================================================
describe('10. Iron Maiden Damage Reflection Scaling', () => {
it('scales reflection percentage: 200 + (slvl - 1) * 25 % (slvl 1: 200%, 10: 425%, 20: 675%)', () => {
expect(calculateIronMaidenReturnPct(1)).toBe(200)
expect(calculateIronMaidenReturnPct(10)).toBe(425)
expect(calculateIronMaidenReturnPct(20)).toBe(675)
// PvP division by 4
expect(calculateIronMaidenReturnPct(20, true)).toBe(168)
})
it('reflects physical melee damage back to cursed attacker mitigated by attacker phys res', () => {
const cursedAttackerStats = new UnitStatList()
cursedAttackerStats.setHp256(1000 * FIXED_ONE)
cursedAttackerStats.addStat('damageresist', 50) // 50% physical resistance
const attackerBus = new StateBus(cursedAttackerStats)
attackerBus.applyState({
stateNameOrId: 'ironmaiden',
slvl: 1, // 200% return
curseTypeOverride: 1,
})
const attacker: CombatUnitContext = {
id: 'cursed_monster',
name: 'Urdar',
statList: cursedAttackerStats,
stateBus: attackerBus,
unitType: 'monster',
}
const defenderStats = new UnitStatList()
defenderStats.setHp256(1000 * FIXED_ONE)
const defenderBus = new StateBus(defenderStats)
const defender: CombatUnitContext = {
id: 'player',
name: 'Hero',
statList: defenderStats,
stateBus: defenderBus,
unitType: 'player',
}
// Monster strikes player for 100 physical damage
const outcome = executeSUnitDmg(attacker, defender, {
skillId: 0,
attackKind: 'melee',
srcDam: 0,
flatPhysMin256: 100 * FIXED_ONE,
flatPhysMax256: 100 * FIXED_ONE,
autoHit: true,
})
// Raw reflected: 100 * 200% = 200. Mitigated by attacker's 50% phys res: 200 * (100 - 50)% = 100!
expect(outcome.reflectedPhys256).toBe(100 * FIXED_ONE)
expect(cursedAttackerStats.getHp256()).toBe(900 * FIXED_ONE)
})
})
// =========================================================================
// 11. Weaken & 12. Decrepify Debuffs
// =========================================================================
describe('11. Weaken & 12. Decrepify Debuffs', () => {
it('evaluates Weaken debuff: -33% damage dealt', () => {
const w = calculateWeakenDebuff()
expect(w.damagepercent).toBe(-33)
})
it('evaluates Decrepify tri-debuff: -50% velocity, -50% attack rate, -50% damage, -50% phys res', () => {
const d = calculateDecrepifyDebuff()
expect(d.velocitypercent).toBe(-50)
expect(d.attackrate).toBe(-50)
expect(d.damagepercent).toBe(-50)
expect(d.damageresist).toBe(-50)
expect(d.amplify_phys_pierce).toBe(50)
expect(d.item_slow).toBe(50)
})
})
// =========================================================================
// 13. Dim Vision, 14. Terror, 15. Confuse Specifications
// =========================================================================
describe('13..15 Mental Curses Specs', () => {
it('evaluates Terror run distance: 24 + (slvl - 1) * 2 subtiles', () => {
expect(calculateTerrorDistance(1)).toBe(24)
expect(calculateTerrorDistance(10)).toBe(42)
expect(calculateTerrorDistance(20)).toBe(62)
})
it('returns complete CurseSpec from calculateCurseEffect across all skills', () => {
const specAmp = calculateCurseEffect(66, 1)
expect(specAmp.skillName).toBe('Amplify Damage')
expect(specAmp.stateName).toBe('amplifydamage')
expect(specAmp.curseType).toBe(1)
expect(specAmp.radiusYards).toBe(2.0)
expect(specAmp.durationFrames).toBe(200)
const specAttract = calculateCurseEffect(86, 10)
expect(specAttract.skillName).toBe('Attract')
expect(specAttract.stateName).toBe('attract')
expect(specAttract.curseType).toBe(2)
expect(specAttract.radiusYards).toBe(6.0)
expect(specAttract.durationFrames).toBe(1110)
const specLR = calculateCurseEffect(91, 20)
expect(specLR.skillName).toBe('Lower Resist')
expect(specLR.stateName).toBe('lowerresist')
expect(specLR.debuffStats.lowerresist_pierce).toBe(63)
})
})
})