diablo2-web/tests/monster-target-bar.test.ts

1286 lines
48 KiB
TypeScript

/**
* tests/monster-target-bar.test.ts
*
* Comprehensive unit & integration tests for Diablo II 1.13c Monster Hover Lifebar
* and 2-Line Subtext Tooltip Parity:
* - D2Client.dll `0x6fb739e0` (`UpdateSelectedUnitOnMouseMove`), `0x6fb01a80` (`FindSelectableUnitUnderCursor`)
* - D2Client.dll `0x6fb70850` (`DrawSelectedMonsterLifeBar`), `0x6facfdd0` (`BuildMonsterHoverSubtext`)
* - D2Client.dll `0x6fb6fca0` (`AppendMonsterTypeAndDesc`), `0x6fb719b0` (`AppendMonsterModifiers`), `0x6fb71a20` (`AppendMonsterImmunities`)
* - D2Win.dll `Ordinal_10070` (`DrawMonsterLifeBarBox`)
*/
import { describe, expect, it, vi } from 'vitest'
import {
WorldPanelsHud,
lookupMonsterTblString,
resolveMonsterTitleAndColor,
computeMonsterLifebarLayout,
buildMonsterHoverSubtext,
CANONICAL_IMMUNITY_DESCRIPTORS,
type MonsterTargetHudInfo,
} from '../src/ui/world-panels.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { KeyboardInput } from '../src/sim/input.ts'
import { WaypointNetwork } from '../src/game/portal.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
import {
SceneMouseController,
monsterToTargetHudInfo,
rebudgetMonsterPacks,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import {
readMonsterKinds,
readSuperUniques,
monsterStatsOf,
applyEliteModifiers,
enrichMonsterPacks,
} from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
import { parseTable } from '../src/game/acts.ts'
import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts'
// Headless DOM mocks for SceneMouseController testing
class MockWindow extends EventTarget {
innerWidth = 800
innerHeight = 600
devicePixelRatio = 1
}
class MockCanvas extends EventTarget {
clientWidth = 800
clientHeight = 600
width = 800
height = 600
style = { pointerEvents: 'none' }
getBoundingClientRect() {
return { left: 0, top: 0, width: 800, height: 600 }
}
getContext() {
return null
}
}
class SyntheticPointerEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly buttons: number
readonly shiftKey: boolean
constructor(
type: string,
init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean },
) {
super(type, { cancelable: true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.buttons = init.buttons !== undefined ? init.buttons : 1 << (init.button ?? 0)
this.shiftKey = init.shiftKey ?? false
}
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
function createMockFont(charWidth = 8): D2FontRenderer & {
calls: Array<{ text: string; x: number; y: number; opts?: any }>
} {
const calls: Array<{ text: string; x: number; y: number; opts?: any }> = []
return {
calls,
measureText: vi.fn((text: string) => text.length * charWidth),
drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => {
calls.push({ text, x, y, opts })
}),
} as unknown as D2FontRenderer & {
calls: Array<{ text: string; x: number; y: number; opts?: any }>
}
}
function createHoverTestScene() {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 500, y: 500 },
stats: [
{
id: 'fallen1',
name: 'Fallen',
nameKey: 'Fallen',
descKey: '',
isUndead: false,
isDemon: true,
isBoss: false,
killable: true,
inTown: false,
rank: 'normal',
modifiers: [],
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
hp: 20,
damage: 2,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 0,
xp: 18,
},
{
id: 'zombie1',
name: 'Zombie',
nameKey: 'Zombie',
descKey: '',
isUndead: true,
isDemon: false,
isBoss: false,
killable: true,
inTown: false,
rank: 'unique',
modifiers: ['strong', 'cursed'],
resistances: { physical: 50, magic: 0, fire: 0, lightning: 0, cold: 100, poison: 75 },
hp: 80,
damage: 5,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 0,
xp: 50,
},
],
xpTable: [0, 0, 100, 200],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 52,
playerCooldownTicks: 10,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 100,
},
lootSeed: 42,
talkRadius: 80,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
// Keep exactly 2 deterministic monsters for hover hit-testing
engine.world.monsters.splice(2)
;(engine.world.monsters[0]! as { stats: unknown }).stats = dummyOpts.stats[0]!
engine.world.monsters[0]!.hp = dummyOpts.stats[0]!.hp
engine.world.monsters[0]!.x = 560
engine.world.monsters[0]!.y = 500
engine.world.monsters[0]!.drawBounds = { x: 544, y: 450, w: 32, h: 54 }
;(engine.world.monsters[1]! as { stats: unknown }).stats = dummyOpts.stats[1]!
engine.world.monsters[1]!.hp = dummyOpts.stats[1]!.hp
engine.world.monsters[1]!.x = 650
engine.world.monsters[1]!.y = 500
engine.world.monsters[1]!.drawBounds = { x: 630, y: 440, w: 40, h: 64 }
const camera = new ViewportCamera(canvas, 2000, 2000)
camera.zoom = 1
const hudManager = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
const waypointNetwork = new WaypointNetwork()
const status = { textContent: '' } as HTMLElement
const runtime: MapRuntime = {
base: 'samples/d2-packs',
act: 1,
level: 'Blood Moor',
levelId: 2,
variant: 'default',
variants: ['default'],
quadrant: 'default',
quadrants: ['default'],
cellsX: 100,
cellsY: 100,
widthPx: 2000,
heightPx: 2000,
floors: [],
walls: [],
roofs: [],
objectDrawables: [],
pages: [],
priorityPages: [],
objectPages: [],
grid: {
originX: 0,
originY: 0,
cellsX: 100,
cellsY: 100,
blocked: new Uint8Array(500 * 500),
gridWidth: 500,
},
npcs: [],
entrances: [],
warps: [],
waypoints: [],
animatedDrawables: [],
animSpeed: 1,
frameDurationMs: 40,
palette: null,
charBases: [],
monsterTypes: [],
monsterPacks: [],
entryPath: '',
loadPages: async () => {},
} as unknown as MapRuntime
const controller = new SceneMouseController({
canvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager,
waypointNetwork,
status,
})
controller.attach()
return { canvas, engine, camera, hudManager, controller }
}
describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () => {
describe('R1: Canonical .tbl Strings & MonStats.txt Metadata Pipeline', () => {
it('packs required MonStats.txt columns and resolves canonical ENG & CHI .tbl strings', () => {
const monstatsTable = parseTable(new TextEncoder().encode(RAW_MONSTATS))
expect(monstatsTable.header).toContain('DescStr')
expect(monstatsTable.header).toContain('lUndead')
expect(monstatsTable.header).toContain('hUndead')
expect(monstatsTable.header).toContain('demon')
expect(monstatsTable.header).toContain('killable')
expect(monstatsTable.header).toContain('inTown')
expect(monstatsTable.header).toContain('ResDm')
expect(monstatsTable.header).toContain('ResCo(H)')
// Base monster names
expect(lookupMonsterTblString('Fallen', 'en')).toBe('Fallen')
expect(lookupMonsterTblString('Fallen', 'zh')).toBe('沈淪魔')
expect(lookupMonsterTblString('Zombie', 'en')).toBe('Zombie')
expect(lookupMonsterTblString('Zombie', 'zh')).toBe('僵屍')
// SuperUniques
expect(lookupMonsterTblString('Bishibosh', 'en')).toBe('Bishibosh')
expect(lookupMonsterTblString('Bishibosh', 'zh')).toBe('畢須博須')
expect(lookupMonsterTblString('Corpsefire', 'en')).toBe('Corpsefire')
expect(lookupMonsterTblString('Corpsefire', 'zh')).toBe('屍體發火')
// Base type & rank affixes
expect(lookupMonsterTblString('DemonID', 'en')).toBe('Demon')
expect(lookupMonsterTblString('DemonID', 'zh')).toBe('惡魔')
expect(lookupMonsterTblString('UndeadDescriptX', 'en')).toBe('Undead')
expect(lookupMonsterTblString('UndeadDescriptX', 'zh')).toBe('不死的')
expect(lookupMonsterTblString('Champion', 'en')).toBe('Champion')
expect(lookupMonsterTblString('Champion', 'zh')).toBe('頭目')
expect(lookupMonsterTblString('minion', 'en')).toBe('Minion')
expect(lookupMonsterTblString('minion', 'zh')).toBe('嘍囉')
// Elite modifiers (3205..3218)
expect(lookupMonsterTblString('uniquextrastrong', 'en')).toBe('Extra Strong')
expect(lookupMonsterTblString('uniquextrastrong', 'zh')).toBe('特別強壯')
expect(lookupMonsterTblString('uniqueextrafast', 'en')).toBe('Extra Fast')
expect(lookupMonsterTblString('uniqueextrafast', 'zh')).toBe('特別快速')
expect(lookupMonsterTblString('uniquecursed', 'en')).toBe('Cursed')
expect(lookupMonsterTblString('uniquecursed', 'zh')).toBe('特別詛咒')
expect(lookupMonsterTblString('monsteruniqueprop6', 'en')).toBe('Stone Skin')
expect(lookupMonsterTblString('monsteruniqueprop6', 'zh')).toBe('皮膚硬化')
// Immunity strings (10990..10995)
expect(lookupMonsterTblString('strScaryMonDesc1', 'en')).toBe('Immune to Fire')
expect(lookupMonsterTblString('strScaryMonDesc1', 'zh')).toBe('火焰系無效')
expect(lookupMonsterTblString('strScaryMonDesc2', 'en')).toBe('Immune to Cold')
expect(lookupMonsterTblString('strScaryMonDesc2', 'zh')).toBe('冰冷系無效')
expect(lookupMonsterTblString('strScaryMonDesc3', 'en')).toBe('Immune to Lightning')
expect(lookupMonsterTblString('strScaryMonDesc3', 'zh')).toBe('閃電系無效')
expect(lookupMonsterTblString('strScaryMonDesc4', 'en')).toBe('Immune to Poison')
expect(lookupMonsterTblString('strScaryMonDesc4', 'zh')).toBe('毒素系無效')
expect(lookupMonsterTblString('strScaryMonDesc5', 'en')).toBe('Immune to Physical')
expect(lookupMonsterTblString('strScaryMonDesc5', 'zh')).toBe('實體攻擊無效')
expect(lookupMonsterTblString('strScaryMonDesc6', 'en')).toBe('Immune to Magic')
expect(lookupMonsterTblString('strScaryMonDesc6', 'zh')).toBe('魔法系無效')
})
it('populates descKey, isUndead, isDemon, isBoss, killable, inTown, and resistances in readMonsterKinds and enrichMonsterPacks', () => {
const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'hell')
const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'hell')
const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s]))
const fallen = kinds.get('fallen1')!
const zombie = kinds.get('zombie1')!
const quillRat = kinds.get('quillrat1')!
const fallenShaman = kinds.get('fallenshaman1')!
const andariel = kinds.get('andariel')!
expect(fallen.isDemon).toBe(true)
expect(fallen.isUndead).toBe(false)
expect(fallen.resistances.fire).toBeGreaterThanOrEqual(100) // Hell Fallen is Fire Immune
expect(zombie.isUndead).toBe(true)
expect(zombie.isDemon).toBe(false)
expect(quillRat.isUndead).toBe(false)
expect(quillRat.isDemon).toBe(false)
expect(fallenShaman.descKey).toBe('raises Fallen')
expect(andariel.isBoss).toBe(true)
const stats = monsterStatsOf(fallen, new Rng(123), 4)
expect(stats.nameKey).toBe('Fallen')
expect(stats.isDemon).toBe(true)
expect(stats.isUndead).toBe(false)
expect(stats.resistances?.fire).toBeGreaterThanOrEqual(100)
// Verify enrichMonsterPacks enriches pre-baked pack stats with MonStats & SuperUniques metadata
const rawPacks = [
{
kindId: 'fallenshaman1',
rank: 'unique' as const,
superUniqueId: 'Bishibosh',
members: [
{
id: 'fallenshaman1',
name: 'Bishibosh',
rank: 'unique' as const,
hp: 120,
damage: 10,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 4,
xp: 90,
},
],
},
]
const enriched = enrichMonsterPacks(rawPacks, kinds, superUniques)
expect(enriched[0]!.members[0]!.nameKey).toBe('Bishibosh')
expect(enriched[0]!.members[0]!.descKey).toBe('raises Fallen')
expect(enriched[0]!.members[0]!.isDemon).toBe(true)
expect(enriched[0]!.members[0]!.isUndead).toBe(false)
})
})
describe('R3: Authentic 1.13c Lifebar Plaque Layout, Title, and 2-Line Subtext', () => {
it('computes 1.13c panel-aware lifebar layout and [2, 128] HP fill clamping (0x6fb70850 & Ordinal_10070)', () => {
// Short name (" Fallen " = 10 chars * 8 = 80px -> clamped to min width 128)
const layoutClosed = computeMonsterLifebarLayout(800, false, false, 80, 100, 100)
expect(layoutClosed.visible).toBe(true)
expect(layoutClosed.centerX).toBe(400)
expect(layoutClosed.barWidth).toBe(128)
expect(layoutClosed.left).toBe(400 - 64)
expect(layoutClosed.y).toBe(32)
expect(layoutClosed.top).toBe(19)
expect(layoutClosed.bottom).toBe(36)
expect(layoutClosed.barHeight).toBe(17) // 16 + 1 inclusive
expect(layoutClosed.fillRatio128).toBe(128)
expect(layoutClosed.fillWidth).toBe(128)
// Long name (textWidth = 264px > 128)
const layoutLong = computeMonsterLifebarLayout(800, false, false, 264, 50, 100)
expect(layoutLong.barWidth).toBe(264)
expect(layoutLong.left).toBe(400 - 132)
expect(layoutLong.fillRatio128).toBe(64)
expect(layoutLong.fillWidth).toBe(132)
// Sliver of HP (1 / 10000) -> fillRatio128 clamped to minimum 2 per 0x6fb7092f
const layoutSliver = computeMonsterLifebarLayout(800, false, false, 80, 1, 10000)
expect(layoutSliver.fillRatio128).toBe(2)
expect(layoutSliver.fillWidth).toBe(2)
// Dead (0 / 100) -> visible = false, fillRatio128 = 0
const layoutDead = computeMonsterLifebarLayout(800, false, false, 80, 0, 100)
expect(layoutDead.visible).toBe(false)
expect(layoutDead.fillRatio128).toBe(0)
expect(layoutDead.fillWidth).toBe(0)
// Left panel open, right closed -> shifts right by +200 to centerX = 600
const layoutLeftOpen = computeMonsterLifebarLayout(800, true, false, 80, 100, 100)
expect(layoutLeftOpen.visible).toBe(true)
expect(layoutLeftOpen.centerX).toBe(600)
// Right panel open, left closed -> shifts left by -200 to centerX = 200
const layoutRightOpen = computeMonsterLifebarLayout(800, false, true, 80, 100, 100)
expect(layoutRightOpen.visible).toBe(true)
expect(layoutRightOpen.centerX).toBe(200)
// Both panels open -> hidden
const layoutBothOpen = computeMonsterLifebarLayout(800, true, true, 80, 100, 100)
expect(layoutBothOpen.visible).toBe(false)
})
it('resolves monster title and color across Normal, Champion, Unique, Minion, and Boss ranks in ENG and CHI', () => {
// Normal
const normalEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
'en',
)
expect(normalEn).toEqual({
displayName: 'Fallen',
paddedTitle: ' Fallen ',
titleColor: 'white',
})
const normalZh = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
'zh',
)
expect(normalZh).toEqual({
displayName: '沈淪魔',
paddedTitle: ' 沈淪魔 ',
titleColor: 'white',
})
// Champion
const champEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
'en',
)
expect(champEn).toEqual({
displayName: 'Champion Fallen',
paddedTitle: ' Champion Fallen ',
titleColor: 'blue',
})
const champZh = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
'zh',
)
expect(champZh).toEqual({
displayName: '頭目 沈淪魔',
paddedTitle: ' 頭目 沈淪魔 ',
titleColor: 'blue',
})
// SuperUnique / Unique
const uniqueEn = resolveMonsterTitleAndColor(
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
'en',
)
expect(uniqueEn).toEqual({
displayName: 'Bishibosh',
paddedTitle: ' Bishibosh ',
titleColor: 'gold',
})
const uniqueZh = resolveMonsterTitleAndColor(
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
'zh',
)
expect(uniqueZh).toEqual({
displayName: '畢須博須',
paddedTitle: ' 畢須博須 ',
titleColor: 'gold',
})
// Minion (title is white base name, subtext line 1 carries Minion / 僕人)
const minionEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 15, maxHp: 15, rank: 'minion' },
'en',
)
expect(minionEn).toEqual({
displayName: 'Fallen',
paddedTitle: ' Fallen ',
titleColor: 'white',
})
// Act Boss
const bossZh = resolveMonsterTitleAndColor(
{ name: 'Andariel', nameKey: 'Andariel', hp: 500, maxHp: 500, rank: 'boss', isBoss: true },
'zh',
)
expect(bossZh).toEqual({
displayName: '安達利爾',
paddedTitle: ' 安達利爾 ',
titleColor: 'gold',
})
})
it('builds 2-line subtext (0x6facfdd0): Undead/Demon/Animal, DescStr, Unique Modifiers, and 6-element Immunities with line promotion', () => {
// 1. Undead Unique with DescStr ('raises Fallen'), 2 modifiers, and Cold + Poison immunities
const undeadUnique: MonsterTargetHudInfo = {
name: 'Corpsefire',
nameKey: 'Corpsefire',
descKey: 'raises Fallen',
hp: 100,
maxHp: 100,
rank: 'unique',
isUndead: true,
isDemon: false,
modifiers: ['strong', 'spectralhit'],
resistances: {
cold: 100,
fire: 50,
lightning: 0,
poison: 120,
physical: 99,
magic: 0,
},
}
const subEn = buildMonsterHoverSubtext(undeadUnique, 'en')
expect(subEn.line1).toBe('Undead Extra Strong Spectral Hit Raises Fallen')
expect(subEn.line1Y).toBe(55)
expect(subEn.line2).toBe('Immune to Cold, Immune to Poison')
expect(subEn.line2Y).toBe(69)
const subZh = buildMonsterHoverSubtext(undeadUnique, 'zh')
expect(subZh.line1).toBe('不死的 特別強壯 幽靈一擊 復甦的沉淪魔')
expect(subZh.line2).toBe('冰冷系無效, 毒素系無效')
// 2. Animal/Beast (neither Undead nor Demon, no DescStr, normal rank) -> empty subtext when no immunities
const quillRat: MonsterTargetHudInfo = {
name: 'Quill Rat',
nameKey: 'QuillRat',
descKey: '',
hp: 10,
maxHp: 10,
rank: 'normal',
isUndead: false,
isDemon: false,
modifiers: [],
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 0, magic: 0 },
}
const emptySub = buildMonsterHoverSubtext(quillRat, 'en')
expect(emptySub.line1).toBe('')
expect(emptySub.line2).toBe('')
// 3. Animal/Beast with Physical + Magic immunity: Line 1 is empty, so Line 2 renders at y = 55 per 0x6fb70850!
const immuneBeast: MonsterTargetHudInfo = {
...quillRat,
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 100, magic: 100 },
}
const promotedEn = buildMonsterHoverSubtext(immuneBeast, 'en')
expect(promotedEn.line1).toBe('')
expect(promotedEn.line2).toBe('Immune to Physical, Immune to Magic')
expect(promotedEn.line2Y).toBe(55)
// 4. Verify all 6 immunity descriptors in canonical 1.13c .rdata:0x6FB834EC order
expect(CANONICAL_IMMUNITY_DESCRIPTORS.map(d => d.key)).toEqual([
'physical',
'magic',
'fire',
'lightning',
'cold',
'poison',
])
})
it('renders plaque backdrop at top=19 (h=17), title at y=32, line 1 at y=55, and line 2 at y=69 in WorldPanelsHud.drawMonsterTargetBar', () => {
const hud = new WorldPanelsHud()
const font = createMockFont(8)
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
const ctx = {
fillStyle: '',
fillRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) {
fillRectCalls.push({ x, y, w, h, fillStyle: this.fillStyle })
}),
} as unknown as CanvasRenderingContext2D
const target: MonsterTargetHudInfo = {
name: 'Bishibosh',
nameKey: 'Bishibosh',
descKey: 'FallenShamanDesc',
hp: 50,
maxHp: 100,
rank: 'unique',
isDemon: true,
isUndead: false,
modifiers: ['fireenchant', 'magicresistant'],
resistances: { fire: 100, cold: 0, lightning: 0, poison: 0, physical: 0, magic: 75 },
}
hud.drawMonsterTargetBar(ctx, target, font, {
leftPanelOpen: false,
rightPanelOpen: false,
canvasWidth: 800,
locale: 'en',
})
// Two fillRect calls: dark backdrop (w=128, h=17 at top=19) and crimson HP fill (w=64, h=17 at top=19)
expect(fillRectCalls).toHaveLength(2)
expect(fillRectCalls[0]).toEqual({
x: 400 - 64,
y: 19,
w: 128,
h: 17,
fillStyle: 'rgba(0, 0, 0, 0.72)',
})
expect(fillRectCalls[1]).toEqual({
x: 400 - 64,
y: 19,
w: 64,
h: 17,
fillStyle: 'rgba(156, 20, 20, 0.88)',
})
// Three drawText calls: title at y=32, line 1 at y=55, line 2 at y=69
expect(font.calls).toHaveLength(3)
expect(font.calls[0]!.y).toBe(32)
expect(font.calls[0]!.opts?.color).toBe('gold')
expect(font.calls[1]!.y).toBe(55)
expect(font.calls[1]!.opts?.font).toBe('font6')
expect(font.calls[2]!.y).toBe(69)
expect(font.calls[2]!.opts?.font).toBe('font6')
})
})
describe('R2: Authentic Mouse Hover Hit-Testing & Target Clearing in SceneMouseController', () => {
it('does NOT auto-populate targetMonster by proximity when cursor is not hovering over a monster', () => {
const { engine, hudManager, controller } = createHoverTestScene()
// Monster 0 is only 60px away from player (560, 500 vs 500, 500)
expect(
Math.hypot(
engine.world.monsters[0]!.x - engine.world.player.x,
engine.world.monsters[0]!.y - engine.world.player.y,
),
).toBe(60)
// Sync HUD without hovering any monster -> targetMonster MUST be null!
controller.syncMonsterTargetHud(engine.world.player.x, engine.world.player.y - 16)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('populates targetMonster on cursor hover over monster drawBounds and clears on move to empty ground, HUD, or pointerleave', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x // 500
const camY = engine.world.player.y - 16 // 484
// Monster 0 drawBounds: x=[544, 576], y=[450, 504]
// World (560, 480) -> screen (400 + (560 - 500) = 460, 300 + (480 - 484) = 296)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBe(engine.world.monsters[0])
expect(hudManager.targetMonster).not.toBeNull()
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
expect(hudManager.targetMonster?.isDemon).toBe(true)
expect(hudManager.targetMonster?.hp).toBe(20)
// Move cursor to empty ground at World (450, 450) -> screen (350, 266)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 350, clientY: 266, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Hover Monster 1 at World (650, 480) -> screen (550, 296)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 550, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBe(engine.world.monsters[1])
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
expect(hudManager.targetMonster?.isUndead).toBe(true)
expect(hudManager.targetMonster?.modifiers).toEqual(['strong', 'cursed'])
expect(hudManager.targetMonster?.resistances?.cold).toBe(100)
// Move cursor onto bottom HUD control bar (y = 580) -> clears hover
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 400, clientY: 580, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Hover Monster 0 again, then trigger pointerleave -> clears hover
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(hudManager.targetMonster).not.toBeNull()
canvas.dispatchEvent(new SyntheticPointerEvent('pointerleave', { clientX: 0, clientY: 0, buttons: 0 }))
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('selects foreground monster by isometric depth (higher y) when two monsters overlap and locks target during left-click hold', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x // 500
const camY = engine.world.player.y - 16 // 484
// Overlap Monster 0 (y = 490, background) and Monster 1 (y = 505, foreground) at world (560, 480)
engine.world.monsters[0]!.x = 560
engine.world.monsters[0]!.y = 490
engine.world.monsters[0]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
engine.world.monsters[1]!.x = 560
engine.world.monsters[1]!.y = 505
engine.world.monsters[1]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
const hit = controller.findMonsterAtWorld(560, 480)
expect(hit).toBe(engine.world.monsters[1]) // Foreground monster (higher y) wins
// Left-click hold on Monster 1 at screen (460, 296) -> locks onto Monster 1
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', { clientX: 460, clientY: 296, button: 0, buttons: 1 }),
)
expect(controller.lockedMonster).toBe(engine.world.monsters[1])
// Drag cursor away to empty ground while holding left button -> stays locked on Monster 1
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 300, clientY: 200, button: 0, buttons: 1 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.getActiveTargetMonster()).toBe(engine.world.monsters[1])
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
// Release left mouse button over empty ground -> clears lock and clears targetMonster
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerup', { clientX: 300, clientY: 200, button: 0, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.lockedMonster).toBeNull()
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('clears targetMonster immediately when hovered monster dies or becomes unkillable', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
// Hover Monster 0
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
// Kill Monster 0
engine.world.monsters[0]!.hp = 0
engine.world.monsters[0]!.state = 'dead'
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Verify monsterToTargetHudInfo maps all fields cleanly
const hudInfo = monsterToTargetHudInfo(engine.world.monsters[1]!)
expect(hudInfo).toMatchObject({
name: 'Zombie',
nameKey: 'Zombie',
isUndead: true,
isDemon: false,
rank: 'unique',
modifiers: ['strong', 'cursed'],
})
})
it('filters "dummy" DescStr (0x6fb70c0b) and resolves wMonUModStrTable[24] ("thief") plus short/numeric MonUMod keys', () => {
const dummyMonster: MonsterTargetHudInfo = {
name: 'Skeleton',
nameKey: 'Skeleton',
descKey: 'dummy',
subType: 'Dummy',
hp: 30,
maxHp: 30,
rank: 'normal',
isUndead: true,
isDemon: false,
}
const dummySub = buildMonsterHoverSubtext(dummyMonster, 'en')
expect(dummySub.line1).toBe('Undead')
expect(dummySub.line1Colored).toBe('Undead')
const thiefUnique: MonsterTargetHudInfo = {
name: 'Bone Ash',
nameKey: 'Bone Ash',
descKey: 'dummy',
hp: 150,
maxHp: 150,
rank: 'unique',
isUndead: true,
modifiers: ['thief', 'curse', 'resist', '18'],
}
const thiefEn = buildMonsterHoverSubtext(thiefUnique, 'en')
expect(thiefEn.line1).toBe('Undead Thief Cursed Magic Resistant Cold Enchanted')
const thiefZh = buildMonsterHoverSubtext(thiefUnique, 'zh')
expect(thiefZh.line1).toBe('不死的 小偷 特別詛咒 魔法抵抗 冰冷強化')
})
it('wraps multi-immunity Line 2 and clamps long modifier Line 1 within the open half-viewport when a side panel is open', () => {
const hud = new WorldPanelsHud()
const font = createMockFont(8)
const ctx = {
fillStyle: '',
fillRect: vi.fn(),
} as unknown as CanvasRenderingContext2D
const sixImmuneBoss: MonsterTargetHudInfo = {
name: 'Uber Boss',
nameKey: 'Diablo',
hp: 500,
maxHp: 500,
rank: 'unique',
isDemon: true,
modifiers: ['strong', 'fast', 'cursed', 'magicresistant', 'fireenchant', 'lightenchant', 'coldenchant', 'stoneskin'],
resistances: {
physical: 100,
magic: 100,
fire: 100,
lightning: 100,
cold: 100,
poison: 100,
},
}
hud.drawMonsterTargetBar(ctx, sixImmuneBoss, font, {
leftPanelOpen: true,
rightPanelOpen: false,
canvasWidth: 800,
locale: 'en',
})
// Active viewport is [400, 800] (width 400, maxSubtextWidth = 392)
// Title at y=32, clamped Line 1 at y=55, wrapped Line 2 across y=69, 83, 97
const line1Call = font.calls.find(c => c.y === 55)
expect(line1Call).toBeDefined()
const plainLine1 = line1Call!.text.replace(/ÿc[0-9a-z]/g, '')
expect(font.measureText(plainLine1, 'font6')).toBeLessThanOrEqual(392)
const immunityCalls = font.calls.filter(c => c.y >= 69)
expect(immunityCalls.length).toBeGreaterThanOrEqual(2)
expect(immunityCalls[0]!.y).toBe(69)
expect(immunityCalls[1]!.y).toBe(83)
for (const call of immunityCalls) {
const plain = call.text.replace(/ÿc[0-9a-z]/g, '')
expect(font.measureText(plain, 'font6')).toBeLessThanOrEqual(392)
}
})
it('preserves boss and superunique ranks in monsterToTargetHudInfo and uses getMonsterArtMap or boss fallback bounds before first render', () => {
const { engine, canvas, camera, hudManager, controller } = createHoverTestScene()
// Boss monster without drawBounds (before first render)
const bossMonster = engine.world.monsters[0]!
bossMonster.drawBounds = undefined
;(bossMonster as any).stats = {
...bossMonster.stats,
id: 'andariel',
name: 'Andariel',
nameKey: 'Andariel',
rank: 'boss',
isBoss: true,
isDemon: true,
}
const bossHud = monsterToTargetHudInfo(bossMonster)
expect(bossHud.rank).toBe('boss')
expect(bossHud.isBoss).toBe(true)
// 1. Without monsterArtMap, boss fallback bounds extend up to y - 96 (normal only extends to y - 64)
const controllerNoArt = new SceneMouseController({
canvas,
engine,
camera,
input: controller.input,
getRuntime: controller.getRuntime,
hudManager,
waypointNetwork: { activate: () => false } as any,
status: { textContent: '', hidden: false } as unknown as HTMLElement,
})
expect(controllerNoArt.findMonsterAtWorld(bossMonster.x, bossMonster.y - 85)).toBe(bossMonster)
// 2. With getMonsterArtMap, pre-render hit-testing uses frame 0 dimensions from monsterArtMap
const fakeArtMap = new Map([
[
'andariel',
{
token: 'AN',
handle: {} as any,
groups: [[{ x: 0, y: 0, width: 120, height: 150, anchorX: -60, anchorY: -140 }]],
directions: 8,
walkOffset: 0,
standOffset: 0,
walkFrames: 1,
standFrames: 1,
layerFailures: [],
},
],
])
const controllerWithArt = new SceneMouseController({
canvas,
engine,
camera,
input: controller.input,
getRuntime: controller.getRuntime,
hudManager,
waypointNetwork: { activate: () => false } as any,
status: { textContent: '', hidden: false } as unknown as HTMLElement,
getMonsterArtMap: () => fakeArtMap as any,
})
expect(controllerWithArt.findMonsterAtWorld(bossMonster.x - 55, bossMonster.y - 130)).toBe(bossMonster)
})
it('keeps attacking lockedMonster in tick() when cursor moves away during left-click hold and updates hudManager.targetMonster HP immediately after castSkill', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const monster0 = engine.world.monsters[0]!
// Place Monster 0 within melee reach (distance 30 <= reach)
monster0.x = 530
monster0.y = 500
monster0.drawBounds = { x: 510, y: 450, w: 40, h: 60 }
const castSpy = vi.spyOn(controller, 'castSkill').mockImplementation((_skillId, tx, ty) => {
if (tx === monster0.x && ty === monster0.y) {
monster0.hp = Math.max(0, monster0.hp - 7)
if (monster0.hp === 0) monster0.state = 'dead'
}
return true
})
// Left-click Monster 0 at screen (430, 296) -> world (530, 480)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', { clientX: 430, clientY: 296, button: 0, buttons: 1 }),
)
expect(controller.lockedMonster).toBe(monster0)
// HP on HUD must immediately reflect post-hit HP (20 - 7 = 13), not stale 20!
expect(hudManager.targetMonster?.hp).toBe(13)
// Drag cursor far away to screen (100, 100) while holding left button
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 100, clientY: 100, button: 0, buttons: 1 }),
)
engine.world.player.cooldown = 0
controller.tick()
// tick() must attack lockedMonster at (530, 500), NOT cursor at (200, 184)!
expect(castSpy).toHaveBeenLastCalledWith(expect.any(Number), 530, 500)
expect(hudManager.targetMonster?.hp).toBe(6)
// Third swing kills Monster 0 -> lockedMonster and hudManager.targetMonster clear immediately
engine.world.player.cooldown = 0
controller.tick()
expect(monster0.hp).toBe(0)
expect(controller.lockedMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('renders entire Subtext Line 1 (including Race and race-only Champions/Bosses) in rank color per 0x6fb719b0 and 0x6fb71a20', () => {
// 1. Unique Undead with modifiers + DescStr -> entire line1Colored starts with ÿc4 (gold)
const uniqueUndead = buildMonsterHoverSubtext(
{
name: 'Corpsefire',
nameKey: 'Corpsefire',
descKey: 'raises Fallen',
hp: 100,
maxHp: 100,
rank: 'unique',
isUndead: true,
modifiers: ['strong', 'spectralhit'],
},
'en',
)
expect(uniqueUndead.line1).toBe('Undead Extra Strong Spectral Hit Raises Fallen')
expect(uniqueUndead.line1Colored).toBe('ÿc4Undead Extra Strong Spectral Hit Raises Fallen')
// 2. Champion Demon without DescStr -> line1Colored is blue ('ÿc3Demon'), not white
const champFallen = buildMonsterHoverSubtext(
{
name: 'Fallen',
nameKey: 'Fallen',
descKey: 'dummy',
hp: 60,
maxHp: 60,
rank: 'champion',
isDemon: true,
},
'en',
)
expect(champFallen.line1).toBe('Demon')
expect(champFallen.line1Colored).toBe('ÿc3Demon')
// 3. Champion Demon with DescStr -> entire line1Colored is blue ('ÿc3Demon Raises Fallen')
const champShaman = buildMonsterHoverSubtext(
{
name: 'Fallen Shaman',
nameKey: 'FallenShaman',
descKey: 'raises Fallen',
hp: 90,
maxHp: 90,
rank: 'champion',
isDemon: true,
},
'en',
)
expect(champShaman.line1).toBe('Demon Raises Fallen')
expect(champShaman.line1Colored).toBe('ÿc3Demon Raises Fallen')
// 4. Act Boss (Andariel, descKey = 'dummy') -> line1Colored is gold ('ÿc4Demon')
const andarielSub = buildMonsterHoverSubtext(
{
name: 'Andariel',
nameKey: 'Andariel',
descKey: 'dummy',
hp: 500,
maxHp: 500,
rank: 'boss',
isBoss: true,
isDemon: true,
},
'en',
)
expect(andarielSub.line1).toBe('Demon')
expect(andarielSub.line1Colored).toBe('ÿc4Demon')
// 5. Minion Demon -> line1Colored is white ('Demon Minion')
const minionSub = buildMonsterHoverSubtext(
{
name: 'Fallen',
nameKey: 'Fallen',
hp: 30,
maxHp: 30,
rank: 'minion',
isDemon: true,
},
'en',
)
expect(minionSub.line1).toBe('Demon Minion')
expect(minionSub.line1Colored).toBe('Demon Minion')
})
it('resolves Champion subtype prefixes from numeric MonUMod IDs (16, 36..39), case-variants, and affixes fallback (.rdata:0x6fb86d64)', () => {
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['16'] },
'en',
).displayName,
).toBe('Champion Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['36'] },
'en',
).displayName,
).toBe('Ghostly Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['37'] },
'zh',
).displayName,
).toBe(`${lookupMonsterTblString('champfanaticX', 'zh')} 沈淪魔`)
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', modifiers: ['38'] },
'en',
).displayName,
).toBe('Possessed Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', affixes: ['39'] },
'en',
).displayName,
).toBe('Berserker Fallen')
expect(
resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 50, maxHp: 50, rank: 'champion', affixes: ['Berserker'] },
'zh',
).displayName,
).toBe(`${lookupMonsterTblString('champberserkX', 'zh')} 沈淪魔`)
})
it('demotes already-elite packs before promoting when rebudgetMonsterPacks is called with eliteMultiplier="all"', () => {
const normalBaseHp = 30
const alreadyChampionPack = {
members: [
{
id: 'fallen1',
name: 'Fallen',
level: 3, // base level 1 + 2 champion bonus
hp: normalBaseHp * 3, // 90 (already 3x champion HP)
damage: 5,
cooldownTicks: 15,
reach: 48,
aggroRadius: 200,
speed: 6,
xp: 25,
rank: 'champion' as const,
modifiers: ['ghostly'],
},
],
}
const rebudgeted = rebudgetMonsterPacks([alreadyChampionPack], {
levelId: 2,
densityMultiplier: 1,
eliteMultiplier: 'all',
seed: 777,
})
expect(rebudgeted).toHaveLength(1)
const leader = rebudgeted[0]!.members[0]!
// Leader HP must be 3x (90) or 4x (120) of normalBaseHp (30), never 9x (270) or 12x (360)!
expect(leader.hp === normalBaseHp * 3 || leader.hp === normalBaseHp * 4).toBe(true)
// 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()
})
})
})