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

1047 lines
44 KiB
TypeScript

/**
* Unit & Parity Tests for Diablo II v1.13c Complete UI / HUD (Issue #27).
*/
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parseColoredText, parseFontTblMetrics, D2_COLOR_HEX } from '../src/ui/font.ts'
import { GlobesHud, LIFE_GLOBE_BOUNDS, MANA_GLOBE_BOUNDS } from '../src/ui/globes.ts'
import { BeltHud, BELT_COLS, BELT_ROWS, BELT_BASE_X } from '../src/ui/belt.ts'
import { SkillHotkeysHud, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/ui/hotkeys.ts'
import {
ControlBarHud,
EXP_BAR_BOUNDS,
EXP_BAR_HOVER_BOUNDS,
STAMINA_BAR_BOUNDS,
} from '../src/ui/control-bar.ts'
import {
InventoryPanel,
INV_PANEL_ORIGIN,
INV_GRID_ORIGIN,
EQUIP_SLOTS_LAYOUT,
resolveItemSpriteRect,
STARTER_BAG_ITEMS,
STARTER_EQUIPPED_GEAR,
} from '../src/ui/inventory.ts'
import { CharacterSheetPanel, CHAR_PANEL_ORIGIN, CHAR_CLOSE_BTN_BOUNDS, STAT_ALLOC_BUTTONS } from '../src/ui/character-sheet.ts'
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
import {
WorldPanelsHud,
ACT_WAYPOINTS,
ALL_QUESTS,
ACT_QUESTS,
findWaypointBySlug,
CUBE_GRID_ORIGIN,
STASH_GRID_ORIGIN,
} from '../src/ui/world-panels.ts'
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
import { HudManager, computeHudLayout } from '../src/ui/hud-manager.ts'
import { projectNpcLabel } from '../src/scene/act-scene.ts'
import {
getLevelNames,
sceneNameZh,
sceneNameEn,
LEVEL_NAMES_ZH,
LEVEL_NAMES_EN,
} from '../src/game/level-names-zh.ts'
import {
buildTooltipViewModel,
createInitialState,
allocatePoint,
} from '../src/game/skill-calc-engine.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, 320x432)
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: 432 })
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()
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 })
// Non-beltable items (scrolls, equipment, throwing potions) return false
expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(false)
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', () => {
const inv = new InventoryPanel()
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('allocates character attribute points and skill points with 1.13c rules', () => {
const char = new CharacterSheetPanel()
const beforePts = char.attrs.statPoints
const beforeVit = char.attrs.vit
const beforeHp = char.attrs.baseMaxHp
expect(char.allocateStat('vit')).toBe(true)
expect(char.attrs.statPoints).toBe(beforePts - 1)
expect(char.attrs.vit).toBe(beforeVit + 1)
expect(char.attrs.baseMaxHp).toBe(beforeHp + 2)
const tree = new SkillTreePanel()
const beforeSkillPts = tree.unspentSkillPoints
const beforeIceBolt = tree.getHardPoints(39)
expect(tree.allocateSkill(39)).toBe(true)
expect(tree.unspentSkillPoints).toBe(beforeSkillPts - 1)
expect(tree.getHardPoints(39)).toBe(beforeIceBolt + 1)
})
it('defines all 5 Acts of Waypoint destinations and F1..F8 skill bindings', () => {
const hotkeys = new SkillHotkeysHud()
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?.titleZh).toBe('罗格营地')
})
it('projects NPC labels to sprite quad top-centers across 0.4x..8.0x zoom and DPR (Issue #135)', () => {
// Deckard Cain from scene.json: width: 43, height: 75, offsetX: -45, offsetY: -54
const cainNpc = {
x: 4768,
y: 1752,
frame: { width: 43, height: 75, offsetX: -45, offsetY: -54 },
}
// Camera centered on Cain
const camX = 4768
const camY = 1752 - 16
// 1. Standard 1080p, DPR = 1.0, Zoom = 1.0x
const res1 = projectNpcLabel({
npc: cainNpc,
camX,
camY,
cameraZoom: 1.0,
canvas: { width: 1920, height: 1080 },
rect: { left: 0, top: 0, width: 1920, height: 1080 },
})
// Expected topX = 4768 - 45 + 21.5 = 4744.5. (topX - camX) = -23.5
// Expected topY = 1752 - 54 = 1698. (topY - camY) = 1698 - 1736 = -38
expect(res1.domX).toBeCloseTo(1920 * 0.5 - 23.5, 4)
expect(res1.domY).toBeCloseTo(1080 * 0.5 - 38 - 2, 4)
expect(res1.fontSize).toBe(13)
// 2. Retina 4K (DPR = 2.0, css 1920x1080, buffer 3840x2160), Zoom = 2.0x
const res2 = projectNpcLabel({
npc: cainNpc,
camX,
camY,
cameraZoom: 2.0,
canvas: { width: 3840, height: 2160 },
rect: { left: 0, top: 0, width: 1920, height: 1080 },
})
// Delta in CSS pixels is: deltaWorld * zoom * (css / buffer) = deltaWorld * 2.0 * 0.5 = deltaWorld * 1.0
expect(res2.domX).toBeCloseTo(1920 * 0.5 - 23.5, 4)
expect(res2.domY).toBeCloseTo(1080 * 0.5 - 38 - 2, 4)
expect(res2.fontSize).toBe(18)
// 3. Extreme Zoom Max 8.0x, font size clamped to 24px
const res8 = projectNpcLabel({
npc: cainNpc,
camX,
camY,
cameraZoom: 8.0,
canvas: { width: 1920, height: 1080 },
rect: { left: 0, top: 0, width: 1920, height: 1080 },
})
expect(res8.fontSize).toBe(24)
// 4. Extreme Zoom Min 0.4x, font size clamped to 11px
const res04 = projectNpcLabel({
npc: cainNpc,
camX,
camY,
cameraZoom: 0.4,
canvas: { width: 1920, height: 1080 },
rect: { left: 0, top: 0, width: 1920, height: 1080 },
})
expect(res04.fontSize).toBe(11)
// 5. Artless NPC fallback matches MARKER_HEIGHT (26px)
const artlessNpc = { x: 3000, y: 2000, frame: null }
const resArtless = projectNpcLabel({
npc: artlessNpc,
camX: 3000,
camY: 2000,
cameraZoom: 1.0,
canvas: { width: 800, height: 600 },
rect: { left: 0, top: 0, width: 800, height: 600 },
})
expect(resArtless.domX).toBeCloseTo(400, 4)
expect(resArtless.domY).toBeCloseTo(300 - 26 - 2, 4)
})
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(STARTER_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 STARTER_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 overrides (e.g. cta -> invcrs, tbk -> invbbk, cube -> invbox)
const ctaItem = { id: 'test4', code: 'cta', name: 'Call to Arms', invFile: 'nonexistent', width: 1, height: 2 }
expect(resolveItemSpriteRect(ctaItem, itemRects)).toEqual(itemRects['invcrs'])
const cubeItem = { id: 'test5', code: 'cube', name: 'Horadric Cube', invFile: 'nonexistent', width: 2, height: 2 }
expect(resolveItemSpriteRect(cubeItem, itemRects)).toEqual(itemRects['invbox'])
// Tier 5: Dimension fallback for completely unknown items
const fallback1x1 = { id: 'test6', code: 'xyz', name: 'Mystery 1x1', invFile: 'nonexistent', width: 1, height: 1 }
const fb1 = resolveItemSpriteRect(fallback1x1, itemRects)
expect(fb1).not.toBeNull()
expect(fb1!.w).toBeGreaterThan(0)
const fallback2x4 = { id: 'test7', code: 'xyz', name: 'Mystery 2x4', invFile: 'nonexistent', width: 2, height: 4 }
const fb2 = resolveItemSpriteRect(fallback2x4, itemRects)
expect(fb2).not.toBeNull()
expect(fb2!.w).toBeGreaterThan(0)
})
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
const worldPanels = new WorldPanelsHud()
const now = 1000
const bgNames = getLevelNames(17)
worldPanels.showAreaBanner(bgNames.nameZh, bgNames.nameEn, now)
expect(worldPanels.areaBanner).toEqual({
titleZh: '埋骨之地',
titleEn: 'Burial Grounds',
untilMs: now + 3600,
})
// 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: '火球 (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('[下一等级] (Lv 2)'))
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 [下一等级] and shows max allocation notice in gold
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)
const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]'))
expect(maxedNotice).toBeDefined()
expect(maxedNotice?.color).toBe('gold')
// 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('blue')
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 = new InventoryPanel()
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 + 117, 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)
})
})