diablo2-web/tests/client/inventory-batch6.test.ts

504 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
import {
CANONICAL_VAR_INV_GFX,
itemDataToUiInventoryItem,
itemToUiInventoryItem,
onlineItemDataToUiInventoryItem,
resolveItemInvFile,
} from '../../src/common/items/item-bridge.ts'
import { ItemQuality, type Item } from '../../src/common/items/items.ts'
import {
CANONICAL_MISC_USABLE_CODES,
EQUIP_SLOTS_LAYOUT,
GEMSOCKET_SPRITE_RECT,
GRID_ITEM_BG_BLUE,
GRID_ITEM_BG_GREEN,
GRID_ITEM_BG_RED,
INVENTORY_TXT_LAYOUTS,
INV_GRID_ORIGIN,
INV_PANEL_ORIGIN,
InventoryPanel,
WEAPON_SWAP_TABS_BOUNDS,
drawItemSocketOverlay,
getInventoryLayout,
isUsableRightClickItem,
parseInventoryTxt,
resolveGridItemBackgroundFill,
resolveItemSpriteRect,
resolvePaperdollSlotBackgroundFill,
resolveWeaponSwapTabClick,
type UiInventoryItem,
} from '../../src/client/ui/inventory.ts'
import type { D2FontRenderer } from '../../src/client/ui/font.ts'
function createMockCanvasContext() {
const fillRectCalls: { x: number; y: number; w: number; h: number; fillStyle: string }[] = []
const drawImageCalls: unknown[][] = []
const arcCalls: unknown[][] = []
const textCalls: string[] = []
const ctx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
fillRectCalls.push({ x, y, w, h, fillStyle: String(ctx.fillStyle) })
}),
strokeRect: vi.fn(),
drawImage: vi.fn((...args: unknown[]) => {
drawImageCalls.push(args)
}),
arc: vi.fn((...args: unknown[]) => {
arcCalls.push(args)
}),
} as unknown as CanvasRenderingContext2D
const font = {
drawText: vi.fn((_ctx: unknown, text: string) => {
textCalls.push(text)
}),
} as unknown as D2FontRenderer
return { ctx, font, fillRectCalls, drawImageCalls, arcCalls, textCalls }
}
describe('Batch 6 Phase 2: Inventory Panel & Item Rendering Parity (Issues #680-#685)', () => {
describe('Issue #680: Right-Click Usable Items Driven by misc.txt useable Column', () => {
const EXPECTED_35_USABLE_CODES = [
'elx', 'hpo', 'mpo', 'hpf', 'mpf', 'vps', 'yps', 'rvs', 'rvl', 'wms',
'tbk', 'ibk', 'tsc', 'isc', 'xyz', 'box', 'ass',
'hp1', 'hp2', 'hp3', 'hp4', 'hp5',
'mp1', 'mp2', 'mp3', 'mp4', 'mp5',
'hrb', 'rps', 'rpl', 'bps', 'bpl', '0sc', 'tr2', 'toa',
] as const
it('recognizes all 35 canonical misc.txt useable=1 codes and rejects non-canonical codes like cube', () => {
expect(CANONICAL_MISC_USABLE_CODES.size).toBe(35)
for (const code of EXPECTED_35_USABLE_CODES) {
expect(isUsableRightClickItem(code)).toBe(true)
expect(isUsableRightClickItem(` ${code.toUpperCase()} `)).toBe(true)
}
// Reject non-canonical 'cube' alias and non-usable items
expect(isUsableRightClickItem('cube')).toBe(false)
expect(isUsableRightClickItem('cm1')).toBe(false)
expect(isUsableRightClickItem('rin')).toBe(false)
expect(isUsableRightClickItem('cap')).toBe(false)
expect(isUsableRightClickItem('r01')).toBe(false)
expect(isUsableRightClickItem(undefined)).toBe(false)
})
it('consumes usable potions/scrolls on right-click in InventoryPanel while rejecting non-usable items', () => {
const panel = new InventoryPanel()
panel.visible = true
const usedItems: string[] = []
const hpPotion: UiInventoryItem = {
id: 'pot-1',
code: 'hp4',
invFile: 'invbpot',
name: 'Greater Healing Potion',
nameZh: '强效治疗药剂',
baseNameZh: '强效治疗药剂',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
}
panel.gridItems = [{ item: hpPotion, col: 0, row: 0 }]
const handled = panel.handleClick(
INV_GRID_ORIGIN.x + 5,
INV_GRID_ORIGIN.y + 5,
{
onUseItem: item => {
usedItems.push(item.code)
},
},
2,
)
expect(handled).toBe(true)
expect(usedItems).toEqual(['hp4'])
expect(panel.gridItems.length).toBe(0)
})
})
describe('Issue #681: Item Variant Graphic Index (graphicIndex / InvGfx1..6) without Name Guessing or String Hashing', () => {
const itemRects = BAKED_UI_MANIFEST.itemRects
it('forwards graphicIndex from D2GS ItemData through itemDataToUiInventoryItem to select InvGfx1..6', () => {
// Rings: 5 variants (invrin1..invrin5)
for (let i = 0; i < 5; i++) {
const uiRing = itemDataToUiInventoryItem({
id: 100 + i,
code: 'rin',
quality: 4,
identified: true,
graphicIndex: i,
})
expect(uiRing.variant).toBe(i)
expect(uiRing.graphicIndex).toBe(i)
expect(uiRing.invFile).toBe(`invrin${i + 1}`)
expect(resolveItemSpriteRect(uiRing, itemRects)).toEqual(itemRects[`invrin${i + 1}`])
}
// Amulets: 3 variants (invamu1..invamu3)
for (let i = 0; i < 3; i++) {
const uiAmu = onlineItemDataToUiInventoryItem({
id: 200 + i,
code: 'amu',
quality: 6,
identified: true,
graphicIndex: i,
})
expect(uiAmu.variant).toBe(i)
expect(uiAmu.invFile).toBe(`invamu${i + 1}`)
expect(resolveItemSpriteRect(uiAmu, itemRects)).toEqual(itemRects[`invamu${i + 1}`])
}
// Jewels: 6 variants (invjw1..invjw6), never misc.txt placeholder invgswe
for (let i = 0; i < 6; i++) {
const uiJewel = onlineItemDataToUiInventoryItem({
id: 300 + i,
code: 'jew',
quality: 7,
identified: true,
graphicIndex: i,
})
expect(uiJewel.variant).toBe(i)
expect(uiJewel.invFile).toBe(`invjw${i + 1}`)
expect(resolveItemSpriteRect(uiJewel, itemRects)).toEqual(itemRects[`invjw${i + 1}`])
}
// Charms: cm1 (invch1/4/7), cm2 (invch2/5/8), cm3 (invch3/6/9)
for (let i = 0; i < 3; i++) {
const cm1 = onlineItemDataToUiInventoryItem({ id: 400 + i, code: 'cm1', quality: 4, identified: true, graphicIndex: i })
const cm2 = onlineItemDataToUiInventoryItem({ id: 410 + i, code: 'cm2', quality: 4, identified: true, graphicIndex: i })
const cm3 = onlineItemDataToUiInventoryItem({ id: 420 + i, code: 'cm3', quality: 4, identified: true, graphicIndex: i })
expect(cm1.invFile).toBe(CANONICAL_VAR_INV_GFX.cm1![i])
expect(cm2.invFile).toBe(CANONICAL_VAR_INV_GFX.cm2![i])
expect(cm3.invFile).toBe(CANONICAL_VAR_INV_GFX.cm3![i])
}
})
it('does not hash item.id to pick random ring/amulet/jewel graphics and rejects fake cta code', () => {
// Different item.id strings without variant/graphicIndex must deterministically use index 0
for (const id of ['ring-alpha', 'ring-beta', 'ring-gamma', '99999', '12345']) {
expect(resolveItemSpriteRect({ code: 'rin', id }, itemRects)).toEqual(itemRects['invrin1'])
expect(resolveItemSpriteRect({ code: 'amu', id }, itemRects)).toEqual(itemRects['invamu1'])
expect(resolveItemSpriteRect({ code: 'jew', id }, itemRects)).toEqual(itemRects['invjw1'])
}
// Canonical crs resolves to invcrs, while fake code 'cta' returns null
expect(resolveItemSpriteRect({ code: 'crs' }, itemRects)).toEqual(itemRects['invcrs'])
expect(resolveItemSpriteRect({ code: 'cta' }, itemRects)).toBeNull()
})
it('resolves unique items with dedicated UniqueItems.txt invfile (Annihilus, Hellfire Torch) via table lookup', () => {
const anniDomain = {
id: 1,
code: 'cm1',
name: 'Annihilus',
quality: ItemQuality.UNIQUE,
identified: true,
uniqueItemDef: { invfile: 'invmss', index: 'Annihilus' },
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', invWidth: 1, invHeight: 1 },
} as unknown as Item
expect(resolveItemInvFile(anniDomain, 'unique', 'cm1').invFile).toBe('invmss')
expect(itemToUiInventoryItem(anniDomain).invFile).toBe('invmss')
})
})
describe('Issue #682: Canonical inventory.txt Layout Loading & Coordinates', () => {
it('parses all 32 rows of canonical 1.13c inventory.txt and exposes 640x480 and 800x600 layouts', () => {
expect(INVENTORY_TXT_LAYOUTS.length).toBe(32)
const ama640 = getInventoryLayout('Amazon', 0)
expect(ama640.invLeft).toBe(320)
expect(ama640.invRight).toBe(640)
expect(ama640.invTop).toBe(0)
expect(ama640.invBottom).toBe(441)
expect(ama640.gridLeft).toBe(339)
expect(ama640.gridTop).toBe(257)
const ama800 = getInventoryLayout('ama', 1)
expect(ama800.class).toBe('Amazon2')
expect(ama800.invLeft).toBe(400)
expect(ama800.invRight).toBe(720)
expect(ama800.invTop).toBe(60)
expect(ama800.invBottom).toBe(501)
expect(ama800.invWidth).toBe(320)
expect(ama800.invHeight).toBe(441)
expect(ama800.gridLeft).toBe(419)
expect(ama800.gridTop).toBe(315)
expect(ama800.gridX).toBe(10)
expect(ama800.gridY).toBe(4)
expect(ama800.gridBoxWidth).toBe(29)
expect(ama800.gridBoxHeight).toBe(29)
// Verify INV_PANEL_ORIGIN, INV_GRID_ORIGIN, and all 10 EQUIP_SLOTS_LAYOUT match Amazon2..Assassin2
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 441 })
expect(INV_GRID_ORIGIN).toEqual({ x: 419, y: 315, cols: 10, rows: 4, cellPx: 29 })
expect(EQUIP_SLOTS_LAYOUT.helm).toMatchObject({ x: 535, y: 68, w: 54, h: 51 })
expect(EQUIP_SLOTS_LAYOUT.amulet).toMatchObject({ x: 609, y: 95, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.weapon1).toMatchObject({ x: 420, y: 107, w: 55, h: 112 })
expect(EQUIP_SLOTS_LAYOUT.armor).toMatchObject({ x: 533, y: 137, w: 56, h: 82 })
expect(EQUIP_SLOTS_LAYOUT.weapon2).toMatchObject({ x: 651, y: 107, w: 55, h: 112 })
expect(EQUIP_SLOTS_LAYOUT.gloves).toMatchObject({ x: 421, y: 241, w: 54, h: 53 })
expect(EQUIP_SLOTS_LAYOUT.ring1).toMatchObject({ x: 495, y: 240, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.belt).toMatchObject({ x: 536, y: 239, w: 52, h: 25 })
expect(EQUIP_SLOTS_LAYOUT.ring2).toMatchObject({ x: 609, y: 240, w: 23, h: 24 })
expect(EQUIP_SLOTS_LAYOUT.boots).toMatchObject({ x: 652, y: 242, w: 54, h: 52 })
// Fail-fast on unknown layout or malformed TSV
expect(() => getInventoryLayout('NonExistentPanel', 1)).toThrow()
expect(() => parseInventoryTxt('')).toThrow()
})
})
describe('Issue #683: Weapon Swap I / II Tabs Hitboxes & No Duplicate Text Overlay', () => {
it('splits left and right 62x22 weapon swap tab strips into I (set 0) and II (set 1) sub-tabs', () => {
expect(WEAPON_SWAP_TABS_BOUNDS.left).toEqual({ x: 415, y: 83, w: 62, h: 22 })
expect(WEAPON_SWAP_TABS_BOUNDS.right).toEqual({ x: 645, y: 83, w: 62, h: 22 })
expect(resolveWeaponSwapTabClick(420, 90)).toBe(0) // left I
expect(resolveWeaponSwapTabClick(455, 90)).toBe(1) // left II
expect(resolveWeaponSwapTabClick(650, 90)).toBe(0) // right I
expect(resolveWeaponSwapTabClick(685, 90)).toBe(1) // right II
expect(resolveWeaponSwapTabClick(500, 90)).toBeNull()
const panel = new InventoryPanel()
panel.visible = true
expect(panel.weaponSwapSet).toBe(0)
// Clicking already-active 'I' sub-tab is a no-op
panel.handleClick(420, 90)
expect(panel.weaponSwapSet).toBe(0)
// Clicking 'II' sub-tab switches to set 1
panel.handleClick(455, 90)
expect(panel.weaponSwapSet).toBe(1)
// Clicking 'II' again on right strip is a no-op
panel.handleClick(685, 90)
expect(panel.weaponSwapSet).toBe(1)
// Clicking 'I' on right strip switches back to set 0
panel.handleClick(650, 90)
expect(panel.weaponSwapSet).toBe(0)
})
it('renders invTab0Img / invTab1Img without drawing duplicate Exocet Roman numeral text on top', () => {
const panel = new InventoryPanel()
panel.visible = true
const { ctx, font, textCalls } = createMockCanvasContext()
panel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: {} as HTMLImageElement,
invTab1Img: {} as HTMLImageElement,
buySellBtnImg: null,
itemsAtlasImg: null,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
font,
)
expect(textCalls).not.toContain('I')
expect(textCalls).not.toContain('II')
})
})
describe('Issue #684: Grid & Paperdoll Item Background Colors Follow 1.13c Usability Rules', () => {
const player = { level: 30, str: 60, dex: 60, classCode: 'sor' as const }
const usableUnique: UiInventoryItem = {
id: 'u-1',
code: 'cap',
invFile: 'invcap',
name: 'Biggin\'s Bonnet',
nameZh: '比金的软帽',
baseNameZh: '帽子',
quality: 'unique',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
reqLevel: 3,
stats: [],
}
const unusableArmor: UiInventoryItem = {
id: 'a-1',
code: 'uar',
invFile: 'invful',
name: 'Sacred Armor',
nameZh: '神圣盔甲',
baseNameZh: '神圣盔甲',
quality: 'set',
invWidth: 2,
invHeight: 3,
allowedSlots: ['armor'],
reqStr: 232,
stats: [],
}
it('uses blue for usable grid items regardless of quality, red for unusable/unidentified, and no green fill on normal equipped paperdoll slots', () => {
expect(resolveGridItemBackgroundFill(usableUnique, player)).toBe(GRID_ITEM_BG_BLUE)
expect(resolveGridItemBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
expect(resolveGridItemBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
GRID_ITEM_BG_GREEN,
)
// Paperdoll slots have no fill when usable, red when requirements unmet, green when hovered with cursor item
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player)).toBeNull()
expect(resolvePaperdollSlotBackgroundFill(unusableArmor, player)).toBe(GRID_ITEM_BG_RED)
expect(resolvePaperdollSlotBackgroundFill(usableUnique, player, { isHovered: true, hasCursorItem: true })).toBe(
GRID_ITEM_BG_GREEN,
)
const panel = new InventoryPanel()
panel.visible = true
panel.playerContext = player
panel.equipped.helm = usableUnique
panel.gridItems = [{ item: usableUnique, col: 0, row: 0 }]
const { ctx, font, fillRectCalls } = createMockCanvasContext()
panel.draw(
ctx,
{
borderRightImg: null,
invSheetImg: {} as HTMLImageElement,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: {} as HTMLImageElement,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
font,
)
// Only the 10x4 grid item draws a background fillRect (in GRID_ITEM_BG_BLUE); the usable equipped helm draws no fillRect
expect(fillRectCalls).toHaveLength(1)
expect(fillRectCalls[0]?.fillStyle).toBe(GRID_ITEM_BG_BLUE)
})
})
describe('Issue #685: Hover-Only Socket Overlays Using gemsocket DC6 Art (Zero Procedural Circles)', () => {
const socketedSword: UiInventoryItem = {
id: 'sw-1',
code: 'crs',
invFile: 'invcrs',
name: 'Crystal Sword',
nameZh: '水晶剑',
baseNameZh: '水晶剑',
quality: 'normal',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1', 'weapon2'],
sockets: 2,
socketedItems: [
{
id: 'r-1',
code: 'r07',
invFile: 'invr07',
name: 'Tal Rune',
nameZh: '塔尔符文',
baseNameZh: '塔尔符文',
quality: 'rune',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
},
],
stats: [],
}
it('draws socket overlays in InventoryPanel ONLY when the item is hovered and never calls ctx.arc', () => {
const panel = new InventoryPanel()
panel.visible = true
panel.gridItems = [{ item: socketedSword, col: 0, row: 0 }]
const mockAtlas = {} as HTMLImageElement
const mockGemSocket = {} as HTMLImageElement
// 1. Not hovered: only the base item sprite is drawn (1 drawImage call)
const unhovered = createMockCanvasContext()
panel.draw(
unhovered.ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: mockAtlas,
gemSocketImg: mockGemSocket,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
unhovered.font,
)
expect(unhovered.arcCalls).toHaveLength(0)
expect(unhovered.drawImageCalls).toHaveLength(1)
// 2. Hovered: base item sprite + 2 gemsocket frames (28x28) + 1 socketed rune sprite = 4 drawImage calls, 0 ctx.arc calls
panel.handleMouseMove(INV_GRID_ORIGIN.x + 10, INV_GRID_ORIGIN.y + 10)
expect(panel.hoveredItem?.item).toBe(socketedSword)
const hovered = createMockCanvasContext()
panel.draw(
hovered.ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: mockAtlas,
gemSocketImg: mockGemSocket,
itemRects: BAKED_UI_MANIFEST.itemRects,
},
hovered.font,
)
expect(hovered.arcCalls).toHaveLength(0)
expect(hovered.drawImageCalls).toHaveLength(4)
expect(GEMSOCKET_SPRITE_RECT).toEqual({ x: 0, y: 0, w: 28, h: 28 })
})
it('fails fast by throwing when a socketed child item has an unresolvable sprite rect', () => {
const brokenHost: UiInventoryItem = {
...socketedSword,
socketedItems: [
{
id: 'bad-sock',
code: 'nonexistent_socket_code',
invFile: 'nonexistent_invfile',
name: 'Invalid Socket Item',
nameZh: '无效镶嵌物',
baseNameZh: '无效镶嵌物',
quality: 'normal',
invWidth: 1,
invHeight: 1,
allowedSlots: [],
stats: [],
},
],
}
const { ctx } = createMockCanvasContext()
expect(() =>
drawItemSocketOverlay(ctx, brokenHost, 0, 0, 58, 87, {
itemsAtlasImg: {} as HTMLImageElement,
itemRects: BAKED_UI_MANIFEST.itemRects,
}),
).toThrow(/Missing sprite rect for socketed item/)
})
})
})