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

497 lines
20 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 {
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'
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)
})
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<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')
})
})