diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 0b03841..9389e06 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -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 } : {}), } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 2484c8b..fa0de08 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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) diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index 5403f86..19f0b3c 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -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() + }) }) })