/** * 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() }) }) })