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:
parent
8a9970aa31
commit
bfc1164d9b
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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!
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue