diablo2-web/tests/ui-hud.test.ts

1092 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit & Parity Tests for Diablo II v1.13c Complete UI / HUD (Issue #27).
*/
import { describe, expect, it } from 'vitest'
import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { D2FontRenderer, parseColoredText, parseFontTblMetrics, D2_COLOR_HEX } from '../src/client/ui/font.ts'
import { GlobesHud, LIFE_GLOBE_BOUNDS, MANA_GLOBE_BOUNDS } from '../src/client/ui/globes.ts'
import { BeltHud, BELT_COLS, BELT_ROWS, BELT_BASE_X } from '../src/client/ui/belt.ts'
import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/client/ui/hotkeys.ts'
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
STAMINA_BAR_BOUNDS,
} from '../src/client/ui/control-bar.ts'
import { InventoryPanel, INV_PANEL_ORIGIN, INV_GRID_ORIGIN, INV_GOLD_BTN_BOUNDS, INV_GOLD_BOX_RECESS, EQUIP_SLOTS_LAYOUT, resolveItemSpriteRect } from '../src/client/ui/inventory.ts'
import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/client/ui/character-sheet.ts'
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/client/ui/skill-tree-panel.ts'
import {
WorldPanelsHud,
ACT_WAYPOINTS,
ALL_QUESTS,
ACT_QUESTS,
findWaypointBySlug,
CUBE_GRID_ORIGIN,
STASH_GRID_ORIGIN,
} from '../src/client/ui/world-panels.ts'
import { BAKED_UI_MANIFEST } from '../src/common/items/baked-ui-meta.ts'
import { HudManager, computeHudLayout } from '../src/client/ui/hud-manager.ts'
import { createDemoHotkeys, createDemoInventory } from './fixtures/hud-demo-state.ts'
import {
getLevelNames,
sceneNameZh,
sceneNameEn,
LEVEL_NAMES_ZH,
LEVEL_NAMES_EN,
} from '../src/common/world/level-names-zh.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/common/skills/skill-calc-engine.ts'
import { DEMO_BAG_ITEMS, DEMO_EQUIPPED_GEAR, seedDemoBeltPotions } from './fixtures/demo-profile-data.ts'
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
const manifestPath = join(process.cwd(), 'public', 'ui', 'manifest.json')
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
expect(manifest.dc6DecodeFailures).toBe(0)
expect(manifest.decodedDc6Count).toBeGreaterThanOrEqual(400)
expect(Object.keys(manifest.itemRects).length).toBeGreaterThanOrEqual(300)
})
it('parses Woo!\\x01 font tbl metrics and ÿcX color codes accurately', () => {
// Construct a synthetic 3596-byte Woo!\x01 tbl
const tbl = new Uint8Array(3596)
tbl.set([87, 111, 111, 33, 1, 0, 0, 0, 0, 1, 10, 20], 0)
// Character 'A' (65): width 12, height 10, frameIndex 65
const offA = 12 + 65 * 14
tbl[offA + 0] = 65
tbl[offA + 3] = 12
tbl[offA + 4] = 10
tbl[offA + 8] = 65
const parsed = parseFontTblMetrics(tbl)
expect(parsed.lineHeight).toBe(10)
expect(parsed.capHeight).toBe(20)
expect(parsed.widths[65]).toBe(12)
expect(parsed.heights[65]).toBe(10)
expect(parsed.frameIndices[65]).toBe(65)
const spans = parseColoredText('ÿc4Harlequin Crestÿc0 (ÿc3+2 To All Skillsÿc0)')
expect(spans).toEqual([
{ text: 'Harlequin Crest', color: 'gold' },
{ text: ' (', color: 'white' },
{ text: '+2 To All Skills', color: 'blue' },
{ text: ')', color: 'white' },
])
expect(D2_COLOR_HEX.gold).toBe('#c8a15a')
})
it('enforces D2Client.dll 800x600 panel and control bar coordinates', () => {
// Left Dock (80, 60, 320x432) + Right Dock (400, 60, 320x441 per inventory.txt)
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
// Left (117..165) & Right (635..683) 48x48 Skill Slots per D2Client.dll 0x6fad7440..0x6fad7536
expect(LEFT_SKILL_BOUNDS).toEqual({ x: 117, y: 551, width: 48, height: 48 })
expect(RIGHT_SKILL_BOUNDS).toEqual({ x: 635, y: 551, width: 48, height: 48 })
// Life (x=28, y=515, 80x80) & Mana (x=692, y=515, 80x80) Globes
expect(LIFE_GLOBE_BOUNDS).toEqual({ x: 28, y: 515, width: 80, height: 80 })
expect(MANA_GLOBE_BOUNDS).toEqual({ x: 692, y: 515, width: 80, height: 80 })
expect(BELT_BASE_X).toBe(423)
// Experience Bar (Issue #146: groove at x=256, y=560, w=120, h=4 in 800CtrlPnl7)
expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 })
expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 })
expect(STAMINA_BAR_BOUNDS).toEqual({ x: 273, y: 573, width: 102, height: 18 })
})
it('aligns Experience Bar to authentic 1.13c groove and handles hover hit-testing (Issue #146)', () => {
expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 })
expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 })
// 10 segments of 12px width = 120px total width
expect(EXP_BAR_BOUNDS.width).toBe(120)
const segmentWidth = EXP_BAR_BOUNDS.width / 10
expect(segmentWidth).toBe(12)
// 9 divider ticks at x = 256 + i * 12 (i = 1..9)
const tickXs = Array.from({ length: 9 }, (_, i) => EXP_BAR_BOUNDS.x + (i + 1) * 12)
expect(tickXs).toEqual([268, 280, 292, 304, 316, 328, 340, 352, 364])
const bar = new ControlBarHud()
// Test start of groove (256, 560)
bar.handleMouseMove(256, 560)
expect(bar.hoveredElement).toBe('exp')
// Test throughout the groove
bar.handleMouseMove(256 + 60, 562)
expect(bar.hoveredElement).toBe('exp')
// Test end of groove (376, 563)
bar.handleMouseMove(256 + 120, 563)
expect(bar.hoveredElement).toBe('exp')
// Test padded hover bounds: y = 558 and y = 566
bar.handleMouseMove(256, 558)
expect(bar.hoveredElement).toBe('exp')
bar.handleMouseMove(376, 566)
expect(bar.hoveredElement).toBe('exp')
// Outside bounds
bar.handleMouseMove(255, 560)
expect(bar.hoveredElement).toBeNull()
bar.handleMouseMove(377, 560)
expect(bar.hoveredElement).toBeNull()
bar.handleMouseMove(300, 557)
expect(bar.hoveredElement).toBeNull()
bar.handleMouseMove(300, 567)
expect(bar.hoveredElement).toBeNull()
})
it('computes vertical bottom-up liquid clipping for Life & Mana globes', () => {
const half = GlobesHud.computeClipRect(LIFE_GLOBE_BOUNDS, 500, 1000)
expect(half.ratio).toBe(0.5)
expect(half.clipH).toBe(40)
expect(half.srcY).toBe(40)
expect(half.dstY).toBe(515 + 40)
const empty = GlobesHud.computeClipRect(MANA_GLOBE_BOUNDS, 0, 940)
expect(empty.clipH).toBe(0)
expect(empty.dstY).toBe(515 + 80)
})
it('supports 4x4 belt drinking with automatic column gravity refill', () => {
const belt = new BeltHud()
// The belt starts empty (server-authoritative); seed the legacy 16-potion layout explicitly.
expect(belt.countTotalPotions()).toBe(0)
seedDemoBeltPotions(belt)
expect(belt.countTotalPotions()).toBe(BELT_COLS * BELT_ROWS)
// Drink column 0 bottom potion: rows 1, 2, 3 should shift down to 0, 1, 2, and row 3 becomes null
const consumed = belt.useSlot(0)
expect(consumed).not.toBeNull()
expect(consumed?.kind).toBe('hp')
expect(belt.grid[0]![0]).not.toBeNull()
expect(belt.grid[1]![0]).not.toBeNull()
expect(belt.grid[2]![0]).not.toBeNull()
expect(belt.grid[3]![0]).toBeNull()
expect(belt.countTotalPotions()).toBe(15)
// Issue #425: 2-pass autoPlacePotion / autoPlacePotionSlot / tryAutoStore (INVENTORY_GetFreeBeltSlot parity)
// Pass 1: stacks any tier of Healing Potion (hp1..hp5) into column 0 row 3 (matching 'hp' kind)
const placedPass1 = belt.autoPlacePotionSlot({ code: 'hp1', name: 'Minor Healing Potion' })
expect(placedPass1).toMatchObject({ col: 0, row: 3 })
expect(belt.grid[3]![0]?.code).toBe('hp1')
expect(belt.countTotalPotions()).toBe(16)
// When all 16 slots are full, autoPlacePotion returns false so pickup falls back to inventory
expect(belt.autoPlacePotion({ code: 'hp5', name: 'Super Healing Potion' })).toBe(false)
// Clear belt and verify Pass 2 (empty row 0) + Pass 1 (stacking above row 0)
belt.clear()
expect(belt.countTotalPotions()).toBe(0)
expect(belt.autoPlacePotionSlot({ code: 'vps', name: 'Stamina Potion' })).toMatchObject({ col: 0, row: 0 })
expect(belt.autoPlacePotion({ code: 'vps', name: 'Stamina Potion' })).toBe(true)
expect(belt.grid[1]![0]?.code).toBe('vps')
// Antidote does not match Stamina in col 0, so Pass 2 places it in empty col 1 row 0
expect(belt.autoPlacePotionSlot({ code: 'yps', name: 'Antidote Potion' })).toMatchObject({ col: 1, row: 0 })
// Beltable scrolls ('tsc', 'isc' per 1.13c Misc.txt belt=1) return true; non-beltable throwing potions return false
expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(true)
expect(belt.autoPlacePotion({ code: 'gpm', name: 'Rancid Gas Potion' })).toBe(false)
})
it('handles 10x4 inventory grid pickup, placement, swap, equipment slots, and I/II weapon swap', () => {
// The panel starts empty (server-authoritative); seed the legacy starter gear explicitly.
expect(new InventoryPanel().gridItems).toEqual([])
const inv = createDemoInventory()
expect(INV_GRID_ORIGIN.cols).toBe(10)
expect(INV_GRID_ORIGIN.rows).toBe(4)
expect(Object.keys(EQUIP_SLOTS_LAYOUT)).toHaveLength(10)
// Pick up Horadric Cube at (0, 0)
expect(inv.cursorItem).toBeNull()
expect(inv.clickGridCell(0, 0)).toBe(true)
expect(inv.cursorItem?.code).toBe('box')
// Place Horadric Cube back into free space at (5, 0)
expect(inv.clickGridCell(5, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(inv.gridItems.some(p => p.item.code === 'box' && p.col === 5 && p.row === 0)).toBe(true)
// Pick up equipped weapon1 (The Oculus) and swap with Call to Arms in bag (8, 0)
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.cursorItem?.code).toBe('oba')
expect(inv.clickGridCell(8, 0)).toBe(true)
expect(inv.cursorItem?.code).toBe('crs') // Call to Arms is now on cursor!
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(inv.equipped.weapon1?.code).toBe('crs')
// Toggle Weapon Swap Set I <-> II
expect(inv.weaponSwapSet).toBe(0)
inv.toggleWeaponSwap()
expect(inv.weaponSwapSet).toBe(1)
expect(inv.swapWeapons.weapon1?.code).toBe('crs')
})
it('starts with neutral (server-not-ready) character and skill state', () => {
const char = new CharacterSheetPanel()
expect(char.classCode).toBeNull()
expect(char.attrs.statPoints).toBe(0)
expect(char.attrs.level).toBe(0)
const tree = new SkillTreePanel()
expect(tree.characterClass).toBeNull()
expect(tree.unspentSkillPoints).toBe(0)
expect(tree.getSkillTree()).toEqual([])
// Without unspent points the 1.13c allocation rule rejects the request; no local mutation API exists.
tree.setClass('sor')
tree.characterLevel = 1
expect(tree.canAllocate(39)).toBe(false)
tree.unspentSkillPoints = 1
expect(tree.canAllocate(39)).toBe(true)
expect(tree.getHardPoints(39)).toBe(0)
})
it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => {
// No F-key bindings until seeded (server / user); seed the legacy F1..F8 layout explicitly.
expect(new SkillHotkeysHud().triggerFunctionKey('F2')).toBeNull()
const hotkeys = createDemoHotkeys()
expect(hotkeys.triggerFunctionKey('F2')).toEqual({ side: 'right', skillId: 59 })
expect(hotkeys.rightSkillId).toBe(59) // Blizzard
const worldPanels = new WorldPanelsHud()
expect(ACT_WAYPOINTS[1]!.length).toBe(9)
expect(ACT_WAYPOINTS[5]!.length).toBe(9)
worldPanels.showAreaBanner('罗格营地', 'Rogue Encampment', 1000)
expect(worldPanels.areaBanner).toBeNull()
})
it('enforces 1:1 uniform aspect ratio HUD scaling without horizontal distortion (Issue #136)', () => {
// 16:9 Widescreen (1920x1080)
const layout1080p = computeHudLayout(1920, 1080)
expect(layout1080p.uiScale).toBeCloseTo(1.8, 4)
expect(layout1080p.offsetX).toBeCloseTo(240, 4)
expect(layout1080p.offsetY).toBeCloseTo(0, 4)
// 21:9 Ultrawide (2560x1080)
const layoutUltrawide = computeHudLayout(2560, 1080)
expect(layoutUltrawide.uiScale).toBeCloseTo(1.8, 4)
expect(layoutUltrawide.offsetX).toBeCloseTo(560, 4)
expect(layoutUltrawide.offsetY).toBeCloseTo(0, 4)
// 4:3 Native (800x600)
const layoutNative = computeHudLayout(800, 600)
expect(layoutNative.uiScale).toBe(1.0)
expect(layoutNative.offsetX).toBe(0)
expect(layoutNative.offsetY).toBe(0)
// 4:3 Retina (1600x1200)
const layoutRetina = computeHudLayout(1600, 1200)
expect(layoutRetina.uiScale).toBe(2.0)
expect(layoutRetina.offsetX).toBe(0)
expect(layoutRetina.offsetY).toBe(0)
// Test CDP verification window size (1280x840)
const layoutBrowser = computeHudLayout(1280, 840)
expect(layoutBrowser.uiScale).toBeCloseTo(1.4, 4)
expect(layoutBrowser.offsetX).toBeCloseTo(80, 4)
expect(layoutBrowser.offsetY).toBeCloseTo(0, 4)
// Portrait / Tall Viewport (800x1000)
const layoutPortrait = computeHudLayout(800, 1000)
expect(layoutPortrait.uiScale).toBe(1.0)
expect(layoutPortrait.offsetX).toBe(0)
expect(layoutPortrait.offsetY).toBe(400) // Anchored to bottom
})
it('maps client mouse coordinates to logical 800x600 viewport and filters out-of-bounds clicks', () => {
const mockCanvas = {
clientWidth: 1920,
clientHeight: 1080,
width: 1920,
height: 1080,
style: { pointerEvents: 'none' },
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1920, height: 1080 }),
getContext: () => null,
addEventListener: () => {},
} as unknown as HTMLCanvasElement
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
// Center of screen (960, 540) -> logical (400, 300)
const center = hud.clientToLogical(960, 540)
expect(center.x).toBeCloseTo(400, 2)
expect(center.y).toBeCloseTo(300, 2)
// Left border of 800x600 HUD (240, 0) -> logical (0, 0)
const topLeft = hud.clientToLogical(240, 0)
expect(topLeft.x).toBeCloseTo(0, 2)
expect(topLeft.y).toBeCloseTo(0, 2)
// Right border of 800x600 HUD (1680, 1080) -> logical (800, 600)
const bottomRight = hud.clientToLogical(1680, 1080)
expect(bottomRight.x).toBeCloseTo(800, 2)
expect(bottomRight.y).toBeCloseTo(600, 2)
// Left widescreen margin (100, 500) -> logical x < 0
const leftMargin = hud.clientToLogical(100, 500)
expect(leftMargin.x).toBeLessThan(0)
expect(hud.isPointInterceptedByHud(leftMargin.x, leftMargin.y)).toBe(false)
// Right widescreen margin (1800, 500) -> logical x > 800
const rightMargin = hud.clientToLogical(1800, 500)
expect(rightMargin.x).toBeGreaterThan(800)
expect(hud.isPointInterceptedByHud(rightMargin.x, rightMargin.y)).toBe(false)
// Control bar at bottom (logical y >= 540) is intercepted
expect(hud.isPointInterceptedByHud(400, 550)).toBe(true)
// Life globe (logical x <= 117, y >= 496) is intercepted
expect(hud.isPointInterceptedByHud(50, 520)).toBe(true)
// Mana globe (logical x >= 683, y >= 496) is intercepted
expect(hud.isPointInterceptedByHud(720, 520)).toBe(true)
// Empty game world in center (logical 400, 300) is NOT intercepted
expect(hud.isPointInterceptedByHud(400, 300)).toBe(false)
})
it('enforces pixel-exact stone recess alignment for Character Sheet (Issue #138)', () => {
// 1. Panel Origin & Close Button
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
expect(CHAR_CLOSE_BTN_BOUNDS).toEqual({ x: 208, y: 448, w: 32, h: 32 })
// 2. Stat Allocation Buttons (30x30 buttons centered in engraved stone recesses)
expect(STAT_ALLOC_BUTTONS.str).toEqual({ x: 202, y: 138, w: 30, h: 30 })
expect(STAT_ALLOC_BUTTONS.dex).toEqual({ x: 202, y: 200, w: 30, h: 30 })
expect(STAT_ALLOC_BUTTONS.vit).toEqual({ x: 202, y: 286, w: 30, h: 30 })
expect(STAT_ALLOC_BUTTONS.ene).toEqual({ x: 202, y: 348, w: 30, h: 30 })
})
it('defines all 39 canonical waypoints across Acts 1..5 with valid levelId and waypointId (Issue #137)', () => {
// Act counts: Act 1 (9), Act 2 (9), Act 3 (9), Act 4 (3), Act 5 (9) = 39 total
expect(ACT_WAYPOINTS[1]!.length).toBe(9)
expect(ACT_WAYPOINTS[2]!.length).toBe(9)
expect(ACT_WAYPOINTS[3]!.length).toBe(9)
expect(ACT_WAYPOINTS[4]!.length).toBe(3)
expect(ACT_WAYPOINTS[5]!.length).toBe(9)
let totalWaypoints = 0
const waypointIds = new Set<number>()
for (let act = 1; act <= 5; act++) {
const wps = ACT_WAYPOINTS[act]!
totalWaypoints += wps.length
for (const wp of wps) {
expect(wp.act).toBe(act)
expect(wp.levelId).toBeGreaterThan(0)
expect(wp.waypointId).toBeGreaterThanOrEqual(0)
expect(wp.waypointId).toBeLessThanOrEqual(38)
expect(wp.slug).toBeTruthy()
expect(wp.nameZh).toBeTruthy()
expect(wp.nameEn).toBeTruthy()
expect(waypointIds.has(wp.waypointId)).toBe(false)
waypointIds.add(wp.waypointId)
}
}
expect(totalWaypoints).toBe(39)
expect(waypointIds.size).toBe(39)
// Verify findWaypointBySlug
const rogue = findWaypointBySlug('rogue-encampment')
expect(rogue).toBeDefined()
expect(rogue?.levelId).toBe(1)
expect(rogue?.waypointId).toBe(0)
const coldPlains = findWaypointBySlug('cold-plains')
expect(coldPlains).toBeDefined()
expect(coldPlains?.levelId).toBe(3)
expect(coldPlains?.waypointId).toBe(1)
const pandemonium = findWaypointBySlug('the-pandemonium-fortress')
expect(pandemonium).toBeDefined()
expect(pandemonium?.levelId).toBe(103)
expect(pandemonium?.waypointId).toBe(27)
const hallsOfPain = findWaypointBySlug('halls-of-pain')
expect(hallsOfPain).toBeDefined()
expect(hallsOfPain?.levelId).toBe(123)
expect(hallsOfPain?.waypointId).toBe(35)
})
it('resolves authentic DC6 item sprites via 5-tier fallback chain without wireframes (Issue #139)', () => {
const itemRects = BAKED_UI_MANIFEST.itemRects
// Starter equipped items must all resolve to valid sprite rects
for (const [slot, item] of Object.entries(DEMO_EQUIPPED_GEAR)) {
if (!item) continue
const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Equipped item ${slot} (${item.code}) should resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0)
expect(rect!.h).toBeGreaterThan(0)
}
// Starter bag items must all resolve to valid sprite rects
for (const placement of DEMO_BAG_ITEMS) {
const item = placement.item
const rect = resolveItemSpriteRect(item, itemRects)
expect(rect, `Bag item ${item.name} (${item.code}) should resolve`).not.toBeNull()
expect(rect!.w).toBeGreaterThan(0)
expect(rect!.h).toBeGreaterThan(0)
}
// Tier 1: Direct invFile match
const directItem = { id: 'test1', code: 'cap', name: 'Cap', invFile: 'invcap', width: 1, height: 1 }
expect(resolveItemSpriteRect(directItem, itemRects)).toEqual(itemRects['invcap'])
// Tier 2: Item name lookup in codeToInvFile
const nameItem = { id: 'test2', code: 'unknown', name: 'Annihilus', invFile: 'invalid_inv_file', width: 1, height: 1 }
const nameRect = resolveItemSpriteRect(nameItem, itemRects)
expect(nameRect).not.toBeNull()
expect(nameRect!.w).toBeGreaterThan(0)
// Tier 3: Item code lookup in codeToInvFile
const codeItem = { id: 'test3', code: 'cm1', name: 'Unknown Charm', invFile: 'invalid_inv_file', width: 1, height: 1 }
const codeRect = resolveItemSpriteRect(codeItem, itemRects)
expect(codeRect).not.toBeNull()
expect(codeRect!.w).toBeGreaterThan(0)
// Tier 4: Canonical 1.13c 3-letter item codes (e.g. crs -> invcrs, tbk -> invbbk, box -> invbox)
const crsItem = { id: 'test4', code: 'crs', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
expect(resolveItemSpriteRect(crsItem, itemRects)).toEqual(itemRects['invcrs'])
const cubeItem = { id: 'test5', code: 'box', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
// Unknown items without valid invFile or canonical code return null (no fake dimension fallback)
const fallback1x1 = { id: 'test6', code: 'unknown_code', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 }
const fb1 = resolveItemSpriteRect(fallback1x1, itemRects)
expect(fb1).toBeNull()
const fallback2x4 = { id: 'test7', code: 'unknown_code', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 }
const fb2 = resolveItemSpriteRect(fallback2x4, itemRects)
expect(fb2).toBeNull()
})
it('maps all 27 canonical quests with authentic DC6 icons and Act 4 socket guard (Issue #140)', () => {
// Verify canonical quest counts across acts: 6, 6, 6, 3, 6 = 27
expect(ACT_QUESTS[1]!.length).toBe(6)
expect(ACT_QUESTS[2]!.length).toBe(6)
expect(ACT_QUESTS[3]!.length).toBe(6)
expect(ACT_QUESTS[4]!.length).toBe(3)
expect(ACT_QUESTS[5]!.length).toBe(6)
expect(ALL_QUESTS.length).toBe(27)
const questRects = BAKED_UI_MANIFEST.questRects
expect(Object.keys(questRects).length).toBeGreaterThan(0)
// All 27 quests must have valid active sprite rects and completed sprite rects in baked manifest
for (const q of ALL_QUESTS) {
expect(q.id).toMatch(/^a[1-5]q[1-6]$/)
expect(q.titleZh).toBeTruthy()
expect(q.titleEn).toBeTruthy()
// Active icon rect
const activeRect = questRects[q.id]
expect(activeRect, `Active quest icon for ${q.id} should exist`).toBeDefined()
expect(activeRect.w).toBeGreaterThan(0)
expect(activeRect.h).toBeGreaterThan(0)
// Completed icon rect
const doneRect = questRects[`questdone_${q.id}`] ?? questRects[q.id]
expect(doneRect, `Completed quest icon for ${q.id} should exist`).toBeDefined()
expect(doneRect.w).toBeGreaterThan(0)
expect(doneRect.h).toBeGreaterThan(0)
}
// Act 4 socket guard: Act 4 has exactly 3 quests, sockets 3..5 are unassigned/empty
const act4Quests = ACT_QUESTS[4]!
expect(act4Quests).toHaveLength(3)
expect(act4Quests[0]!.id).toBe('a4q1')
expect(act4Quests[1]!.id).toBe('a4q2')
expect(act4Quests[2]!.id).toBe('a4q3')
})
it('loads localized map area names from tbl with 1.13c Act 5 alignment and slug protection (Issue #147)', () => {
// 1. Specific key areas required by Issue #147 and v1.13c tbl
expect(getLevelNames(1)).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' })
expect(getLevelNames(2)).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' })
expect(getLevelNames(17)).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
// 2. Act 5 (109..136) alignment against string.tbl / expansionstring.tbl
expect(getLevelNames(109)).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' })
expect(getLevelNames(118)).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" })
expect(getLevelNames(119)).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' })
expect(getLevelNames(120)).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' })
expect(getLevelNames(121)).toEqual({ nameZh: '尼拉塞克的神殿', nameEn: "Nihlathak's Temple" })
expect(getLevelNames(122)).toEqual({ nameZh: '悲痛之厅', nameEn: 'Halls of Anguish' })
expect(getLevelNames(123)).toEqual({ nameZh: '痛苦之厅', nameEn: 'Halls of Pain' })
expect(getLevelNames(124)).toEqual({ nameZh: '沃德之厅', nameEn: 'Halls of Vaught' })
expect(getLevelNames(131)).toEqual({ nameZh: '毁灭王座', nameEn: 'Throne of Destruction' })
expect(getLevelNames(132)).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' })
expect(getLevelNames(133)).toEqual({ nameZh: '主母巢穴', nameEn: "Matron's Den" })
expect(getLevelNames(134)).toEqual({ nameZh: '遗忘沙漠', nameEn: 'Forgotten Sands' })
expect(getLevelNames(135)).toEqual({ nameZh: '痛苦熔炉', nameEn: 'Furnace of Pain' })
expect(getLevelNames(136)).toEqual({ nameZh: '崔斯特姆', nameEn: 'Tristram' })
// 3. Complete 136 level IDs validity: no empty strings, no raw hyphen slugs
for (let id = 1; id <= 136; id++) {
const names = getLevelNames(id)
expect(names.nameZh).toBeTruthy()
expect(names.nameEn).toBeTruthy()
expect(names.nameZh).not.toContain('-')
expect(names.nameEn).not.toContain('-')
expect(LEVEL_NAMES_ZH[id]).toBe(names.nameZh)
expect(LEVEL_NAMES_EN[id]).toBe(names.nameEn)
}
// 4. Slug resolution: both un-prefixed and numbered prefixes resolve cleanly
expect(getLevelNames('burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
expect(getLevelNames('17-burial-grounds')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
expect(getLevelNames(' 17-burial-grounds ')).toEqual({ nameZh: '埋骨之地', nameEn: 'Burial Grounds' })
expect(getLevelNames('rogue-encampment')).toEqual({ nameZh: '罗格营地', nameEn: 'Rogue Encampment' })
expect(getLevelNames('blood-moor')).toEqual({ nameZh: '鲜血荒地', nameEn: 'Blood Moor' })
expect(getLevelNames('harrogath')).toEqual({ nameZh: '哈洛加斯', nameEn: 'Harrogath' })
expect(getLevelNames('the-ancients-way')).toEqual({ nameZh: '远古之路', nameEn: "The Ancients' Way" })
expect(getLevelNames('icy-cellar')).toEqual({ nameZh: '冰窖', nameEn: 'Icy Cellar' })
expect(getLevelNames('arreat-summit')).toEqual({ nameZh: '亚瑞特山顶', nameEn: 'Arreat Summit' })
expect(getLevelNames('the-pandemonium-fortress')).toEqual({ nameZh: '群魔堡垒', nameEn: 'Pandemonium Fortress' })
expect(getLevelNames('THE-CHAOS-SANCTUARY')).toEqual({ nameZh: '混沌避难所', nameEn: 'Chaos Sanctuary' })
expect(getLevelNames('the-worldstone-chamber')).toEqual({ nameZh: '世界之石大殿', nameEn: 'Worldstone Chamber' })
// 5. sceneNameZh and sceneNameEn parity and slug leak prevention
expect(sceneNameZh(17)).toBe('埋骨之地')
expect(sceneNameZh('burial-grounds')).toBe('埋骨之地')
expect(sceneNameZh('17-burial-grounds')).toBe('埋骨之地')
expect(sceneNameEn(17)).toBe('Burial Grounds')
expect(sceneNameEn('burial-grounds')).toBe('Burial Grounds')
expect(sceneNameEn('17-burial-grounds')).toBe('Burial Grounds')
// 6. Unknown fallback must never produce raw un-spaced lowercase slugs
const unknownFallback = getLevelNames('unknown-custom-dungeon')
expect(unknownFallback.nameZh).toBe('Unknown Custom Dungeon')
expect(unknownFallback.nameEn).toBe('Unknown Custom Dungeon')
expect(sceneNameZh('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon')
expect(sceneNameEn('unknown-custom-dungeon')).toBe('Unknown Custom Dungeon')
// 7. Area banner display logic in WorldPanelsHud (no-op in 1.13c per Issue #673)
const worldPanels = new WorldPanelsHud()
const now = 1000
const bgNames = getLevelNames(17)
worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now)
expect(worldPanels.areaBanner).toBeNull()
// 8. Safe fallbacks for null/undefined/empty inputs without throwing TypeError
expect(getLevelNames(null as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' })
expect(getLevelNames(undefined as any)).toEqual({ nameZh: 'Unknown Area', nameEn: 'Unknown Area' })
expect(sceneNameZh(null as any)).toBe('Unknown Area')
expect(sceneNameEn(null as any)).toBe('Unknown Area')
})
it('renders authentic multi-line skill tooltips with 1.13c next-level preview and styling rules (Issue #152)', () => {
// 1. Unlearned skill with requirements not met (Sorceress lvl 1 looking at Fire Ball lvl 12)
const sorcBuild = createInitialState('sor')
const unlearnedFireBall = buildTooltipViewModel(sorcBuild, 47, 1) // Fire Ball requires clvl 12, Fire Bolt
expect(unlearnedFireBall).not.toBeNull()
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
// Title: Fire Ball in Gold
expect(unlearnedLines[0]).toEqual({ text: '火球', color: 'gold', font: 'fontexocet10', align: 'center' })
const unlearnedLinesEn = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!, 'ENG')
expect(unlearnedLinesEn[0]).toEqual({ text: 'Fire Ball', color: 'gold', font: 'fontexocet10', align: 'center' })
// Required Level: red when character level < reqlevel
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要等級:12'))
expect(reqLevelLine).toBeDefined()
expect(reqLevelLine?.color).toBe('red')
// Prerequisites: red when unmet
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
expect(prereqLine).toBeDefined()
expect(prereqLine?.color).toBe('red')
// Unlearned skill MUST NOT include 下一等級 preview
expect(unlearnedLines.some(l => l.text.includes('下一等級'))).toBe(false)
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
expect(fireBolt).not.toBeNull()
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
// When level is met, reqlevel line is omitted from tooltip
expect(fireBoltLines.some(l => l.text.includes('需要等級'))).toBe(false)
expect(fireBoltLines.some(l => l.text.includes('下一等級'))).toBe(false)
// 3. Learned skill (slvl = 1) with next-level preview in green
const learnedBuild = createInitialState('sor')
allocatePoint(learnedBuild, 36) // Fire Bolt +1
const learnedFireBolt = buildTooltipViewModel(learnedBuild, 36, 1)
expect(learnedFireBolt).not.toBeNull()
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!)
const currLevelLine = learnedLines.find(l => l.text.includes('目前技能等級:1'))
expect(currLevelLine).toBeDefined()
expect(currLevelLine?.color).toBe('white')
const nextLevelHeader = learnedLines.find(l => l.text.includes('下一等級'))
expect(nextLevelHeader).toBeDefined()
expect(nextLevelHeader?.color).toBe('green')
// Next level damage preview in green
const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
expect(nextLevelDmg).toBeDefined()
// 4. Maxed skill (hardPoints === 20): omits 下一等級
const maxedBuild = createInitialState('sor')
allocatePoint(maxedBuild, 36, 20)
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
expect(maxedFireBolt).not.toBeNull()
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
expect(maxedLines.some(l => l.text.includes('下一等級'))).toBe(false)
// 5. Synergy section formatting
const fireballBuild = createInitialState('sor')
allocatePoint(fireballBuild, 36) // Fire Bolt +1
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!)
const synHeader = fbLines.find(l => l.text.includes('額外的對'))
expect(synHeader).toBeDefined()
expect(synHeader?.color).toBe('white')
const synFireBolt = fbLines.find(l => l.text.includes('火彈: +14% 每等級傷害'))
expect(synFireBolt).toBeDefined()
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point)
const synMeteor = fbLines.find(l => l.text.includes('隕石: +14% 每等級傷害'))
expect(synMeteor).toBeDefined()
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points)
})
it('clamps tooltip placement within canvas viewport and strictly above the control bar (Issue #152)', () => {
const dummyLines = [
{ text: '火球', color: 'white' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '当前等级: 1', color: 'white' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 6 - 14', color: 'blue' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[下一等级] (Lv 2)', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '火焰伤害: 10 - 20', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
{ text: '[协同技能加成]', color: 'blue' as const, font: 'fontexocet10' as const, align: 'center' as const },
{ text: '• 火弹: 每个有效技能点增加 14% 伤害 (当前 +14%)', color: 'blue' as const, font: 'font8' as const, align: 'left' as const },
{ text: '• 陨石: 每个有效技能点增加 14% 伤害', color: 'gray' as const, font: 'font8' as const, align: 'left' as const },
]
const mockFont = {
measureText: (txt: string) => txt.length * 8,
} as any
// Test extreme positions across the 800x600 screen
const testPositions = [
{ x: 420, y: 80, w: 48, h: 48 }, // Top-left node in right-dock panel
{ x: 700, y: 120, w: 48, h: 48 }, // Far-right node
{ x: 450, y: 400, w: 48, h: 48 }, // Bottom node
{ x: 500, y: 500, w: 48, h: 48 }, // Extreme bottom node near control bar
{ x: 50, y: 200, w: 48, h: 48 }, // Far left node
]
for (const pos of testPositions) {
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
// Viewport bounds: x >= 8, x + w <= 792 (800 - 8)
expect(placement.x).toBeGreaterThanOrEqual(8)
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
// Vertical bounds: y >= 8, y + h <= 552 (strictly above control bar y=552)
expect(placement.y).toBeGreaterThanOrEqual(8)
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
// Minimum width is 240
expect(placement.w).toBeGreaterThanOrEqual(240)
// Height matches line count * 17 + 24
expect(placement.h).toBe(dummyLines.length * 17 + 24)
}
})
it('separates Left-Click (Move/Stack/Identify-Target) from Right-Click (Use) for Tomes, Scrolls, and Horadric Cube (Issue #487)', () => {
const inv = createDemoInventory() // seeded starter bag (box / tbk / isc)
inv.visible = true
const worldPanels = new WorldPanelsHud()
let cubeOpened = 0
let tpCastCount = 0
const cellCenter = (col: number, row: number) => ({
x: INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx + 10,
y: INV_GRID_ORIGIN.y + row * INV_GRID_ORIGIN.cellPx + 10,
})
// 1. Left-Click (button = 0) on Horadric Cube ('box' at 0,0) picks it up instead of opening Cube
const boxPt = cellCenter(0, 0)
expect(
inv.handleClick(
boxPt.x,
boxPt.y,
{
onOpenCube: () => {
cubeOpened++
},
onCastTownPortal: () => {
tpCastCount++
},
},
0,
),
).toBe(true)
expect(cubeOpened).toBe(0)
expect(inv.cursorItem?.code).toBe('box')
// Put Horadric Cube back at (0, 0) via Left-Click
expect(inv.handleClick(boxPt.x, boxPt.y, undefined, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
// 2. Right-Click (button = 2) on Horadric Cube ('box' at 0,0) opens Cube and leaves 'box' in inventory
expect(
inv.handleClick(
boxPt.x,
boxPt.y,
{
onOpenCube: () => {
cubeOpened++
},
},
2,
),
).toBe(true)
expect(cubeOpened).toBe(1)
expect(inv.cursorItem).toBeNull()
expect(inv.gridItems.some(p => p.item.code === 'box' && p.col === 0 && p.row === 0)).toBe(true)
// 3. Left-Click (button = 0) on Tome of Town Portal ('tbk' at 2,0) picks it up without casting TP
const tbkPt = cellCenter(2, 0)
expect(
inv.handleClick(
tbkPt.x,
tbkPt.y,
{
onCastTownPortal: () => {
tpCastCount++
},
},
0,
),
).toBe(true)
expect(tpCastCount).toBe(0)
expect(inv.cursorItem?.code).toBe('tbk')
expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
// 4. Right-Click (button = 2) on Tome of Town Portal ('tbk' at 2,0) decrements quantity (20 -> 19) and casts TP
expect(
inv.handleClick(
tbkPt.x,
tbkPt.y,
{
onCastTownPortal: () => {
tpCastCount++
},
},
2,
),
).toBe(true)
expect(tpCastCount).toBe(1)
expect(inv.cursorItem).toBeNull()
const tbkPlaced = inv.gridItems.find(p => p.item.code === 'tbk')!
expect(tbkPlaced.item.quantity).toBe(19)
expect(tbkPlaced.item.baseNameZh.includes('数量: 19')).toBe(true)
// 5. Left-Click Scroll of Town Portal ('tsc') onto Tome of Town Portal ('tbk') stacks quantity (19 -> 20)
inv.cursorItem = {
id: 'scroll_tp_1',
code: 'tsc',
name: 'Scroll of Town Portal',
nameZh: '回城卷轴',
baseNameZh: '回城卷轴',
quality: 'normal',
invFile: 'invscrol',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [{ text: '右键点击开启回城传送门', color: 'white' }],
}
expect(inv.handleClick(tbkPt.x, tbkPt.y, undefined, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(tbkPlaced.item.quantity).toBe(20)
// Place another 'tsc' in empty slot (5, 0) and Right-Click to consume scroll and cast TP
const tscSlotPt = cellCenter(5, 0)
inv.cursorItem = {
id: 'scroll_tp_2',
code: 'tsc',
name: 'Scroll of Town Portal',
nameZh: '回城卷轴',
baseNameZh: '回城卷轴',
quality: 'normal',
invFile: 'invscrol',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [{ text: '右键点击开启回城传送门', color: 'white' }],
}
expect(inv.handleClick(tscSlotPt.x, tscSlotPt.y, undefined, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(
inv.handleClick(
tscSlotPt.x,
tscSlotPt.y,
{
onCastTownPortal: () => {
tpCastCount++
},
},
2,
),
).toBe(true)
expect(tpCastCount).toBe(2)
expect(inv.gridItems.some(p => p.item.id === 'scroll_tp_2')).toBe(false)
// 6. Right-Click Tome of Identify ('ibk' at 2,2) enters Identify Mode, Left-Click unidentified item identifies it
const unidRingPt = cellCenter(5, 1)
inv.gridItems.push({
col: 5,
row: 1,
item: {
id: 'unid_ring_1',
code: 'rin',
name: 'The Stone of Jordan',
nameZh: '乔丹之石',
baseNameZh: '戒指',
quality: 'unique',
invFile: 'invrin',
invWidth: 1,
invHeight: 1,
allowedSlots: ['ring1', 'ring2'],
identified: false,
stats: [
{ text: '未辨识 (Unidentified)', color: 'red' },
{ text: '+1 所有技能', color: 'blue' },
],
},
})
// Unidentified item cannot be equipped
inv.cursorItem = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item
expect(inv.clickEquipSlot('ring1')).toBe(false)
inv.cursorItem = null
const ibkPt = cellCenter(2, 2)
expect(inv.isIdentifyMode()).toBe(false)
// Left-Click 'ibk' picks it up (does NOT enter Identify Mode)
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
expect(inv.isIdentifyMode()).toBe(false)
expect((inv.cursorItem as { code?: string } | null)?.code).toBe('ibk')
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
// Right-Click 'ibk' enters Identify Mode
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 2)).toBe(true)
expect(inv.isIdentifyMode()).toBe(true)
// Left-Click unidentified ring in Inventory identifies it and decrements 'ibk' quantity (20 -> 19)
expect(inv.handleClick(unidRingPt.x, unidRingPt.y, undefined, 0)).toBe(true)
expect(inv.isIdentifyMode()).toBe(false)
const idRing = inv.gridItems.find(p => p.item.id === 'unid_ring_1')!.item
expect(idRing.identified).toBe(true)
expect(idRing.stats.some(s => s.text.includes('未辨识'))).toBe(false)
const ibkPlaced = inv.gridItems.find(p => p.item.code === 'ibk')!
expect(ibkPlaced.item.quantity).toBe(19)
// Left-Click Scroll of Identify ('isc') onto 'ibk' stacks quantity (19 -> 20)
inv.cursorItem = {
id: 'scroll_id_1',
code: 'isc',
name: 'Scroll of Identify',
nameZh: '辨识卷轴',
baseNameZh: '辨识卷轴',
quality: 'normal',
invFile: 'invscrol',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [{ text: '右键点击辨识未鉴定物品', color: 'white' }],
}
expect(inv.handleClick(ibkPt.x, ibkPt.y, undefined, 0)).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(ibkPlaced.item.quantity).toBe(20)
// 7. Right-Click on normal equipment ('crs' at 8,0) does NOT pick it up onto cursorItem
const crsPt = cellCenter(8, 0)
expect(inv.handleClick(crsPt.x, crsPt.y, undefined, 2)).toBe(false)
expect(inv.cursorItem).toBeNull()
// 8. Horadric Cube 3x4 grid placement and pickup (`CUBE_GRID_ORIGIN`)
expect(CUBE_GRID_ORIGIN).toEqual({ x: 80 + 118, y: 60 + 139, cols: 3, rows: 4, cellPx: 29 })
// Cannot place Horadric Cube ('box') inside Horadric Cube
const boxItem = inv.gridItems.find(p => p.item.code === 'box')!.item
expect(worldPanels.clickCubeCell(0, 0, boxItem).handled).toBe(false)
// Can place identified ring into Horadric Cube (0, 0) and pick it back up via Left-Click
inv.cursorItem = idRing
const cubeCell00 = {
x: CUBE_GRID_ORIGIN.x + 10,
y: CUBE_GRID_ORIGIN.y + 10,
}
expect(
worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isRightClick: false,
}),
).toBe(true)
expect(inv.cursorItem).toBeNull()
expect(worldPanels.cubeItems).toHaveLength(1)
expect(worldPanels.cubeItems[0]!.item.id).toBe('unid_ring_1')
// Left-Click again picks it back up from Cube
expect(
worldPanels.handleLeftDockClick('cube', cubeCell00.x, cubeCell00.y, {
onClose: () => {},
onWaypointTeleport: () => {},
inventory: inv,
isRightClick: false,
}),
).toBe(true)
expect((inv.cursorItem as { id?: string } | null)?.id).toBe('unid_ring_1')
expect(worldPanels.cubeItems).toHaveLength(0)
// Stash grid dimensions check
expect(STASH_GRID_ORIGIN.cols).toBe(6)
expect(STASH_GRID_ORIGIN.rows).toBe(8)
})
it('extracts goldcoinbtn.dc6 (40x18) and renders Inventory gold button and aligned gold readout per D2Client.dll 0x6fb3cfb0', () => {
// 1. Verify public/ui/goldcoinbtn.png exists and is a 40x18 PNG strip (2 frames of 20x18)
const goldBtnPath = join(process.cwd(), 'public', 'ui', 'goldcoinbtn.png')
expect(existsSync(goldBtnPath)).toBe(true)
const pngBytes = readFileSync(goldBtnPath)
// PNG IHDR width @ offset 16 (uint32 BE), height @ offset 20 (uint32 BE)
const view = new DataView(pngBytes.buffer, pngBytes.byteOffset, pngBytes.byteLength)
expect(view.getUint32(16, false)).toBe(40)
expect(view.getUint32(20, false)).toBe(18)
// 2. Verify D2Client.dll 0x6fb3cfb0 / invchar6.dc6 socket & recess bounds
expect(INV_GOLD_BTN_BOUNDS).toEqual({
x: 400 + 84,
y: 60 + 391,
relX: 84,
relY: 391,
w: 20,
h: 18,
})
expect(INV_GOLD_BOX_RECESS).toEqual({
x: 400 + 107,
y: 60 + 392,
relX: 107,
relY: 392,
w: 90,
h: 16,
textX: 400 + 152,
textY: 60 + 403,
})
// 3. Verify InventoryPanel.draw blits goldCoinBtnImg at (484, 451) and centers gold readout at (552, 463)
const inv = new InventoryPanel()
inv.visible = true
inv.gold = 850_000
const drawImageCalls: Array<{ img: unknown; args: number[] }> = []
const drawTextCalls: Array<{ text: string; x: number; y: number; opts?: Record<string, unknown> | undefined }> = []
const mockGoldBtnImg = { tag: 'goldCoinBtn' } as unknown as HTMLImageElement
const mockCtx = {
drawImage: (img: unknown, ...args: number[]) => {
drawImageCalls.push({ img, args })
},
save: () => {},
restore: () => {},
beginPath: () => {},
arc: () => {},
fill: () => {},
stroke: () => {},
fillRect: () => {},
strokeRect: () => {},
fillStyle: '',
strokeStyle: '',
} as unknown as CanvasRenderingContext2D
const mockFont = {
drawText: (_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: Record<string, unknown>) => {
drawTextCalls.push({ text, x, y, opts })
},
measureText: (text: string) => text.length * 6,
} as unknown as D2FontRenderer
inv.draw(
mockCtx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
goldCoinBtnImg: mockGoldBtnImg,
itemsAtlasImg: null,
itemRects: {},
},
mockFont,
)
const goldBtnCall = drawImageCalls.find(c => c.img === mockGoldBtnImg)
expect(goldBtnCall).toBeDefined()
expect(goldBtnCall!.args).toEqual([0, 0, 20, 18, 484, 451, 20, 18])
const goldTextCall = drawTextCalls.find(c => c.text === (850_000).toLocaleString())
expect(goldTextCall).toBeDefined()
expect(goldTextCall!.x).toBe(INV_GOLD_BOX_RECESS.textX)
expect(goldTextCall!.y).toBe(INV_GOLD_BOX_RECESS.textY)
expect(goldTextCall!.opts).toMatchObject({
font: 'font8',
color: 'white',
align: 'center',
})
// Verify 8px cap-height digits [textY - 7, textY] + 1px shadow stay strictly inside dark recess [452, 467]
expect(goldTextCall!.y - 7).toBeGreaterThanOrEqual(INV_GOLD_BOX_RECESS.y)
expect(goldTextCall!.y + 1).toBeLessThanOrEqual(INV_GOLD_BOX_RECESS.y + INV_GOLD_BOX_RECESS.h - 1)
// 4. Verify clicking directly on the Gold Coin Button (490, 460) asks for the gold dialog (no local drop)
let goldButtonPresses = 0
expect(
inv.handleClick(490, 460, {
onGoldButton: () => {
goldButtonPresses++
},
}),
).toBe(true)
expect(goldButtonPresses).toBe(1)
// 5. Verify D2FontRenderer.drawText uses lining-numeral serif stack (never old-style Georgia)
const fontRenderer = new D2FontRenderer()
let capturedFont = ''
const fontCtx = {
save: () => {},
restore: () => {},
measureText: (t: string) => ({ width: t.length * 6 }),
fillText: () => {},
set font(v: string) {
capturedFont = v
},
get font() {
return capturedFont
},
textBaseline: 'alphabetic',
fillStyle: '',
} as unknown as CanvasRenderingContext2D
fontRenderer.drawText(fontCtx, '850,000', 552, 463, { font: 'font8', color: 'white', align: 'center' })
expect(capturedFont).not.toContain('Georgia')
expect(capturedFont).toContain('Times New Roman')
})
})