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