diff --git a/src/game/combat.ts b/src/game/combat.ts index 05f8844..e463a82 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -42,6 +42,7 @@ import { calculateFireMasteryBonus, calculateEnchantAttackDamage, } from './engine/missile-engine.ts' +import { computeEffectiveResistance } from './engine/combat-pipeline.ts' export { allocatePacksToRooms, @@ -187,10 +188,24 @@ export interface Monster { chillTicks?: number | undefined /** Ticks remaining poisoned (damage over time). */ poisonTicks?: number | undefined + /** Active Necromancer curse debuff state. */ + curse?: MonsterCurseState | undefined /** Whether drops have already been rolled for this monster instance. */ 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. */ export interface CombatPlayer { /** World x. */ @@ -756,6 +771,10 @@ export function damageMonster(world: CombatWorld, monsterIndex: number, amount: monster.hp -= amount monster.hitFlash = 4 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 monster.state = 'dead' monster.corpseTicks = 100 @@ -905,7 +924,12 @@ function tickPlayer( world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) } else { 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) { const enchantRes = calculateEnchantAttackDamage({ enchantDamage: player.enchantDamage, @@ -919,6 +943,10 @@ function tickPlayer( target.hp -= totalDamage target.hitFlash = 4 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) { target.state = 'dead' 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) { monster.state = 'idle' continue @@ -988,11 +1023,85 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain monster.state = 'idle' 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 dy = player.y - monster.y 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' continue } @@ -1000,10 +1109,23 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain if (distance <= monster.stats.reach) { monster.state = 'attack' 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 + 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 + // 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 if (!isRanged && player.coldArmorType === 'shiverarmor') { const slvl = player.coldArmorSlvl ?? 1 @@ -1072,7 +1194,8 @@ export function tickMonsters(world: CombatWorld, options: CombatOptions, terrain } monster.state = 'chase' 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) monster.x = movedMonster.x monster.y = movedMonster.y diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index 5379f04..85b519a 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -19,6 +19,7 @@ */ import { FIXED_ONE, UnitStatList } from './stat-list.ts' import { StateBus } from './state-bus.ts' +import { calculateLowerResistReduction, calculateIronMaidenReturnPct } from './missile-engine.ts' export interface CombatUnitContext { readonly id: string @@ -35,6 +36,9 @@ export interface CombatUnitContext { readonly weaponPoisonBitrate256?: number readonly weaponPoisonFrames?: number readonly unitType?: 'player' | 'mercenary' | 'party' | 'party_mercenary' | 'minion' | 'monster' + readonly isBoss?: boolean + readonly isChampion?: boolean + readonly rank?: 'normal' | 'champion' | 'unique' | 'minion' } export interface ResistanceResult { @@ -319,6 +323,7 @@ export interface SUnitDmgOutcome { readonly attackerStatesApplied?: readonly string[] readonly attackerColdDamage256?: number readonly reflectedPhys256?: number + readonly attackerHealed256?: number readonly spawnedMissiles?: readonly string[] } @@ -418,9 +423,14 @@ export function executeSUnitDmg( const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128) 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 = (packet.physDamagePct ?? 0) + - attacker.statList.getModifierBonus('damagepercent') + + dmgPctBonus + + curseDmgMod + attacker.statList.getModifierBonus('skill_damage_percent') rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100)) } @@ -494,9 +504,19 @@ export function executeSUnitDmg( } // 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({ baseRes: defender.statList.getAccruedStat('damageresist'), - convictionPierce: defender.statList.getModifierBonus('amplify_phys_pierce'), + convictionPierce: curseAmpPierce, }) const resStatKey = @@ -513,11 +533,19 @@ export function executeSUnitDmg( const isElemPiercableByConviction = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' 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 ? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce')) : 0 const lowerResistPierce = isElemPiercableByLR - ? (packet.lowerResistPierce ?? defender.statList.getModifierBonus('lowerresist_pierce')) + ? (packet.lowerResistPierce ?? curseLrPierce) : 0 const passivePierce = packet.passiveEnemyPierce ?? @@ -562,6 +590,15 @@ export function executeSUnitDmg( ? 0 : 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 if (packet.skillId === 42 && !elemResRec.isImmune) { const rawStatic256 = Math.trunc((hpBefore256 * 25) / 100) @@ -606,6 +643,26 @@ export function executeSUnitDmg( const curHp = attacker.statList.getHp256() 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') { const reactiveMissile = defender.stateBus.triggerReactiveEvents({ event: 'hitbymissile', @@ -638,6 +695,7 @@ export function executeSUnitDmg( ...(attackerStatesApplied.length > 0 ? { attackerStatesApplied } : {}), ...(attackerColdDamage256 > 0 ? { attackerColdDamage256 } : {}), ...(reflectedPhys256 > 0 ? { reflectedPhys256 } : {}), + ...(attackerHealed256 > 0 ? { attackerHealed256 } : {}), ...(spawnedMissiles.length > 0 ? { spawnedMissiles } : {}), } } diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index b20599a..2e7fbdc 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -19,7 +19,7 @@ import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './ import { getBlizzardDropOffset } from './blizzard-table.ts' import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.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 @@ -3634,3 +3634,337 @@ export function calculateBoneSpiritDamage( 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> +} + +/** + * 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 = {} + + 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, + } +} + diff --git a/src/game/engine/state-bus.ts b/src/game/engine/state-bus.ts index a2f71be..4d781b4 100644 --- a/src/game/engine/state-bus.ts +++ b/src/game/engine/state-bus.ts @@ -128,8 +128,9 @@ export class StateBus { const isAura = params.isAuraOverride ?? stateRec?.aura ?? false // CRITICAL 1.13c RULE: `STATE_ATTRACT` (`cursetype === 2`) cannot be overwritten by - // any standard `cursetype === 1` curse! - if (curseType === 1) { + // any standard `cursetype === 1` curse! Furthermore, casting Attract on an already + // 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') if (existingAttract) { return { @@ -145,7 +146,7 @@ export class StateBus { let evictedState: string | undefined // 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) { const prev = this.entries[i]! if (prev.curseType === 1) { diff --git a/src/game/skills.ts b/src/game/skills.ts index 2614478..9071e53 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -109,6 +109,19 @@ import { calculateBoneSpiritDamage, calculateBoneSpiritSynergyMultiplier, spawnPoisonNovaRing, + calculateCurseRadiusYards, + calculateCurseRadiusPx, + calculateCurseDurationFrames, + calculateAmplifyDamageDebuff, + calculateWeakenDebuff, + calculateIronMaidenReturnPct, + calculateTerrorDistance, + calculateDecrepifyDebuff, + calculateLowerResistReduction, + computeImmunityBreak, + calculateCurseEffect, + type CurseOpts, + type CurseSpec, type DamageRange, type PoisonDamageSpec, type SynergyState, @@ -195,6 +208,19 @@ export { calculateBoneSpiritDamage, calculateBoneSpiritSynergyMultiplier, spawnPoisonNovaRing, + calculateCurseRadiusYards, + calculateCurseRadiusPx, + calculateCurseDurationFrames, + calculateAmplifyDamageDebuff, + calculateWeakenDebuff, + calculateIronMaidenReturnPct, + calculateTerrorDistance, + calculateDecrepifyDebuff, + calculateLowerResistReduction, + computeImmunityBreak, + calculateCurseEffect, + type CurseOpts, + type CurseSpec, type DamageRange, type PoisonDamageSpec, type SynergyState, @@ -3482,6 +3508,198 @@ export const CANONICAL_113C_OVERLAYS: Readonly> = xOffset: 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) + 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 if (!def) { if (weaponDamage) { diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index 69cccea..063a543 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -27,6 +27,7 @@ import { calculateStaticFieldDamage, calculateStaticFieldRadiusPx, hasLineOfSight, + calculateCurseEffect, } from '../engine/missile-engine.ts' const REGISTERED_SKILL_MODULES = new Map() @@ -490,7 +491,12 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { // 6. Curses & AoE Debuffs (`srvDoFunc = 6, 30, 47, 59, 61, 71`) 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 = + curseEffect?.stateName || skill.auraTgtState || skill.auraState || (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), getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id), } - const debuffStats: Record = {} - if (skill.id === 66) debuffStats['amplify_phys_pierce'] = 100 - if (skill.id === 87) { + const debuffStats: Record = curseEffect ? { ...curseEffect.debuffStats } : {} + if (skill.id === 66 && !debuffStats['amplify_phys_pierce']) debuffStats['amplify_phys_pierce'] = 100 + if (skill.id === 87 && !debuffStats['amplify_phys_pierce']) { debuffStats['amplify_phys_pierce'] = 50 debuffStats['item_slow'] = 50 } - if (skill.id === 91) { + if (skill.id === 91 && !debuffStats['lowerresist_pierce']) { debuffStats['lowerresist_pierce'] = evaluateCalc('dm56', calcCtx) } for (const t of targets) { 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({ stateNameOrId: curseState, sourceUnitId: caster.id, sourceSkillId: skill.id, slvl: evalResult.slvl, - durationFrames: evalResult.durationFrames || 250, + durationFrames: evalResult.durationFrames || (curseEffect ? curseEffect.durationFrames : 250), currentFrame: currentTick, stats: debuffStats, curseTypeOverride: skill.id === 86 ? 2 : 1, @@ -528,7 +547,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { if (res.applied) { 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 { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 8833851..110cde6 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts' import { npcDialog } from '../game/quests.ts' import type { NpcDef } from '../game/quests.ts' -import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, 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 { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -2106,6 +2106,135 @@ export function castSkill( 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 let speed = 450 let range = 450 diff --git a/tests/skills/nec/adv-curses-stress.test.ts b/tests/skills/nec/adv-curses-stress.test.ts new file mode 100644 index 0000000..35176ba --- /dev/null +++ b/tests/skills/nec/adv-curses-stress.test.ts @@ -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! + }) + }) +}) diff --git a/tests/skills/nec/curses.test.ts b/tests/skills/nec/curses.test.ts new file mode 100644 index 0000000..4bbaae6 --- /dev/null +++ b/tests/skills/nec/curses.test.ts @@ -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) + }) + }) +})