fix(monsters): apply 1.13c MonUMod resistance bonuses in applyEliteModifiers/enrichMonsterStats and isolate hover from 36px click-assist

This commit is contained in:
troytt 2026-09-28 07:00:16 +00:00
parent 0be447fab3
commit 44ec4124b9
3 changed files with 216 additions and 9 deletions

View File

@ -1014,16 +1014,69 @@ export function applyEliteModifiers(
damage = Math.round(damage * 1.2)
speed = Math.round(speed * 1.1 * 100) / 100
}
let physicalRes = base.resistances?.physical ?? 0
let magicRes = base.resistances?.magic ?? 0
let fireRes = base.resistances?.fire ?? 0
let lightningRes = base.lightningResist ?? base.resistances?.lightning ?? 0
let coldRes = base.resistances?.cold ?? 0
let poisonRes = base.resistances?.poison ?? 0
if (modifiers.includes('stoneskin')) {
physicalRes += 50
}
if (modifiers.includes('manahit')) {
magicRes += 20
}
if (modifiers.includes('fireenchant')) {
fireRes += 75
}
if (modifiers.includes('coldenchant')) {
coldRes += 75
}
if (modifiers.includes('lightenchant')) {
lightningRes += 75
}
if (modifiers.includes('poisonenchant')) {
poisonRes += 75
}
if (modifiers.includes('magicresistant')) {
fireRes += 40
coldRes += 40
lightningRes += 20
}
if (modifiers.includes('spectralhit')) {
fireRes += 20
coldRes += 20
lightningRes += 20
}
const hasAnyResMod =
physicalRes !== 0 ||
magicRes !== 0 ||
fireRes !== 0 ||
lightningRes !== 0 ||
coldRes !== 0 ||
poisonRes !== 0
const updatedResistances = base.resistances
? { ...base.resistances, lightning: lightningRes }
: (lightningRes !== 0 ? { lightning: lightningRes } : undefined)
? {
...base.resistances,
physical: physicalRes,
magic: magicRes,
fire: fireRes,
lightning: lightningRes,
cold: coldRes,
poison: poisonRes,
}
: hasAnyResMod
? {
...(physicalRes !== 0 ? { physical: physicalRes } : {}),
...(magicRes !== 0 ? { magic: magicRes } : {}),
...(fireRes !== 0 ? { fire: fireRes } : {}),
...(lightningRes !== 0 ? { lightning: lightningRes } : {}),
...(coldRes !== 0 ? { cold: coldRes } : {}),
...(poisonRes !== 0 ? { poison: poisonRes } : {}),
}
: undefined
return {
...base,
@ -1743,9 +1796,28 @@ export function enrichMonsterStats(
const nameKey = stats.nameKey ?? (isSuperUniqueLeader ? (su?.nameKey || stats.name || stats.superUniqueId) : (kind.nameKey || stats.name))
const rawDesc = stats.descKey ?? (kind.descKey !== '' ? kind.descKey : undefined)
const descKey = rawDesc !== undefined && rawDesc !== '' && rawDesc.toLowerCase() !== 'dummy' ? rawDesc : undefined
const mergedRes = stats.resistances
? { ...kind.resistances, ...stats.resistances }
const kindWithMods = stats.modifiers && stats.modifiers.length > 0
? applyEliteModifiers(
{
...stats,
resistances: kind.resistances,
lightningResist: kind.resistances.lightning,
},
stats.rank ?? 'normal',
stats.modifiers,
stats.superUniqueId,
).resistances ?? kind.resistances
: kind.resistances
const mergedRes: MonsterResistances = stats.modifiers && stats.modifiers.length > 0
? {
physical: Math.max(kindWithMods.physical ?? 0, stats.resistances?.physical ?? kindWithMods.physical ?? 0),
magic: Math.max(kindWithMods.magic ?? 0, stats.resistances?.magic ?? kindWithMods.magic ?? 0),
fire: Math.max(kindWithMods.fire ?? 0, stats.resistances?.fire ?? kindWithMods.fire ?? 0),
lightning: Math.max(kindWithMods.lightning ?? 0, stats.resistances?.lightning ?? kindWithMods.lightning ?? 0),
cold: Math.max(kindWithMods.cold ?? 0, stats.resistances?.cold ?? kindWithMods.cold ?? 0),
poison: Math.max(kindWithMods.poison ?? 0, stats.resistances?.poison ?? kindWithMods.poison ?? 0),
}
: (stats.resistances ? { ...kind.resistances, ...stats.resistances } : kind.resistances)
return {
...stats,
nameKey,
@ -1756,7 +1828,10 @@ export function enrichMonsterStats(
killable: stats.killable ?? kind.killable,
inTown: stats.inTown ?? kind.inTown,
resistances: mergedRes,
resists: stats.resists ? { ...mergedRes, ...stats.resists } : mergedRes,
resists: stats.modifiers && stats.modifiers.length > 0
? mergedRes
: (stats.resists ? { ...mergedRes, ...stats.resists } : mergedRes),
...(mergedRes.lightning !== undefined ? { lightningResist: mergedRes.lightning } : {}),
}
}

View File

@ -4787,6 +4787,9 @@ export class SceneMouseController {
this.lockedMonster = null
} else if (e.button === 2) {
this.isRightMouseDown = false
if (!this.isLeftMouseDown) {
this.activeClickType = 'none'
}
} else {
this.isLeftMouseDown = false
this.isRightMouseDown = false
@ -4815,7 +4818,8 @@ export class SceneMouseController {
const range = getSkillRange(rightSkillId, this.hudManager)
// 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY)
let clickedMonster: Monster | null = hoveredAtClick
if (clickedMonster === null) {
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
@ -4833,7 +4837,7 @@ export class SceneMouseController {
if (clickedMonster !== null) {
this.activeClickType = 'monster'
this.hoveredMonster = clickedMonster
this.hoveredMonster = hoveredAtClick
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) {
this.navTarget = null
@ -5060,7 +5064,8 @@ export class SceneMouseController {
}
// 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
const hoveredAtClick = this.findMonsterAtWorld(worldX, worldY)
let clickedMonster: Monster | null = hoveredAtClick
if (clickedMonster === null) {
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
@ -5079,7 +5084,7 @@ export class SceneMouseController {
if (clickedMonster !== null) {
this.activeClickType = 'monster'
this.lockedMonster = clickedMonster
this.hoveredMonster = clickedMonster
this.hoveredMonster = hoveredAtClick
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId, this.hudManager)
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)

View File

@ -35,6 +35,7 @@ import {
readMonsterKinds,
readSuperUniques,
monsterStatsOf,
applyEliteModifiers,
enrichMonsterPacks,
} from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
@ -1153,6 +1154,132 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
// Leader level must be base 1 + 2 (3) or base 1 + 3 (4), never +4 or +5!
expect(leader.level === 3 || leader.level === 4).toBe(true)
})
it('applies 1.13c MonUMod elemental/physical/magic resistance bonuses in applyEliteModifiers and enrichMonsterPacks so Bishibosh and Frozenstein display immunities on Line 2 (D2Game.dll 0x6fc41be0)', () => {
const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'normal')
const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'normal')
const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s]))
// 1. Pre-baked Cold Plains Bishibosh (fallenshaman1: base fire=25 + magicresistant(+40) + fireenchant(+75) = 140 >= 100)
const bishiboshPack = enrichMonsterPacks(
[
{
superUniqueId: 'Bishibosh',
members: [
{
id: 'fallenshaman1',
name: 'Bishibosh',
hp: 43,
damage: 2,
cooldownTicks: 25,
reach: 40,
aggroRadius: 560,
speed: 113,
xp: 32,
level: 5,
rank: 'unique',
resistances: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 },
resists: { physical: 0, magic: 0, fire: 25, lightning: 20, cold: 0, poison: 0 },
modifiers: ['magicresistant', 'fireenchant'],
superUniqueId: 'Bishibosh',
},
],
},
],
kinds,
superUniques,
)
const bishiboshStats = bishiboshPack[0]!.members[0]!
expect(bishiboshStats.resistances?.fire).toBe(140)
expect(bishiboshStats.resistances?.cold).toBe(40)
expect(bishiboshStats.resistances?.lightning).toBe(20)
const bishiboshHud = monsterToTargetHudInfo({
kind: 'fallenshaman1',
x: 100,
y: 100,
hp: 43,
maxHp: 43,
state: 'idle',
stats: bishiboshStats,
} as any)
const bishiboshSubEn = buildMonsterHoverSubtext(bishiboshHud, 'en')
expect(bishiboshSubEn.line1).toBe('Demon Magic Resistant Fire Enchanted Raises Fallen')
expect(bishiboshSubEn.line2).toBe('Immune to Fire')
expect(bishiboshSubEn.line2Colored).toBe('ÿc1Immune to Fire')
const bishiboshSubZh = buildMonsterHoverSubtext(bishiboshHud, 'zh')
expect(bishiboshSubZh.line2).toBe(lookupMonsterTblString('strScaryMonDesc1', 'zh'))
expect(bishiboshSubZh.line2Colored).toBe(`ÿc1${lookupMonsterTblString('strScaryMonDesc1', 'zh')}`)
// 2. Pre-baked Frozenstein (snowyeti4: base cold=75 + coldenchant(+75) = 150 >= 100, manahit(+20 magic))
const frozensteinPack = enrichMonsterPacks(
[
{
superUniqueId: 'Frozenstein',
members: [
{
id: 'snowyeti4',
name: 'Frozenstein',
hp: 300,
damage: 25,
cooldownTicks: 25,
reach: 40,
aggroRadius: 560,
speed: 120,
xp: 200,
level: 40,
rank: 'unique',
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 75, poison: 0 },
modifiers: ['coldenchant', 'manahit'],
superUniqueId: 'Frozenstein',
},
],
},
],
kinds,
superUniques,
)
const frozensteinStats = frozensteinPack[0]!.members[0]!
expect(frozensteinStats.resistances?.cold).toBe(150)
expect(frozensteinStats.resistances?.magic).toBe(20)
// 3. applyEliteModifiers directly for stoneskin (+50 physical), spectralhit (+20 fire/cold/ltng), poisonenchant (+75 poison)
const baseStats = monsterStatsOf(kinds.get('fallen1')!, new Rng(1), 4)
const modded = applyEliteModifiers(baseStats, 'unique', ['stoneskin', 'spectralhit', 'poisonenchant'])
expect(modded.resistances?.physical).toBe(50)
expect(modded.resistances?.fire).toBe(20)
expect(modded.resistances?.cold).toBe(20)
expect(modded.resistances?.lightning).toBe(20)
expect(modded.resistances?.poison).toBe(75)
})
it('isolates hoveredMonster from 36px click-assist fallback in handleRightClickWorld and handleLeftClickWorld', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const monster0 = engine.world.monsters[0]!
// Monster 0 is at (560, 500) with drawBounds [544..576, 450..504]
// World point (535, 500) is OUTSIDE drawBounds (x=535 < 544) but WITHIN 36px fallback (dist=25 <= 36)
expect(controller.findMonsterAtWorld(535, 500)).toBeNull()
// 1. Right-click at (535, 500) (outside drawBounds, inside 36px fallback) must NOT stick hoveredMonster or targetMonster
controller.handleRightClickWorld(535, 500)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 2, buttons: 0 }))
expect(controller.activeClickType).toBe('none')
// 2. Left-click at (535, 500) (outside drawBounds, inside 36px fallback) locks monster while held, then clears on release
controller.isLeftMouseDown = true
controller.handleLeftClickWorld(535, 500)
expect(controller.lockedMonster).toBe(monster0)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
canvas.dispatchEvent(new SyntheticPointerEvent('pointerup', { clientX: 435, clientY: 316, button: 0, buttons: 0 }))
expect(controller.lockedMonster).toBeNull()
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
})
})