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