Merge branch 'fix/vendor-item-bg-color' into main

This commit is contained in:
troytt 2026-09-27 08:36:09 +00:00
commit ffda82d650
5 changed files with 537 additions and 20 deletions

View File

@ -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

View File

@ -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 } : {}),

View File

@ -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<string>(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 } : {}),

View File

@ -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<PlayerItemRequirementContext>): 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<CharacterClassCode, string> = {
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) {

View File

@ -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)
}
})
})