diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 03b46a1..a793b65 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -505,6 +505,12 @@ export class HudManager { difficulty: 0 | 1 | 2 = 0, ): boolean { this.worldPanels.activePlayerInventory = this.inventory + this.worldPanels.setPlayerContext({ + level: this.charSheet.attrs.level, + str: this.charSheet.attrs.str, + dex: this.charSheet.attrs.dex, + classCode: this.currentClass ?? 'sor', + }) const ok = this.worldPanels.openVendorForNpc( descriptorOrName, mode, @@ -1106,6 +1112,12 @@ export class HudManager { } syncPublishedState(): void { + this.worldPanels.setPlayerContext({ + level: this.charSheet.attrs.level, + str: this.charSheet.attrs.str, + dex: this.charSheet.attrs.dex, + classCode: this.currentClass ?? 'sor', + }) this.syncCursorMode(this.cursor.lastNowMs) const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs) this.state.dc6DecodeFailures = this.dc6DecodeFailures diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index cb34c0a..a6d0b1d 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -14,6 +14,7 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { BAKED_UI_MANIFEST, type SpriteRect } from './baked-ui-meta.ts' import type { Item } from '../game/items.ts' +import type { CharacterClassCode } from '../game/classes.ts' import { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile } from './item-bridge.ts' @@ -51,6 +52,8 @@ export interface UiInventoryItem { readonly damage?: string | undefined readonly reqLevel?: number | undefined readonly reqStr?: number | undefined + readonly reqDex?: number | undefined + readonly reqClass?: CharacterClassCode | undefined readonly stats: readonly { text: string; color?: D2ColorCode }[] readonly invtransform?: string | undefined readonly chrtransform?: string | undefined @@ -1091,6 +1094,90 @@ export function isUiItemUnidentified(item: UiInventoryItem): boolean { ) } +/** + * Ground Truth (D2Client.dll 1.13c @ 0x6fb3c600..0x6fb3c710 & 0x6fbbb330..0x6fbbb334): + * Palette tint indices used by `INVENTORY_DrawGridItems` (`0x6fb458a0..0x6fb45b58`): + * - Index 0 (`RGB(128, 0, 0)`): Red backdrop — unusable, unmet requirements, unidentified, or gamble mystery item + * - Index 1 (`RGB(0, 128, 0)`): Green backdrop — hovered cell only when holding an item on cursor (`pCursorItem != NULL` at `0x6fb45a04`) + * - Index 2 (`RGB(0, 0, 128)`): Blue backdrop — usable / equippable item + */ +export const GRID_ITEM_BG_RED = 'rgba(72, 18, 18, 0.50)' +export const GRID_ITEM_BG_GREEN = 'rgba(46, 68, 36, 0.55)' +export const GRID_ITEM_BG_BLUE = 'rgba(20, 32, 54, 0.45)' + +export interface PlayerItemRequirementContext { + readonly level: number + readonly str: number + readonly dex: number + readonly classCode?: CharacterClassCode | undefined +} + +/** + * Ground Truth (D2Client.dll 1.13c @ 0x6fb45ab8..0x6fb45b54 & D2Common.dll @ 0x6fd76db0 `ITEMS_CheckItemRequirements` #10244): + * Checks whether the player meets all requirements to equip/use an item in a vendor or inventory grid: + * 1. Gamble mode or Unidentified (`bIdentified == 0` at `0x6fd76e23` / `0x6fd77006`) -> false (Red) + * 2. Broken durability (`durability.max > 0 && durability.current <= 0`) -> false (Red) + * 3. Empty Tome (`tbk` / `ibk` with `quantity <= 0` at `0x6fd77072`) -> false (Red) + * 4. Class restriction (`0x6fd74280` `ITEMS_GetClassOfClassSpecificItem` #10202) -> false if `item.reqClass !== player.classCode` (Red) + * 5. Level requirement (`0x6fd76fc7`) -> false if `player.level < item.reqLevel` (Red) + * 6. Strength requirement (`0x6fd76eab`) -> false if `player.str < item.reqStr` (Red) + * 7. Dexterity requirement (`0x6fd76f83`) -> false if `player.dex < item.reqDex` (Red) + */ +export function canPlayerUseUiItem( + item: UiInventoryItem, + player: PlayerItemRequirementContext, + vendorMode: 'trade' | 'gamble' = 'trade', +): boolean { + if (vendorMode === 'gamble' || isUiItemUnidentified(item)) { + return false + } + if (item.durability !== undefined && item.durability.max > 0 && item.durability.current <= 0) { + return false + } + const code = (item.code ?? '').trim().toLowerCase() + if ((code === 'tbk' || code === 'ibk') && getTomeQuantity(item) <= 0) { + return false + } + if (item.reqClass !== undefined && player.classCode !== undefined && item.reqClass !== player.classCode) { + return false + } + if (item.reqLevel !== undefined && item.reqLevel > 0 && player.level < item.reqLevel) { + return false + } + if (item.reqStr !== undefined && item.reqStr > 0 && player.str < item.reqStr) { + return false + } + if (item.reqDex !== undefined && item.reqDex > 0 && player.dex < item.reqDex) { + return false + } + return true +} + +/** + * Ground Truth (D2Client.dll 1.13c @ 0x6fb459f7..0x6fb45b58 `INVENTORY_DrawGridItems`): + * Resolves the exact cell background fill style for an item in a grid: + * - At `0x6fb45a04..0x6fb45a0c`: Hovering turns the backdrop GREEN (`index 1`) ONLY when `hasCursorItem` is true (`ds:0x6fbcbc28 != 0`). + * Hovering with an empty cursor jumps to `0x6fb45ab8` and preserves the item's RED (`index 0`) or BLUE (`index 2`) requirement color. + * - In Gamble mode (`vendorMode === 'gamble'`), all items are unidentified mystery items -> RED (`index 0`). + * - In Trade mode, items that fail `canPlayerUseUiItem` -> RED (`index 0`), usable items -> BLUE (`index 2`). + */ +export function resolveGridItemBackgroundFill( + item: UiInventoryItem, + player: PlayerItemRequirementContext, + options?: { + readonly isHovered?: boolean | undefined + readonly hasCursorItem?: boolean | undefined + readonly vendorMode?: 'trade' | 'gamble' | undefined + }, +): string { + if (options?.isHovered && options?.hasCursorItem) { + return GRID_ITEM_BG_GREEN + } + return canPlayerUseUiItem(item, player, options?.vendorMode ?? 'trade') + ? GRID_ITEM_BG_BLUE + : GRID_ITEM_BG_RED +} + export function identifyUiItem(item: UiInventoryItem): UiInventoryItem { ;(item as { identified?: boolean; isGambleMystery?: boolean }).identified = true ;(item as { identified?: boolean; isGambleMystery?: boolean }).isGambleMystery = false @@ -1124,6 +1211,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem { ...(rebuilt.damage !== undefined ? { damage: rebuilt.damage } : {}), ...(rebuilt.reqLevel !== undefined ? { reqLevel: rebuilt.reqLevel } : {}), ...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}), + ...(rebuilt.reqDex !== undefined ? { reqDex: rebuilt.reqDex } : {}), + ...(rebuilt.reqClass !== undefined ? { reqClass: rebuilt.reqClass } : {}), ...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}), ...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}), ...(rebuilt.invtransform !== undefined ? { invtransform: rebuilt.invtransform } : {}), diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 2e2f217..8e4fe46 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -24,6 +24,83 @@ import { translateItemName, translateRareName, translateMagicName } from '../gam import { isRuneCode } from '../game/gems-data.ts' import type { D2ColorCode } from './font.ts' import { isA } from '../game/item-types.ts' +import { inferItemClassFromType } from '../game/affixes.ts' +import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.ts' + +const VALID_CLASS_CODES = new Set(CHARACTER_CLASS_CODES) + +/** + * Ground Truth (D2Common.dll 1.13c @ 0x6fd74280 `ITEMS_GetClassOfClassSpecificItem` #10202): + * Resolves the required playable class (`'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'`) + * for class-specific items from `ItemTypes.txt` `Class` column and type hierarchy (`Equiv1`/`Equiv2`), + * or `undefined` if any class can equip/use the item. + */ +export function resolveItemRequiredClass( + base: { + readonly type?: string | undefined + readonly type2?: string | undefined + readonly tags?: readonly string[] | undefined + readonly class?: string | undefined + readonly reqClass?: string | undefined + readonly itemClass?: string | undefined + }, + rawItem?: { + readonly reqClass?: string | undefined + readonly itemClass?: string | undefined + }, + dropTables?: DropTables, +): CharacterClassCode | undefined { + const explicit = + rawItem?.reqClass ?? rawItem?.itemClass ?? base.reqClass ?? base.itemClass ?? base.class + if (typeof explicit === 'string') { + const norm = explicit.trim().toLowerCase() + if (VALID_CLASS_CODES.has(norm)) return norm as CharacterClassCode + } + + const isAPred = (type: string, target: string): boolean => { + const t = type.trim().toLowerCase() + if (!t) return false + if (t === target) return true + if (dropTables?.isA) { + try { + if (dropTables.isA(t, target)) return true + } catch { + // ignore + } + } + if (dropTables?.itemTypes) { + try { + if (isA(t, target, dropTables.itemTypes)) return true + } catch { + // ignore + } + } + return false + } + + const candidateTypes: string[] = [] + if (base.type) candidateTypes.push(base.type) + if (base.type2) candidateTypes.push(base.type2) + if (base.tags) candidateTypes.push(...base.tags) + + for (const rawType of candidateTypes) { + const typeCode = rawType.trim().toLowerCase() + if (!typeCode) continue + if (dropTables?.itemTypes) { + const def = dropTables.itemTypes.byCode.get(typeCode) + const defClass = def?.class?.trim().toLowerCase() + if (defClass && VALID_CLASS_CODES.has(defClass)) { + return defClass as CharacterClassCode + } + } + const inferred = inferItemClassFromType(typeCode, isAPred) + if (inferred && VALID_CLASS_CODES.has(inferred)) { + return inferred as CharacterClassCode + } + } + + return undefined +} /** * Resolves the allowable paperdoll equipment slot(s) for an item base based on @@ -496,6 +573,31 @@ export function itemToUiInventoryItem( const dropSfxFrame = (rawItem as any).dropSfxFrame ?? (rawItem as any).dropsfxframe ?? (base as any).dropsfxframe const permStore = Boolean(rawItem.permStore ?? base.permStoreItem) + const reqClass = !isGambleMystery + ? resolveItemRequiredClass(base as any, rawItem as any, dropTables) + : undefined + const effectiveReqLevel = !isGambleMystery + ? Math.max( + tt.reqLevel ?? 0, + typeof (rawItem as any).reqLevel === 'number' ? (rawItem as any).reqLevel : 0, + typeof (base as any).levelreq === 'number' ? (base as any).levelreq : 0, + ) + : 0 + const effectiveReqStr = !isGambleMystery + ? Math.max( + tt.reqStr ?? 0, + typeof (rawItem as any).reqStr === 'number' ? (rawItem as any).reqStr : 0, + typeof (base as any).reqstr === 'number' && tt.reqStr === undefined ? (base as any).reqstr : 0, + ) + : 0 + const effectiveReqDex = !isGambleMystery + ? Math.max( + tt.reqDex ?? 0, + typeof (rawItem as any).reqDex === 'number' ? (rawItem as any).reqDex : 0, + typeof (base as any).reqdex === 'number' && tt.reqDex === undefined ? (base as any).reqdex : 0, + ) + : 0 + return { id, code, @@ -514,16 +616,10 @@ export function itemToUiInventoryItem( ? { defense: rawItem.defense ?? base.defense } : {}), ...(damage !== undefined ? { damage } : {}), - ...(!isGambleMystery && tt.reqLevel !== undefined - ? { reqLevel: tt.reqLevel } - : !isGambleMystery && (rawItem.level || base.level) - ? { reqLevel: rawItem.level || base.level } - : {}), - ...(!isGambleMystery && tt.reqStr !== undefined - ? { reqStr: tt.reqStr } - : !isGambleMystery && (base as any).reqstr - ? { reqStr: (base as any).reqstr } - : {}), + ...(effectiveReqLevel > 0 ? { reqLevel: effectiveReqLevel } : {}), + ...(effectiveReqStr > 0 ? { reqStr: effectiveReqStr } : {}), + ...(effectiveReqDex > 0 ? { reqDex: effectiveReqDex } : {}), + ...(reqClass !== undefined ? { reqClass } : {}), stats, ...(!isGambleMystery && invtransform ? { invtransform } : {}), ...(!isGambleMystery && chrtransform ? { chrtransform } : {}), diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 9811374..cc9b409 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -10,7 +10,11 @@ import type { D2ColorCode, D2FontRenderer } from './font.ts' import { + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_GREEN, + GRID_ITEM_BG_RED, TOME_MAX_QUANTITY, + canPlayerUseUiItem, findFreeGridSlot, getTomeQuantity, identifyUiItem, @@ -18,13 +22,16 @@ import { isUiItemUnidentified, isUsableRightClickItem, itemToUiInventoryItem, + resolveGridItemBackgroundFill, resolveItemSpriteRect, setTomeQuantity, type EquipSlotId, type GridPlacement, type InventoryPanel, + type PlayerItemRequirementContext, type UiInventoryItem, } from './inventory.ts' +import type { CharacterClassCode } from '../game/classes.ts' import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts' import { type TownNpcServiceDescriptor, @@ -37,6 +44,15 @@ import { VendorSessionManager } from '../game/vendor-service.ts' import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts' import { isItemRepairable } from '../game/item-cost.ts' +export { + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_GREEN, + GRID_ITEM_BG_RED, + canPlayerUseUiItem, + resolveGridItemBackgroundFill, + type PlayerItemRequirementContext, +} + export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor' export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const @@ -562,6 +578,12 @@ export class WorldPanelsHud { activeVendorTab: VendorTabId = 'armor' vendorDifficulty: 0 | 1 | 2 = 0 vendorCharLevel = 85 + vendorPlayerStats: PlayerItemRequirementContext = { + level: 85, + str: 156, + dex: 75, + classCode: 'sor', + } vendorTradeState: VendorTradeState = 'idle' hoveredVendorButtonSlot: 0 | 1 | 2 | 3 | null = null pressedMomentarySlot: 0 | 1 | 2 | 3 | null = null @@ -576,6 +598,28 @@ export class WorldPanelsHud { } gamblePlacements: GridPlacement[] = [] + setPlayerContext(ctx: Partial): void { + const nextLevel = ctx.level !== undefined ? Math.max(1, ctx.level) : this.vendorCharLevel + this.vendorCharLevel = nextLevel + this.vendorPlayerStats = { + level: nextLevel, + str: ctx.str !== undefined ? Math.max(0, ctx.str) : this.vendorPlayerStats.str, + dex: ctx.dex !== undefined ? Math.max(0, ctx.dex) : this.vendorPlayerStats.dex, + classCode: ctx.classCode ?? this.vendorPlayerStats.classCode, + } + } + + getEffectivePlayerContext(): PlayerItemRequirementContext { + return { + ...this.vendorPlayerStats, + level: this.vendorCharLevel, + } + } + + canPlayerUseVendorItem(item: UiInventoryItem): boolean { + return canPlayerUseUiItem(item, this.getEffectivePlayerContext(), this.vendorMode) + } + get repairCursorMode(): boolean { return this.vendorTradeState === 'repair' } @@ -2352,9 +2396,11 @@ export class WorldPanelsHud { // 1. D2Client.dll `0x6fb45c8a–0x6fb45d63`: `buysell.dc6` 320x432 stitched background ctx.drawImage(assets.vendorBgImg, ox, oy) - // 2. D2Client.dll `0x6fb458a0`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`) + // 2. D2Client.dll `0x6fb458a0..0x6fb45b58`: Items on the active store page (`Inventory.txt` Monster2 grid at `ox+16, oy+63`) const atlas = assets.itemsAtlasImg ?? null const placements = this.getActiveVendorPlacements() + const playerCtx = this.getEffectivePlayerContext() + const hasCursorItem = Boolean((assets.inventory ?? this.activePlayerInventory)?.cursorItem) for (const placed of placements) { const gx = VENDOR_GRID_ORIGIN.x + placed.col * VENDOR_GRID_ORIGIN.cellPx const gy = VENDOR_GRID_ORIGIN.y + placed.row * VENDOR_GRID_ORIGIN.cellPx @@ -2362,11 +2408,11 @@ export class WorldPanelsHud { const gh = placed.item.invHeight * VENDOR_GRID_ORIGIN.cellPx const isHovered = this.hoveredVendorItem?.item.id === placed.item.id - ctx.fillStyle = isHovered - ? 'rgba(46, 68, 36, 0.55)' - : placed.item.quality === 'magic' - ? 'rgba(20, 34, 62, 0.42)' - : 'rgba(28, 24, 18, 0.42)' + ctx.fillStyle = resolveGridItemBackgroundFill(placed.item, playerCtx, { + isHovered, + hasCursorItem, + vendorMode: this.vendorMode, + }) ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2) const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects) @@ -2723,8 +2769,39 @@ export class WorldPanelsHud { if (item.speedText) { lines.push({ text: item.speedText, color: 'white', font: 'font8' }) } + const playerCtx = this.getEffectivePlayerContext() + if (!unid && item.reqClass !== undefined) { + const classLabelMap: Record = { + ama: '(限亚马逊使用)', + sor: '(限法师使用)', + nec: '(限死灵法师使用)', + pal: '(限圣骑士使用)', + bar: '(限野蛮人使用)', + dru: '(限德鲁伊使用)', + ass: '(限刺客使用)', + } + const classText = classLabelMap[item.reqClass] + if (classText) { + lines.push({ + text: classText, + color: playerCtx.classCode && playerCtx.classCode !== item.reqClass ? 'red' : 'white', + font: 'font8', + }) + } + } + if (item.reqDex !== undefined && item.reqDex > 0) { + lines.push({ + text: '需要敏捷: ' + item.reqDex, + color: playerCtx.dex < item.reqDex ? 'red' : 'white', + font: 'font8', + }) + } if (item.reqStr !== undefined) { - lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' }) + lines.push({ + text: '需要力量: ' + item.reqStr, + color: playerCtx.str < item.reqStr ? 'red' : 'white', + font: 'font8', + }) } const displayReqLevel = unid ? item.rawItem?.base?.level && item.rawItem.base.level > 1 @@ -2732,7 +2809,11 @@ export class WorldPanelsHud { : undefined : item.reqLevel if (displayReqLevel !== undefined) { - lines.push({ text: '需要等级: ' + displayReqLevel, color: 'white', font: 'font8' }) + lines.push({ + text: '需要等级: ' + displayReqLevel, + color: playerCtx.level < displayReqLevel ? 'red' : 'white', + font: 'font8', + }) } if (unid) { diff --git a/tests/vendor-panel-ui.test.ts b/tests/vendor-panel-ui.test.ts index e8ccd33..3f8b906 100644 --- a/tests/vendor-panel-ui.test.ts +++ b/tests/vendor-panel-ui.test.ts @@ -11,9 +11,13 @@ import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' import type { D2ColorCode, D2FontRenderer, D2FontName } from '../src/ui/font.ts' -import { InventoryPanel, type UiInventoryItem } from '../src/ui/inventory.ts' +import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts' +import { InventoryPanel, itemToUiInventoryItem, type UiInventoryItem } from '../src/ui/inventory.ts' import { BLACKSMITH_CLASS_IDS, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_GREEN, + GRID_ITEM_BG_RED, TRADER_CLASS_IDS, VENDOR_BUTTON_DRAW_Y, VENDOR_BUTTON_HIT_W, @@ -98,6 +102,7 @@ interface FontCall { function createMockCanvasAndFont() { const drawImageCalls: DrawImageCall[] = [] const fillRectCalls: number[][] = [] + const fillRectRecords: Array<{ fillStyle: string; args: number[] }> = [] const strokeRectCalls: number[][] = [] const fontCalls: FontCall[] = [] @@ -109,6 +114,7 @@ function createMockCanvasAndFont() { }), fillRect: vi.fn((...nums: number[]) => { fillRectCalls.push(nums) + fillRectRecords.push({ fillStyle: String(ctx.fillStyle), args: nums }) }), strokeRect: vi.fn((...nums: number[]) => { strokeRectCalls.push(nums) @@ -175,7 +181,7 @@ function createMockCanvasAndFont() { ), } as unknown as D2FontRenderer - return { ctx, font, drawImageCalls, fillRectCalls, strokeRectCalls, fontCalls } + return { ctx, font, drawImageCalls, fillRectCalls, fillRectRecords, strokeRectCalls, fontCalls } } describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { @@ -642,5 +648,238 @@ describe('Diablo II 1.13c Vendor / BuySell Panel UI Parity', () => { expect(openedVendorMode).toBe('trade') expect(wp.npcMenu).toBeNull() }) + + it('draws Vendor Trade items with BLUE background when usable and RED background when failing class, level, str, or dex requirements (D2Client.dll 0x6fb458a0..0x6fb45b58 & D2Common #10244)', () => { + const tables = getEmbeddedDropTables() + const wp = new WorldPanelsHud() + const inv = new InventoryPanel() + wp.activePlayerInventory = inv + expect(wp.openVendorForNpc('Charsi', 'trade', 10, 0)).toBe(true) + + // Set player context: Level 10 Sorceress with 25 Str, 25 Dex + wp.setPlayerContext({ + level: 10, + str: 25, + dex: 25, + classCode: 'sor', + }) + + // 1. Usable Normal Cap (levelreq=0, reqstr=0, high ilvl=12 must NOT falsely set reqLevel=12!) + const capBase = tables.getBase('cap')! + const usableNormalCap = itemToUiInventoryItem( + { + base: capBase, + code: 'cap', + name: 'Cap', + level: 12, + ilvl: 12, + quality: 2, + rarity: 'normal', + identified: true, + invWidth: capBase.invWidth, + invHeight: capBase.invHeight, + stack: 1, + value: 10, + stats: {}, + } as any, + tables, + ) + expect(usableNormalCap.reqLevel).toBeUndefined() + + // 2. Usable Magic Leather Armor (reqstr=15 <= 25, reqLevel=3 <= 10) + const leaBase = tables.getBase('lea')! + const usableMagicArmor = itemToUiInventoryItem( + { + base: leaBase, + code: 'lea', + name: 'Sturdy Leather Armor', + level: 12, + ilvl: 12, + quality: 4, + rarity: 'magic', + identified: true, + reqLevel: 3, + invWidth: leaBase.invWidth, + invHeight: leaBase.invHeight, + stack: 1, + value: 50, + stats: {}, + } as any, + tables, + ) + + // 3. Unusable due to Class Restriction: Assassin Katar ('ktr', type 'h2h' -> reqClass 'ass' !== 'sor') + const ktrBase = tables.getBase('ktr')! + const classRestrictedKatar = itemToUiInventoryItem( + { + base: ktrBase, + code: 'ktr', + name: 'Katar', + level: 12, + ilvl: 12, + quality: 2, + rarity: 'normal', + identified: true, + invWidth: ktrBase.invWidth, + invHeight: ktrBase.invHeight, + stack: 1, + value: 40, + stats: {}, + } as any, + tables, + ) + expect(classRestrictedKatar.reqClass).toBe('ass') + + // 4. Unusable due to Strength Requirement: Full Plate Mail ('ful', reqstr=80 > 25) + const fulBase = tables.getBase('ful')! + const highStrArmor = itemToUiInventoryItem( + { + base: fulBase, + code: 'ful', + name: 'Full Plate Mail', + level: 12, + ilvl: 12, + quality: 2, + rarity: 'normal', + identified: true, + invWidth: fulBase.invWidth, + invHeight: fulBase.invHeight, + stack: 1, + value: 500, + stats: {}, + } as any, + tables, + ) + expect(highStrArmor.reqStr).toBe(80) + + // 5. Unusable due to Dexterity Requirement: Long Bow ('lbw', reqdex=19, overridden to reqDex=40 > 25) + const lbwBase = tables.getBase('lbw')! + const highDexBow = itemToUiInventoryItem( + { + base: lbwBase, + code: 'lbw', + name: 'Long Bow', + level: 12, + ilvl: 12, + quality: 2, + rarity: 'normal', + identified: true, + reqDex: 40, + invWidth: lbwBase.invWidth, + invHeight: lbwBase.invHeight, + stack: 1, + value: 200, + stats: {}, + } as any, + tables, + ) + + // 6. Unusable due to Level Requirement: Magic Ring requiring level 25 > 10 + const rinBase = tables.getBase('rin')! + const highLevelRing = itemToUiInventoryItem( + { + base: rinBase, + code: 'rin', + name: 'Fortuitous Ring', + level: 30, + ilvl: 30, + quality: 4, + rarity: 'magic', + identified: true, + reqLevel: 25, + invWidth: rinBase.invWidth, + invHeight: rinBase.invHeight, + stack: 1, + value: 1000, + stats: {}, + } as any, + tables, + ) + + wp.vendorTabPlacements.armor = [ + { item: usableNormalCap, col: 0, row: 0 }, + { item: usableMagicArmor, col: 2, row: 0 }, + { item: classRestrictedKatar, col: 4, row: 0 }, + { item: highStrArmor, col: 5, row: 0 }, + { item: highDexBow, col: 7, row: 0 }, + { item: highLevelRing, col: 9, row: 0 }, + ] + wp.activeVendorTab = 'armor' + + // Hover over the usable normal cap WITHOUT holding a cursorItem: + // Per D2Client.dll 0x6fb45a04..0x6fb45a0c, hovering with empty cursor must NOT turn green! + wp.hoveredVendorItem = { item: usableNormalCap, x: 100, y: 130, col: 0, row: 0 } + inv.cursorItem = null + + const { ctx, font, fillRectRecords } = createMockCanvasAndFont() + wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) + + expect(fillRectRecords.map(r => r.fillStyle)).toEqual([ + GRID_ITEM_BG_BLUE, // usableNormalCap (normal quality, usable -> BLUE, never brown!) + GRID_ITEM_BG_BLUE, // usableMagicArmor (magic quality, usable -> BLUE) + GRID_ITEM_BG_RED, // classRestrictedKatar ('ass' !== 'sor' -> RED) + GRID_ITEM_BG_RED, // highStrArmor (reqStr 80 > 25 -> RED) + GRID_ITEM_BG_RED, // highDexBow (reqDex 40 > 25 -> RED) + GRID_ITEM_BG_RED, // highLevelRing (reqLevel 25 > 10 -> RED) + ]) + + // When holding a cursorItem and hovering over an item, its cell backdrop turns GREEN per 0x6fb45a04 + inv.cursorItem = usableNormalCap + const { ctx: ctx2, font: font2, fillRectRecords: recordsWithCursor } = createMockCanvasAndFont() + wp.drawLeftDockPanel(ctx2, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font2) + expect(recordsWithCursor[0]!.fillStyle).toBe(GRID_ITEM_BG_GREEN) + + // When switching to level 30 Assassin with 100 Str and 100 Dex, all 6 items become usable -> ALL BLUE! + inv.cursorItem = null + wp.hoveredVendorItem = null + wp.setPlayerContext({ + level: 30, + str: 100, + dex: 100, + classCode: 'ass', + }) + const { ctx: ctx3, font: font3, fillRectRecords: recordsAllMet } = createMockCanvasAndFont() + wp.drawLeftDockPanel(ctx3, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font3) + expect(recordsAllMet.map(r => r.fillStyle)).toEqual([ + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_BLUE, + GRID_ITEM_BG_BLUE, + ]) + }) + + it('draws ALL items in Gamble mode with RED background regardless of player level, stats, or class (D2Common.dll 0x6fd76e23 / 0x6fd77006)', () => { + const wp = new WorldPanelsHud() + const inv = new InventoryPanel() + wp.activePlayerInventory = inv + wp.setPlayerContext({ + level: 99, + str: 500, + dex: 500, + classCode: 'sor', + }) + expect(wp.openVendorForNpc('Gheed', 'gamble', 99, 0)).toBe(true) + expect(wp.gamblePlacements.length).toBeGreaterThan(0) + + // Even when hovering a gamble item with an empty cursor, every gamble item background must be RED + wp.hoveredVendorItem = { + item: wp.gamblePlacements[0]!.item, + x: 100, + y: 130, + col: wp.gamblePlacements[0]!.col, + row: wp.gamblePlacements[0]!.row, + } + + const { ctx, font, fillRectRecords } = createMockCanvasAndFont() + wp.drawLeftDockPanel(ctx, 'vendor', { ...createFakeVendorAssets(), inventory: inv }, font) + + expect(fillRectRecords).toHaveLength(wp.gamblePlacements.length) + for (const rec of fillRectRecords) { + expect(rec.fillStyle).toBe(GRID_ITEM_BG_RED) + } + }) }) +