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

806 lines
30 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,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import {
readMonsterKinds,
readSuperUniques,
monsterStatsOf,
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'],
})
})
})
})