feat(ui,items): implement 1.13c character equipment restrictions and red requirement display (#506)

This commit is contained in:
troytt 2026-09-28 12:13:26 +00:00
parent d4cd19d96c
commit 4e461e1fdb
9 changed files with 1059 additions and 42 deletions

View File

@ -313,6 +313,7 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
code: 'xsk',
invFile: 'invmsk',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Horadric Crest",
nameZh: "塔·拉夏的赫拉迪克纹章 (Tal Rasha's Horadric Crest)",
baseNameZh: '死亡面具 (Death Mask)',
@ -340,6 +341,7 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
code: 'uth',
invFile: 'invgth',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Guardianship",
nameZh: "塔·拉夏的守护 (Tal Rasha's Guardianship)",
baseNameZh: '漆甲 (Lacquered Plate)',
@ -367,6 +369,7 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
code: 'oba',
invFile: 'invob5',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Lidless Eye",
nameZh: "塔·拉夏的无睑之眼 (Tal Rasha's Lidless Eye)",
baseNameZh: '涡流水晶 (Swirling Crystal)',
@ -395,6 +398,7 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
code: 'zmb',
invFile: 'invmbl',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Fine-Spun Cloth",
nameZh: "塔·拉夏的织细衣服 (Tal Rasha's Fine-Spun Cloth)",
baseNameZh: '织网腰带 (Mesh Belt)',
@ -421,6 +425,7 @@ export const CLASS_EXCLUSIVE_SETS: Record<CharacterClassCode, Readonly<Partial<R
code: 'amu',
invFile: 'invamu',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Adjudication",
nameZh: "塔·拉夏的判决 (Tal Rasha's Adjudication)",
baseNameZh: '项链 (Amulet)',
@ -1593,7 +1598,7 @@ export const CLASS_STARTER_ATTRS: Record<CharacterClassCode, CharacterAttributes
level: 85,
xp: 1_068_078_030,
nextLevelXp: 1_163_635_493,
str: 220,
str: 235,
dex: 110,
vit: 380,
ene: 30,

View File

@ -97,6 +97,10 @@ export interface ItemBase {
}>> | undefined
/** Whether the item can spawn in normal gameplay or vendor stores. */
readonly spawnable?: boolean | undefined
/** Whether this weapon requires two hands (from Weapons.txt 2handed column). */
readonly twoHanded?: boolean | undefined
/** Whether a Barbarian can wield this two-handed weapon in one hand (from Weapons.txt 1or2handed column). */
readonly oneOrTwoHanded?: boolean | undefined
}
/** One affix, read from a prefix or suffix row. */

View File

@ -57,6 +57,8 @@ export interface WeaponBase extends ItemBase {
readonly twoHandedMaxdam?: number
/** Whether this weapon is a two-handed weapon. */
readonly twoHanded: boolean
/** Whether a Barbarian can wield this two-handed weapon in one hand (from 1or2handed column). */
readonly oneOrTwoHanded?: boolean | undefined
/** Minimum thrown / missile physical damage. */
readonly minMisDam?: number
/** Maximum thrown / missile physical damage. */
@ -265,6 +267,7 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
const twoHandedMindam = parseOptionalNumber(getCell(row, '2handmindam'))
const twoHandedMaxdam = parseOptionalNumber(getCell(row, '2handmaxdam'))
const twoHanded = getCell(row, '2handed') === '1'
const oneOrTwoHanded = getCell(row, '1or2handed') === '1'
const minMisDam = parseOptionalNumber(getCell(row, 'minmisdam'))
const maxMisDam = parseOptionalNumber(getCell(row, 'maxmisdam'))
@ -340,6 +343,7 @@ export function parseWeaponsTable(input: D2Table | Uint8Array | string): WeaponT
...(twoHandedMindam !== undefined ? { twoHandedMindam } : {}),
...(twoHandedMaxdam !== undefined ? { twoHandedMaxdam } : {}),
twoHanded,
oneOrTwoHanded,
...(minMisDam !== undefined ? { minMisDam } : {}),
...(maxMisDam !== undefined ? { maxMisDam } : {}),
speed,

View File

@ -1383,7 +1383,7 @@ export const HERO_CLASSES: Record<string, HeroClassInfo> = {
defaultLeftSkill: 126, defaultRightSkill: 133,
charIdentity: {
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
str: 220, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
str: 235, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
},
},
dz: {

View File

@ -369,6 +369,7 @@ export class HudManager {
},
) {
this.setLang(detectInitialLang())
this.worldPanels.activePlayerInventory = this.inventory
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
if (this.hudCanvas) {
this.hudCanvas.style.cursor = 'none'
@ -560,13 +561,15 @@ export class HudManager {
mode: 'trade' | 'gamble' = 'trade',
difficulty: 0 | 1 | 2 = 0,
): boolean {
this.worldPanels.activePlayerInventory = this.inventory
this.worldPanels.setPlayerContext({
const playerCtx = {
level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor',
})
}
this.worldPanels.activePlayerInventory = this.inventory
this.inventory.setPlayerContext(playerCtx)
this.worldPanels.setPlayerContext(playerCtx)
const ok = this.worldPanels.openVendorForNpc(
descriptorOrName,
mode,
@ -1105,6 +1108,7 @@ export class HudManager {
}
}
this.syncPlayerContext()
this.inventory.handleClick(
rightX,
pt.y,
@ -1191,13 +1195,19 @@ export class HudManager {
this.cursor.setTradeCursorMode(tradeMode, nowMs)
}
syncPublishedState(): void {
this.worldPanels.setPlayerContext({
syncPlayerContext(): void {
const playerCtx = {
level: this.charSheet.attrs.level,
str: this.charSheet.attrs.str,
dex: this.charSheet.attrs.dex,
classCode: this.currentClass ?? 'sor',
})
}
this.inventory.setPlayerContext(playerCtx)
this.worldPanels.setPlayerContext(playerCtx)
}
syncPublishedState(): void {
this.syncPlayerContext()
this.syncCursorMode(this.cursor.lastNowMs)
const cursorInfo = this.cursor.getCurrentFrame(this.cursor.lastNowMs)
this.state.dc6DecodeFailures = this.dc6DecodeFailures
@ -1255,6 +1265,7 @@ export class HudManager {
nowMs: number = performance.now(),
playerMoving: boolean = false,
): void {
this.syncPlayerContext()
if (playerMoving && this.isRunning) {
this.stamina = Math.max(0, this.stamina - 0.12)
if (this.stamina <= 0) this.isRunning = false

View File

@ -16,10 +16,30 @@ 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'
import {
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
CANONICAL_ONE_HANDED_WEAPON_CODES,
CANONICAL_SHIELD_AND_OFFHAND_CODES,
STRICT_TWO_HANDED_WEAPON_CODES,
getEffectiveItemReqClass,
itemToUiInventoryItem,
resolveAllowedSlots,
resolveItemInvFile,
} from './item-bridge.ts'
export type { SpriteRect }
export { itemToUiInventoryItem, resolveAllowedSlots, resolveItemInvFile }
export {
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
CANONICAL_ONE_HANDED_WEAPON_CODES,
CANONICAL_SHIELD_AND_OFFHAND_CODES,
STRICT_TWO_HANDED_WEAPON_CODES,
getEffectiveItemReqClass,
itemToUiInventoryItem,
resolveAllowedSlots,
resolveItemInvFile,
}
export { PLAYER_GOLD_CAP } from './world-panels.ts'
export type EquipSlotId =
@ -49,6 +69,8 @@ export interface UiInventoryItem {
readonly invWidth: number
readonly invHeight: number
readonly allowedSlots: readonly EquipSlotId[]
readonly twoHanded?: boolean | undefined
readonly oneOrTwoHanded?: boolean | undefined
readonly defense?: number | undefined
readonly damage?: string | undefined
readonly damageEn?: string | undefined
@ -605,6 +627,7 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
code: 'uap',
invFile: 'invcap',
invtransform: 'cgrn',
chrtransform: 'cgrn',
name: 'Harlequin Crest',
nameZh: '谐角之冠 (Harlequin Crest)',
baseNameZh: '军帽 (Shako)',
@ -647,6 +670,8 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
id: 'eq-oculus',
code: 'oba',
invFile: 'invob5',
invtransform: 'whit',
chrtransform: 'whit',
name: 'The Oculus',
nameZh: '眼球 (The Oculus)',
baseNameZh: '涡流水晶 (Swirling Crystal)',
@ -675,6 +700,7 @@ export const STARTER_EQUIPPED_GEAR: Readonly<Partial<Record<EquipSlotId, UiInven
code: 'uth',
invFile: 'invgth',
invtransform: 'dpur',
chrtransform: 'dpur',
name: "Tal Rasha's Guardianship",
nameZh: '塔·拉夏的守护',
baseNameZh: '漆甲 (Lacquered Plate)',
@ -1135,13 +1161,107 @@ export interface PlayerItemRequirementContext {
readonly classCode?: CharacterClassCode | undefined
}
let _playerContextSeqCounter = 0
export function nextPlayerContextSeq(): number {
return ++_playerContextSeqCounter
}
/**
* Ground Truth (D2Common.dll 1.13c `ITEMS_CheckItemRequirements` #10244 & `ITEMS_CanEquipInHandSlots` @ 0x6fd73680):
* Classifies an item targeting `weapon1` / `weapon2` into:
* - `'one_handed_weapon'`: 1H weapons (`2handed == 0`)
* - `'barbarian_1or2h_weapon'`: Two-handed swords with `1or2handed == 1` (`BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES`), which Barbarian (`bar`) can wield 1H or 2H
* - `'strict_two_handed_weapon'`: Strict 2H weapons (`2handed == 1 && 1or2handed != 1`: bows, crossbows, polearms, spears, staves, 2H axes, 2H hammers)
* - `'offhand'`: Shields (`shie`, `ashd`), Necromancer Shrunken Heads (`head`), and Quivers (`bowq`, `xboq`)
* - `'other'`: Non-hand items
*/
export type HandSlotItemCategory =
| 'one_handed_weapon'
| 'barbarian_1or2h_weapon'
| 'strict_two_handed_weapon'
| 'offhand'
| 'other'
export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory {
if (!item.allowedSlots.includes('weapon1') && !item.allowedSlots.includes('weapon2')) {
return 'other'
}
const code = (item.code ?? item.rawItem?.base?.id ?? '').trim().toLowerCase()
const rawType = (item.rawItem?.base?.type ?? '').trim().toLowerCase()
// 1. Explicit off-hand / shield check (Shields, Paladin Shields, Necromancer Shrunken Heads, Quivers)
if (
(item.allowedSlots.includes('weapon2') && !item.allowedSlots.includes('weapon1')) ||
rawType === 'shie' ||
rawType === 'ashd' ||
rawType === 'head' ||
rawType === 'bowq' ||
rawType === 'xboq' ||
(code.length > 0 && CANONICAL_SHIELD_AND_OFFHAND_CODES.has(code))
) {
return 'offhand'
}
// 2. Explicit boolean flags on UiInventoryItem or rawItem.base
const rawBase = item.rawItem?.base
if (item.oneOrTwoHanded === true || rawBase?.oneOrTwoHanded === true) {
return 'barbarian_1or2h_weapon'
}
if (item.twoHanded === true || rawBase?.twoHanded === true) {
return 'strict_two_handed_weapon'
}
if (item.twoHanded === false && item.oneOrTwoHanded === false) {
return 'one_handed_weapon'
}
// 3. Canonical 1.13c Weapons.txt / Armor.txt code lookup
if (code.length > 0) {
if (BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code)) {
return 'barbarian_1or2h_weapon'
}
if (STRICT_TWO_HANDED_WEAPON_CODES.has(code)) {
return 'strict_two_handed_weapon'
}
if (CANONICAL_ONE_HANDED_WEAPON_CODES.has(code)) {
return 'one_handed_weapon'
}
}
// 4. ItemType fallback for custom test fixtures without weapon codes
if (
rawType === 'bow' ||
rawType === 'abow' ||
rawType === 'xbow' ||
rawType === 'pole' ||
rawType === 'spea' ||
rawType === 'aspe' ||
rawType === 'staf'
) {
return 'strict_two_handed_weapon'
}
// 5. Tooltip damage line fallback (`双手伤害:` / `Two-Hand Damage:`)
const dmgText = `${item.damage ?? ''} ${item.damageEn ?? ''}`
const hasTwoHandDmg = dmgText.includes('双手伤害') || dmgText.includes('Two-Hand Damage')
const hasOneHandDmg = dmgText.includes('单手伤害') || dmgText.includes('One-Hand Damage')
if (hasTwoHandDmg && hasOneHandDmg) {
return 'barbarian_1or2h_weapon'
}
if (hasTwoHandDmg) {
return 'strict_two_handed_weapon'
}
return 'one_handed_weapon'
}
/**
* 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)
* 4. Class restriction (`0x6fd74280` `ITEMS_GetClassOfClassSpecificItem` #10202) -> false if `effectiveReqClass !== 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)
@ -1161,7 +1281,8 @@ export function canPlayerUseUiItem(
if ((code === 'tbk' || code === 'ibk') && getTomeQuantity(item) <= 0) {
return false
}
if (item.reqClass !== undefined && player.classCode !== undefined && item.reqClass !== player.classCode) {
const effectiveReqClass = getEffectiveItemReqClass(item)
if (effectiveReqClass !== undefined && player.classCode !== undefined && effectiveReqClass !== player.classCode) {
return false
}
if (item.reqLevel !== undefined && item.reqLevel > 0 && player.level < item.reqLevel) {
@ -1245,6 +1366,8 @@ export function identifyUiItem(item: UiInventoryItem): UiInventoryItem {
...(rebuilt.reqStr !== undefined ? { reqStr: rebuilt.reqStr } : {}),
...(rebuilt.reqDex !== undefined ? { reqDex: rebuilt.reqDex } : {}),
...(rebuilt.reqClass !== undefined ? { reqClass: rebuilt.reqClass } : {}),
...(rebuilt.twoHanded !== undefined ? { twoHanded: rebuilt.twoHanded } : {}),
...(rebuilt.oneOrTwoHanded !== undefined ? { oneOrTwoHanded: rebuilt.oneOrTwoHanded } : {}),
...(rebuilt.durability !== undefined ? { durability: rebuilt.durability } : {}),
...(rebuilt.sockets !== undefined ? { sockets: rebuilt.sockets } : {}),
...(rebuilt.speedText !== undefined ? { speedText: rebuilt.speedText } : {}),
@ -1304,6 +1427,28 @@ export class InventoryPanel {
hoveredItem: { item: UiInventoryItem; x: number; y: number } | null = null
/** Player character level for authentic inventory gold capacity calculation. */
playerLevel = 1
/**
* Player character requirement context (`level`, `str`, `dex`, `classCode`) for
* 1.13c equipment validation (`ITEMS_CheckItemRequirements` & `ITEMS_CanEquipInHandSlots`).
*/
playerContext: PlayerItemRequirementContext = {
level: 85,
str: 156,
dex: 75,
classCode: 'sor',
}
/** Monotonic sequence counter for syncing `playerContext` with `WorldPanelsHud`. */
playerContextSeq = 0
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
this.playerContext = {
level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level,
str: ctx.str !== undefined ? Math.max(0, ctx.str) : this.playerContext.str,
dex: ctx.dex !== undefined ? Math.max(0, ctx.dex) : this.playerContext.dex,
classCode: ctx.classCode !== undefined ? ctx.classCode : this.playerContext.classCode,
}
this.playerContextSeq = nextPlayerContextSeq()
}
/** Authentic 1.13c inventory gold capacity: playerLevel * 10,000. */
get maxGold(): number {
@ -1518,9 +1663,76 @@ export class InventoryPanel {
return false
}
/**
* Ground Truth (D2Common.dll 1.13c `ITEMS_CheckItemRequirements` #10244 & `ITEMS_CanEquipInHandSlots` @ 0x6fd73680):
* Validates whether `item` can be equipped into `slotId` given the current `playerContext` and currently equipped items:
* 1. Must be identified and satisfy level (`reqLevel`), strength (`reqStr`), dexterity (`reqDex`), and class (`reqClass`) requirements.
* 2. Must be allowed in `slotId`.
* 3. Hand slot (`weapon1` / `weapon2`) rules when the opposite hand slot (`otherItem`) is occupied:
* - Strict 2H weapons (`strict_two_handed_weapon`) always occupy both hands and cannot be combined with any weapon or shield (for any class, including Barbarian).
* - Two shields/off-hands (`offhand` + `offhand`) cannot be equipped together.
* - Non-Barbarian (`classCode !== 'bar'`):
* - Can only equip 1 one-handed weapon + 1 shield/off-hand, OR 1 two-handed weapon (`strict_two_handed_weapon` or `barbarian_1or2h_weapon`).
* - Cannot dual-wield two weapons, and cannot pair a 2H sword (`barbarian_1or2h_weapon`) with a shield.
* - Barbarian (`classCode === 'bar'`):
* - Can dual-wield one-handed weapons (`one_handed_weapon`) and/or `1or2handed` two-handed swords (`barbarian_1or2h_weapon`).
* - Can pair a `1or2handed` two-handed sword (`barbarian_1or2h_weapon`) with a shield (`offhand`).
*/
canEquipItemInSlot(item: UiInventoryItem, slotId: EquipSlotId): boolean {
if (isUiItemUnidentified(item)) {
return false
}
if (!item.allowedSlots.includes(slotId)) {
return false
}
if (!canPlayerUseUiItem(item, this.playerContext)) {
return false
}
if (slotId === 'weapon1' || slotId === 'weapon2') {
const incomingCat = classifyHandSlotItem(item)
if (incomingCat === 'other') {
return false
}
const otherSlotId: EquipSlotId = slotId === 'weapon1' ? 'weapon2' : 'weapon1'
const otherItem = this.equipped[otherSlotId] ?? null
if (!otherItem) {
return true
}
const otherCat = classifyHandSlotItem(otherItem)
// Strict 2H weapons always require both hands for all classes (including Barbarian)
if (incomingCat === 'strict_two_handed_weapon' || otherCat === 'strict_two_handed_weapon') {
return false
}
// Cannot equip two shields / off-hands simultaneously
if (incomingCat === 'offhand' && otherCat === 'offhand') {
return false
}
const isBarbarian = this.playerContext.classCode === 'bar'
// Non-Barbarians treat 1or2handed two-handed swords as strict 2H weapons
if (!isBarbarian && (incomingCat === 'barbarian_1or2h_weapon' || otherCat === 'barbarian_1or2h_weapon')) {
return false
}
// One weapon + one off-hand shield/quiver/shrunken-head
if (incomingCat === 'offhand' || otherCat === 'offhand') {
return true
}
// Both slots are weapons (Dual Wielding): only Barbarian (`bar`) is allowed
return isBarbarian
}
return true
}
/**
* Handle left-click on an equipment slot (`helm`, `weapon1`, `armor`, etc.).
* Unidentified items cannot be equipped in Diablo II v1.13c.
* Enforces Diablo II v1.13c item level/attribute/class requirements and weapon/shield hand-slot rules.
*/
clickEquipSlot(slotId: EquipSlotId): boolean {
const existing = this.equipped[slotId] ?? null
@ -1530,10 +1742,7 @@ export class InventoryPanel {
this.cursorItem = existing
return true
}
if (isUiItemUnidentified(this.cursorItem)) {
return false
}
if (!this.cursorItem.allowedSlots.includes(slotId)) {
if (!this.canEquipItemInSlot(this.cursorItem, slotId)) {
return false
}
this.equipped[slotId] = this.cursorItem
@ -1822,7 +2031,9 @@ export class InventoryPanel {
for (const [slotId, rect] of Object.entries(EQUIP_SLOTS_LAYOUT) as [EquipSlotId, typeof EQUIP_SLOTS_LAYOUT[EquipSlotId]][]) {
const eq = this.equipped[slotId]
if (!eq) continue
ctx.fillStyle = 'rgba(18, 42, 24, 0.52)'
ctx.fillStyle = canPlayerUseUiItem(eq, this.playerContext)
? 'rgba(18, 42, 24, 0.52)'
: 'rgba(72, 18, 18, 0.50)'
ctx.fillRect(rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2)
this.drawItemSprite(ctx, eq, rect.x, rect.y, rect.w, rect.h, assets)
}
@ -1834,13 +2045,14 @@ export class InventoryPanel {
const gw = placed.item.invWidth * INV_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * INV_GRID_ORIGIN.cellPx
ctx.fillStyle = isUiItemUnidentified(placed.item)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillStyle =
isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, this.playerContext)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
this.drawItemSprite(ctx, placed.item, gx, gy, gw, gh, assets)
}

View File

@ -29,6 +29,113 @@ import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes.
const VALID_CLASS_CODES = new Set<string>(CHARACTER_CLASS_CODES)
/**
* Ground Truth (`Weapons.txt` `1or2handed === 1` & `2handed === 1` in Diablo II v1.13c):
* The 18 two-handed swords (`type = swor`) that a Barbarian (`bar`) can wield in one hand
* (allowing dual-wielding or pairing with a shield) while all other classes must wield them two-handed.
*/
export const BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES: ReadonlySet<string> = new Set([
'2hs', 'clm', 'gis', 'bsw', 'flb', 'gsd',
'92h', '9cm', '9gs', '9b9', '9fb', '9gd',
'72h', '7cm', '7gs', '7b7', '7fb', '7gd',
])
/**
* Ground Truth (`Weapons.txt` `2handed === 1` & `1or2handed !== 1` in Diablo II v1.13c):
* All 119 strict two-handed weapons (2H axes, mauls, spears, polearms, staves, bows, crossbows,
* Amazon bows, Amazon spears) that require both hands for ALL classes including Barbarian.
*/
export const STRICT_TWO_HANDED_WEAPON_CODES: ReadonlySet<string> = new Set([
'lax', 'bax', 'btx', 'gax', 'gix', 'mau', 'gma', 'spr', 'tri', 'brn', 'spt', 'pik',
'bar', 'vou', 'scy', 'pax', 'hal', 'wsc', 'sst', 'lst', 'cst', 'bst', 'wst', 'sbw',
'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb', 'lxb', 'mxb', 'hxb', 'rxb', 'hst',
'msf', '9la', '9ba', '9bt', '9ga', '9gi', '9m9', '9gm', '9sr', '9tr', '9br', '9st',
'9p9', '9b7', '9vo', '9s8', '9pa', '9h9', '9wc', '8ss', '8ls', '8cs', '8bs', '8ws',
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw', '8lx', '8mx', '8hx', '8rx',
'7la', '7ba', '7bt', '7ga', '7gi', '7m7', '7gm', '7sr', '7tr', '7br', '7st', '7p7',
'7o7', '7vo', '7s8', '7pa', '7h7', '7wc', '6ss', '6ls', '6cs', '6bs', '6ws', '6sb',
'6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw', '6lx', '6mx', '6hx', '6rx', 'am1',
'am2', 'am3', 'am4', 'am6', 'am7', 'am8', 'am9', 'amb', 'amc', 'amd', 'ame',
])
/**
* Ground Truth (`Weapons.txt` `2handed !== 1` in Diablo II v1.13c):
* All 169 one-handed weapons across normal, exceptional, elite, and class/quest tiers.
*/
export const CANONICAL_ONE_HANDED_WEAPON_CODES: ReadonlySet<string> = new Set([
'hax', 'axe', '2ax', 'mpi', 'wax', 'wnd', 'ywn', 'bwn', 'gwn', 'clb', 'scp', 'gsc',
'wsp', 'spc', 'mac', 'mst', 'fla', 'whm', 'ssd', 'scm', 'sbr', 'flc', 'crs', 'bsd',
'lsd', 'wsd', 'dgr', 'dir', 'kri', 'bld', 'tkf', 'tax', 'bkf', 'bal', 'jav', 'pil',
'ssp', 'glv', 'tsp', 'gps', 'ops', 'gpm', 'opm', 'gpl', 'opl', 'd33', 'g33', 'leg',
'hdm', 'hfh', '9ha', '9ax', '92a', '9mp', '9wa', '9wn', '9yw', '9bw', '9gw', '9cl',
'9sc', '9qs', '9ws', '9sp', '9ma', '9mt', '9fl', '9wh', '9ss', '9sm', '9sb', '9fc',
'9cr', '9bs', '9ls', '9wd', '9dg', '9di', '9kr', '9bl', '9tk', '9ta', '9bk', '9b8',
'9ja', '9pi', '9s9', '9gl', '9ts', 'qf1', 'qf2', 'ktr', 'wrb', 'axf', 'ces', 'clw',
'btl', 'skr', '9ar', '9wb', '9xf', '9cs', '9lw', '9tw', '9qr', '7ar', '7wb', '7xf',
'7cs', '7lw', '7tw', '7qr', '7ha', '7ax', '72a', '7mp', '7wa', '7wn', '7yw', '7bw',
'7gw', '7cl', '7sc', '7qs', '7ws', '7sp', '7ma', '7mt', '7fl', '7wh', '7ss', '7sm',
'7sb', '7fc', '7cr', '7bs', '7ls', '7wd', '7dg', '7di', '7kr', '7bl', '7tk', '7ta',
'7bk', '7b8', '7ja', '7pi', '7s7', '7gl', '7ts', 'ob1', 'ob2', 'ob3', 'ob4', 'ob5',
'am5', 'ob6', 'ob7', 'ob8', 'ob9', 'oba', 'ama', 'obb', 'obc', 'obd', 'obe', 'obf',
'amf',
])
/**
* Ground Truth (`Armor.txt` `shie` / `ashd` / `head` + `Misc.txt` `bowq` / `xboq` in Diablo II v1.13c):
* All 54 shield, paladin shield, necromancer shrunken head, and quiver codes.
*/
export const CANONICAL_SHIELD_AND_OFFHAND_CODES: ReadonlySet<string> = new Set([
'buc', 'sml', 'lrg', 'kit', 'tow', 'gts', 'bsh', 'spk',
'xuc', 'xml', 'xrg', 'xit', 'xow', 'xts', 'xsh', 'xpk',
'uuc', 'uml', 'urg', 'uit', 'uow', 'uts', 'ush', 'upk',
'pa1', 'pa2', 'pa3', 'pa4', 'pa5', 'pa6', 'pa7', 'pa8', 'pa9', 'paa', 'pab', 'pac', 'pad', 'pae', 'paf',
'ne1', 'ne2', 'ne3', 'ne4', 'ne5', 'ne6', 'ne7', 'ne8', 'ne9', 'nea', 'neb', 'neg', 'ned', 'nee', 'nef',
'aqv', 'cqv',
])
/**
* Ground Truth (`ItemTypes.txt` `Class` column + `Weapons.txt` / `Armor.txt` in Diablo II v1.13c):
* All 111 class-specific item codes mapped to their required playable class.
*/
export const CANONICAL_CLASS_SPECIFIC_ITEM_CODES: Readonly<Record<string, CharacterClassCode>> = {
ktr: 'ass', wrb: 'ass', axf: 'ass', ces: 'ass', clw: 'ass', btl: 'ass', skr: 'ass',
'9ar': 'ass', '9wb': 'ass', '9xf': 'ass', '9cs': 'ass', '9lw': 'ass', '9tw': 'ass', '9qr': 'ass',
'7ar': 'ass', '7wb': 'ass', '7xf': 'ass', '7cs': 'ass', '7lw': 'ass', '7tw': 'ass', '7qr': 'ass',
ob1: 'sor', ob2: 'sor', ob3: 'sor', ob4: 'sor', ob5: 'sor',
ob6: 'sor', ob7: 'sor', ob8: 'sor', ob9: 'sor', oba: 'sor',
obb: 'sor', obc: 'sor', obd: 'sor', obe: 'sor', obf: 'sor',
am1: 'ama', am2: 'ama', am3: 'ama', am4: 'ama', am5: 'ama',
am6: 'ama', am7: 'ama', am8: 'ama', am9: 'ama', ama: 'ama',
amb: 'ama', amc: 'ama', amd: 'ama', ame: 'ama', amf: 'ama',
dr1: 'dru', dr2: 'dru', dr3: 'dru', dr4: 'dru', dr5: 'dru',
dr6: 'dru', dr7: 'dru', dr8: 'dru', dr9: 'dru', dra: 'dru',
drb: 'dru', drc: 'dru', drd: 'dru', dre: 'dru', drf: 'dru',
ba1: 'bar', ba2: 'bar', ba3: 'bar', ba4: 'bar', ba5: 'bar',
ba6: 'bar', ba7: 'bar', ba8: 'bar', ba9: 'bar', baa: 'bar',
bab: 'bar', bac: 'bar', bad: 'bar', bae: 'bar', baf: 'bar',
pa1: 'pal', pa2: 'pal', pa3: 'pal', pa4: 'pal', pa5: 'pal',
pa6: 'pal', pa7: 'pal', pa8: 'pal', pa9: 'pal', paa: 'pal',
pab: 'pal', pac: 'pal', pad: 'pal', pae: 'pal', paf: 'pal',
ne1: 'nec', ne2: 'nec', ne3: 'nec', ne4: 'nec', ne5: 'nec',
ne6: 'nec', ne7: 'nec', ne8: 'nec', ne9: 'nec', nea: 'nec',
neb: 'nec', neg: 'nec', ned: 'nec', nee: 'nec', nef: 'nec',
}
/**
* Resolves the effective class restriction (`reqClass`) for a UI item, checking explicit
* `item.reqClass` first and falling back to `CANONICAL_CLASS_SPECIFIC_ITEM_CODES[code]`.
*/
export function getEffectiveItemReqClass(item: {
readonly reqClass?: CharacterClassCode | undefined
readonly code?: string | undefined
}): CharacterClassCode | undefined {
if (item.reqClass !== undefined && VALID_CLASS_CODES.has(item.reqClass)) {
return item.reqClass
}
const code = (item.code ?? '').trim().toLowerCase()
return code ? CANONICAL_CLASS_SPECIFIC_ITEM_CODES[code] : undefined
}
/**
* Ground Truth (D2Common.dll 1.13c @ 0x6fd74280 `ITEMS_GetClassOfClassSpecificItem` #10202):
* Resolves the required playable class (`'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'`)
@ -631,6 +738,25 @@ export function itemToUiInventoryItem(
typeof (base as any).reqdex === 'number' && tt.reqDex === undefined ? (base as any).reqdex : 0,
)
: 0
const isTwoHandedWeapon =
!isGambleMystery &&
Boolean(
(rawItem as any).twoHanded ||
base.twoHanded ||
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
STRICT_TWO_HANDED_WEAPON_CODES.has(code) ||
(dropTables?.weapons?.get?.(code) as any)?.twoHanded ||
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1',
)
const isOneOrTwoHandedWeapon =
!isGambleMystery &&
Boolean(
(rawItem as any).oneOrTwoHanded ||
base.oneOrTwoHanded ||
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) ||
(dropTables?.weapons?.get?.(code) as any)?.oneOrTwoHanded ||
(dropTables?.weapons?.get?.(code) as any)?.['1or2handed'] === '1',
)
return {
id,
@ -645,6 +771,8 @@ export function itemToUiInventoryItem(
invWidth: rawItem.invWidth ?? base.invWidth ?? 1,
invHeight: rawItem.invHeight ?? base.invHeight ?? 1,
allowedSlots,
...(isTwoHandedWeapon ? { twoHanded: true } : {}),
...(isOneOrTwoHandedWeapon ? { oneOrTwoHanded: true } : {}),
...(!isGambleMystery && tt.defense !== undefined
? { defense: tt.defense }
: !isGambleMystery && (rawItem.defense !== undefined || base.defense > 0)

View File

@ -16,12 +16,14 @@ import {
TOME_MAX_QUANTITY,
canPlayerUseUiItem,
findFreeGridSlot,
getEffectiveItemReqClass,
getTomeQuantity,
identifyUiItem,
isScrollForTome,
isUiItemUnidentified,
isUsableRightClickItem,
itemToUiInventoryItem,
nextPlayerContextSeq,
resolveGridItemBackgroundFill,
resolveItemSpriteRect,
setTomeQuantity,
@ -659,6 +661,7 @@ export class WorldPanelsHud {
misc: [],
}
gamblePlacements: GridPlacement[] = []
playerContextSeq = 0
setPlayerContext(ctx: Partial<PlayerItemRequirementContext>): void {
const nextLevel = ctx.level !== undefined ? Math.max(1, ctx.level) : this.vendorCharLevel
@ -669,9 +672,23 @@ export class WorldPanelsHud {
dex: ctx.dex !== undefined ? Math.max(0, ctx.dex) : this.vendorPlayerStats.dex,
classCode: ctx.classCode ?? this.vendorPlayerStats.classCode,
}
const seq = nextPlayerContextSeq()
this.playerContextSeq = seq
if (this.activePlayerInventory) {
this.activePlayerInventory.playerContext = { ...this.vendorPlayerStats }
this.activePlayerInventory.playerContextSeq = seq
}
}
getEffectivePlayerContext(): PlayerItemRequirementContext {
if (
this.activePlayerInventory &&
this.activePlayerInventory.playerContextSeq > this.playerContextSeq
) {
return {
...this.activePlayerInventory.playerContext,
}
}
return {
...this.vendorPlayerStats,
level: this.vendorCharLevel,
@ -2403,19 +2420,21 @@ export class WorldPanelsHud {
// Draw items stored in stash
const atlas = assets.itemsAtlasImg ?? null
const playerCtx = this.getEffectivePlayerContext()
for (const placed of this.stashItems) {
const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx
const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx
const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx
ctx.fillStyle = isUiItemUnidentified(placed.item)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillStyle =
isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, playerCtx)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
@ -2460,19 +2479,21 @@ export class WorldPanelsHud {
// Draw items stored in Horadric Cube
const atlas = assets.itemsAtlasImg ?? null
const playerCtx = this.getEffectivePlayerContext()
for (const placed of this.cubeItems) {
const gx = CUBE_GRID_ORIGIN.x + placed.col * CUBE_GRID_ORIGIN.cellPx
const gy = CUBE_GRID_ORIGIN.y + placed.row * CUBE_GRID_ORIGIN.cellPx
const gw = placed.item.invWidth * CUBE_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * CUBE_GRID_ORIGIN.cellPx
ctx.fillStyle = isUiItemUnidentified(placed.item)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillStyle =
isUiItemUnidentified(placed.item) || !canPlayerUseUiItem(placed.item, playerCtx)
? 'rgba(72, 18, 18, 0.50)'
: placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
@ -3050,7 +3071,8 @@ export class WorldPanelsHud {
lines.push({ text: speedLine, color: 'white', font: 'font8' })
}
const playerCtx = this.getEffectivePlayerContext()
if (!unid && item.reqClass !== undefined) {
const effectiveReqClass = getEffectiveItemReqClass(item)
if (!unid && effectiveReqClass !== undefined) {
const classLabelMapZh: Record<CharacterClassCode, string> = {
ama: '(限亚马逊使用)',
sor: '(限法师使用)',
@ -3069,11 +3091,11 @@ export class WorldPanelsHud {
dru: '(Druid Only)',
ass: '(Assassin Only)',
}
const classText = isEn ? classLabelMapEn[item.reqClass] : classLabelMapZh[item.reqClass]
const classText = isEn ? classLabelMapEn[effectiveReqClass] : classLabelMapZh[effectiveReqClass]
if (classText) {
lines.push({
text: classText,
color: playerCtx.classCode && playerCtx.classCode !== item.reqClass ? 'red' : 'white',
color: playerCtx.classCode && playerCtx.classCode !== effectiveReqClass ? 'red' : 'white',
font: 'font8',
})
}

View File

@ -0,0 +1,631 @@
import { describe, expect, it } from 'vitest'
import {
BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES,
CANONICAL_CLASS_SPECIFIC_ITEM_CODES,
CANONICAL_ONE_HANDED_WEAPON_CODES,
CANONICAL_SHIELD_AND_OFFHAND_CODES,
EQUIP_SLOTS_LAYOUT,
INV_GRID_ORIGIN,
InventoryPanel,
STRICT_TWO_HANDED_WEAPON_CODES,
canPlayerUseUiItem,
classifyHandSlotItem,
getEffectiveItemReqClass,
itemToUiInventoryItem,
type UiInventoryItem,
} from '../src/ui/inventory.ts'
import { WorldPanelsHud } from '../src/ui/world-panels.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
import { CLASS_STARTER_ATTRS, createStarterProfileForClass } from '../src/game/class-starter-profiles.ts'
import type { CharacterClassCode } from '../src/game/classes.ts'
function createMockCtx() {
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
let currentFillStyle = ''
const ctx = {
save: () => {},
restore: () => {},
fillRect: (x: number, y: number, w: number, h: number) => {
fillRectCalls.push({ x, y, w, h, fillStyle: currentFillStyle })
},
strokeRect: () => {},
drawImage: () => {},
beginPath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
fillText: () => {},
measureText: (t: string) => ({ width: t.length * 8 }),
get fillStyle() {
return currentFillStyle
},
set fillStyle(val: string) {
currentFillStyle = val
},
strokeStyle: '',
lineWidth: 1,
font: '',
textAlign: 'left',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
return { ctx, fillRectCalls }
}
function captureTooltipLines(
hud: WorldPanelsHud,
item: UiInventoryItem,
): Array<{ text: string; color: string; x: number; y: number }> {
const { ctx } = createMockCtx()
const font = new D2FontRenderer()
const drawnLines: Array<{ text: string; color: string; x: number; y: number }> = []
font.drawText = (_c, text, x, y, opts) => {
drawnLines.push({ text, color: opts?.color ?? 'white', x, y })
}
hud.drawItemTooltip(ctx, { item, x: 400, y: 300 }, font)
return drawnLines
}
describe('Issue #506: Diablo II v1.13c Character Equipment Restrictions & Red Requirement Display', () => {
it('verifies canonical 1.13c weapon, shield, and class-specific item code sets from MPQ data tables', () => {
const tables = getEmbeddedDropTables()
expect(BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.size).toBe(18)
expect(BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('2hs')).toBe(true)
expect(BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('7gd')).toBe(true) // Colossus Blade
expect(STRICT_TWO_HANDED_WEAPON_CODES.size).toBe(119)
expect(STRICT_TWO_HANDED_WEAPON_CODES.has('7m7')).toBe(true) // Ogre Maul (IK Maul)
expect(STRICT_TWO_HANDED_WEAPON_CODES.has('6lw')).toBe(true) // Hydra Bow
expect(STRICT_TWO_HANDED_WEAPON_CODES.has('6bs')).toBe(true) // Archon Staff
expect(CANONICAL_ONE_HANDED_WEAPON_CODES.has('crs')).toBe(true) // Crystal Sword
expect(CANONICAL_ONE_HANDED_WEAPON_CODES.has('7cr')).toBe(true) // Phase Blade
expect(CANONICAL_ONE_HANDED_WEAPON_CODES.has('7wa')).toBe(true) // Berserker Axe
expect(CANONICAL_SHIELD_AND_OFFHAND_CODES.has('uit')).toBe(true) // Monarch
expect(CANONICAL_SHIELD_AND_OFFHAND_CODES.has('paf')).toBe(true) // Vortex Shield
expect(CANONICAL_SHIELD_AND_OFFHAND_CODES.has('nea')).toBe(true) // Succubus Skull
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['oba']).toBe('sor')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['baa']).toBe('bar')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['paa']).toBe('pal')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['ama']).toBe('ama')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['nea']).toBe('nec')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['dra']).toBe('dru')
expect(CANONICAL_CLASS_SPECIFIC_ITEM_CODES['ktr']).toBe('ass')
// Verify WeaponBase in embedded drop tables parses oneOrTwoHanded
const colossusBlade = tables.weapons.get('7gd')
expect(colossusBlade?.twoHanded).toBe(true)
expect(colossusBlade?.oneOrTwoHanded).toBe(true)
const ogreMaul = tables.weapons.get('7m7')
expect(ogreMaul?.twoHanded).toBe(true)
expect(ogreMaul?.oneOrTwoHanded).toBe(false)
})
it('Requirement 1: blocks equipping items exceeding character level, strength, dexterity, or class requirements', () => {
const inv = new InventoryPanel()
delete inv.equipped.helm
delete inv.equipped.armor
delete inv.equipped.weapon1
delete inv.equipped.weapon2
inv.setPlayerContext({
level: 40,
str: 80,
dex: 50,
classCode: 'sor',
})
// 1. Exceeds level requirement (`reqLevel: 62 > 40`)
const shako: UiInventoryItem = {
id: 'test-shako',
code: 'uap',
name: 'Harlequin Crest',
nameZh: '谐角之冠',
baseNameZh: '军帽',
quality: 'unique',
invFile: 'invcap',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
reqLevel: 62,
reqStr: 50,
stats: [],
identified: true,
}
inv.cursorItem = shako
expect(inv.canEquipItemInSlot(shako, 'helm')).toBe(false)
expect(inv.clickEquipSlot('helm')).toBe(false)
expect(inv.equipped.helm).toBeUndefined()
expect(inv.cursorItem).toBe(shako)
// 2. Exceeds strength requirement (`reqStr: 156 > 80`)
const monarch: UiInventoryItem = {
id: 'test-monarch',
code: 'uit',
name: 'Stormshield',
nameZh: '暴风之盾',
baseNameZh: '统治者大盾',
quality: 'unique',
invFile: 'invkts',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon2'],
reqLevel: 30,
reqStr: 156,
stats: [],
identified: true,
}
inv.cursorItem = monarch
expect(inv.canEquipItemInSlot(monarch, 'weapon2')).toBe(false)
expect(inv.clickEquipSlot('weapon2')).toBe(false)
expect(inv.equipped.weapon2).toBeUndefined()
// 3. Exceeds dexterity requirement (`reqDex: 136 > 50`)
const phaseBlade: UiInventoryItem = {
id: 'test-pb',
code: '7cr',
name: 'Phase Blade',
nameZh: '幻化之刃',
baseNameZh: '幻化之刃',
quality: 'normal',
invFile: 'invpbl',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 30,
reqStr: 25,
reqDex: 136,
stats: [],
identified: true,
}
inv.cursorItem = phaseBlade
expect(inv.canEquipItemInSlot(phaseBlade, 'weapon1')).toBe(false)
expect(inv.clickEquipSlot('weapon1')).toBe(false)
expect(inv.equipped.weapon1).toBeUndefined()
// 4. Wrong character class (`reqClass: 'bar'` or canonical class code `baa` while player is `'sor'`)
const arreatsFace: UiInventoryItem = {
id: 'test-arreat',
code: 'baa',
name: "Arreat's Face",
nameZh: '亚瑞特的面容',
baseNameZh: '杀手防护面甲',
quality: 'unique',
invFile: 'invba2',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
reqLevel: 20,
reqStr: 50,
stats: [],
identified: true,
}
expect(getEffectiveItemReqClass(arreatsFace)).toBe('bar')
inv.cursorItem = arreatsFace
expect(inv.canEquipItemInSlot(arreatsFace, 'helm')).toBe(false)
expect(inv.clickEquipSlot('helm')).toBe(false)
expect(inv.equipped.helm).toBeUndefined()
// Raise player stats and switch class to Barbarian: now Arreat's Face can be equipped!
inv.setPlayerContext({
level: 85,
str: 180,
dex: 150,
classCode: 'bar',
})
expect(inv.canEquipItemInSlot(arreatsFace, 'helm')).toBe(true)
expect(inv.clickEquipSlot('helm')).toBe(true)
expect(inv.equipped.helm?.id).toBe('test-arreat')
expect(inv.cursorItem).toBeNull()
})
it('Requirement 1 UI: renders unmet level, strength, dexterity, and class requirements in RED in tooltips and grid backdrops', () => {
const inv = new InventoryPanel()
const worldPanels = new WorldPanelsHud()
worldPanels.activePlayerInventory = inv
// Sorceress with low level, str, and dex inspecting a Barbarian-only item with high reqs
inv.setPlayerContext({
level: 25,
str: 40,
dex: 30,
classCode: 'sor',
})
const barbSword: UiInventoryItem = {
id: 'test-barb-item',
code: 'baa',
name: 'Slayer Guard',
nameZh: '杀手防护面甲',
baseNameZh: '杀手防护面甲',
quality: 'rare',
invFile: 'invba2',
invWidth: 2,
invHeight: 2,
allowedSlots: ['helm'],
reqLevel: 60,
reqStr: 118,
reqDex: 75,
stats: [{ text: '+2 野蛮人技能等级', color: 'blue' }],
identified: true,
}
// Check ZH tooltip lines
worldPanels.lang = 'CHI'
const zhLines = captureTooltipLines(worldPanels, barbSword)
const zhClassLine = zhLines.find(l => l.text === '(限野蛮人使用)')
const zhLevelLine = zhLines.find(l => l.text === '需要等级: 60')
const zhStrLine = zhLines.find(l => l.text === '需要力量: 118')
const zhDexLine = zhLines.find(l => l.text === '需要敏捷: 75')
expect(zhClassLine?.color).toBe('red')
expect(zhLevelLine?.color).toBe('red')
expect(zhStrLine?.color).toBe('red')
expect(zhDexLine?.color).toBe('red')
// Check EN tooltip lines
worldPanels.lang = 'ENG'
const enLines = captureTooltipLines(worldPanels, barbSword)
expect(enLines.find(l => l.text === '(Barbarian Only)')?.color).toBe('red')
expect(enLines.find(l => l.text === 'Required Level: 60')?.color).toBe('red')
expect(enLines.find(l => l.text === 'Required Strength: 118')?.color).toBe('red')
expect(enLines.find(l => l.text === 'Required Dexterity: 75')?.color).toBe('red')
// Check Inventory 10x4 grid backdrop is RED (`rgba(72, 18, 18, 0.50)`) when unmet
inv.visible = true
inv.gridItems = [{ item: barbSword, col: 0, row: 0 }]
const { ctx, fillRectCalls } = createMockCtx()
inv.draw(
ctx,
{
borderRightImg: null,
invSheetImg: null,
invTab0Img: null,
invTab1Img: null,
buySellBtnImg: null,
itemsAtlasImg: null,
itemRects: {},
},
new D2FontRenderer(),
)
const cellRect = fillRectCalls.find(
c => c.x === INV_GRID_ORIGIN.x + 1 && c.y === INV_GRID_ORIGIN.y + 1,
)
expect(cellRect?.fillStyle).toBe('rgba(72, 18, 18, 0.50)')
// Now update playerContext so all requirements are met
inv.setPlayerContext({
level: 85,
str: 150,
dex: 100,
classCode: 'bar',
})
worldPanels.lang = 'CHI'
const metLines = captureTooltipLines(worldPanels, barbSword)
expect(metLines.find(l => l.text === '(限野蛮人使用)')?.color).toBe('white')
expect(metLines.find(l => l.text === '需要等级: 60')?.color).toBe('white')
expect(metLines.find(l => l.text === '需要力量: 118')?.color).toBe('white')
expect(metLines.find(l => l.text === '需要敏捷: 75')?.color).toBe('white')
})
it('Requirement 2 (Default Non-Barbarian): allows 1H weapon + shield OR 1 two-handed weapon, blocks dual-wielding and 2H + shield', () => {
const inv = new InventoryPanel()
delete inv.equipped.weapon1
delete inv.equipped.weapon2
inv.setPlayerContext({
level: 85,
str: 200,
dex: 150,
classCode: 'pal', // Paladin (non-Barbarian)
})
const oneHandSword1: UiInventoryItem = {
id: '1h-sword-1',
code: '7cr', // Phase Blade (1H)
name: 'Grief Phase Blade',
nameZh: '悔恨之幻化之刃',
baseNameZh: '幻化之刃',
quality: 'unique',
invFile: 'invpbl',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 59,
reqStr: 25,
reqDex: 136,
stats: [],
identified: true,
}
const oneHandSword2: UiInventoryItem = {
...oneHandSword1,
id: '1h-sword-2',
name: 'Azurewrath',
}
const shield: UiInventoryItem = {
id: 'pal-shield',
code: 'paa', // Herald of Zakarum (Paladin Shield)
name: 'Herald of Zakarum',
nameZh: '撒卡兰姆使者',
baseNameZh: '饰金盾牌',
quality: 'unique',
invFile: 'invpa2',
invWidth: 2,
invHeight: 4,
allowedSlots: ['weapon2'],
reqLevel: 42,
reqStr: 89,
reqClass: 'pal',
stats: [],
identified: true,
}
const twoHandedSword: UiInventoryItem = {
id: '2h-sword',
code: '7gd', // Colossus Blade (1or2handed=1)
name: 'The Grandfather',
nameZh: '祖父',
baseNameZh: '巨神之刃',
quality: 'unique',
invFile: 'invgsd',
invWidth: 2,
invHeight: 4,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 81,
reqStr: 189,
reqDex: 110,
stats: [],
identified: true,
}
const strictTwoHandedPolearm: UiInventoryItem = {
id: '2h-polearm',
code: '7pa', // Cryptic Axe (strict 2H)
name: 'Tomb Reaver',
nameZh: '盗墓者',
baseNameZh: '神秘之斧',
quality: 'unique',
invFile: 'invpax',
invWidth: 2,
invHeight: 4,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 84,
reqStr: 165,
reqDex: 103,
stats: [],
identified: true,
}
expect(classifyHandSlotItem(oneHandSword1)).toBe('one_handed_weapon')
expect(classifyHandSlotItem(shield)).toBe('offhand')
expect(classifyHandSlotItem(twoHandedSword)).toBe('barbarian_1or2h_weapon')
expect(classifyHandSlotItem(strictTwoHandedPolearm)).toBe('strict_two_handed_weapon')
// 1. Equip 1H weapon in weapon1 -> allowed
inv.cursorItem = oneHandSword1
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('1h-sword-1')
expect(inv.cursorItem).toBeNull()
// 2. Try to dual-wield second 1H weapon in weapon2 -> BLOCKED for non-Barbarian!
inv.cursorItem = oneHandSword2
expect(inv.canEquipItemInSlot(oneHandSword2, 'weapon2')).toBe(false)
expect(inv.clickEquipSlot('weapon2')).toBe(false)
expect(inv.equipped.weapon2).toBeUndefined()
expect(inv.cursorItem?.id).toBe('1h-sword-2')
// 3. Equip shield in weapon2 alongside 1H weapon in weapon1 -> ALLOWED!
inv.cursorItem = shield
expect(inv.canEquipItemInSlot(shield, 'weapon2')).toBe(true)
expect(inv.clickEquipSlot('weapon2')).toBe(true)
expect(inv.equipped.weapon2?.id).toBe('pal-shield')
expect(inv.cursorItem).toBeNull()
// 4. While shield is in weapon2, trying to put a 2H sword or 2H polearm into weapon1 -> BLOCKED!
inv.cursorItem = twoHandedSword
expect(inv.canEquipItemInSlot(twoHandedSword, 'weapon1')).toBe(false)
expect(inv.clickEquipSlot('weapon1')).toBe(false)
expect(inv.equipped.weapon1?.id).toBe('1h-sword-1')
inv.cursorItem = strictTwoHandedPolearm
expect(inv.canEquipItemInSlot(strictTwoHandedPolearm, 'weapon1')).toBe(false)
expect(inv.clickEquipSlot('weapon1')).toBe(false)
// 5. Unequip shield from weapon2; now weapon2 is empty, so 2H weapon CAN be swapped into weapon1!
inv.cursorItem = null
expect(inv.clickEquipSlot('weapon2')).toBe(true)
expect(inv.equipped.weapon2).toBeUndefined()
inv.cursorItem = strictTwoHandedPolearm
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('2h-polearm')
expect(inv.cursorItem?.id).toBe('1h-sword-1')
// 6. While 2H polearm is in weapon1, trying to equip shield or 1H weapon in weapon2 -> BLOCKED!
inv.cursorItem = shield
expect(inv.canEquipItemInSlot(shield, 'weapon2')).toBe(false)
expect(inv.clickEquipSlot('weapon2')).toBe(false)
expect(inv.equipped.weapon2).toBeUndefined()
})
it('Requirement 2 (Barbarian 1.13c): allows dual-wielding 1H weapons and 1or2handed two-handed swords, and 2H sword + shield, while blocking strict 2H dual-wielding', () => {
const inv = new InventoryPanel()
delete inv.equipped.weapon1
delete inv.equipped.weapon2
inv.setPlayerContext({
level: 85,
str: 235,
dex: 150,
classCode: 'bar',
})
const phaseBlade: UiInventoryItem = {
id: 'bar-1h-pb',
code: '7cr', // 1H sword
name: 'Phase Blade',
nameZh: '幻化之刃',
baseNameZh: '幻化之刃',
quality: 'normal',
invFile: 'invpbl',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 54,
reqStr: 25,
reqDex: 136,
stats: [],
identified: true,
}
const colossusBlade1: UiInventoryItem = {
id: 'bar-2h-cb1',
code: '7gd', // Colossus Blade (`1or2handed = 1`)
name: 'Colossus Blade I',
nameZh: '巨神之刃 I',
baseNameZh: '巨神之刃',
quality: 'unique',
invFile: 'invgsd',
invWidth: 2,
invHeight: 4,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 81,
reqStr: 189,
reqDex: 110,
stats: [],
identified: true,
}
const colossusBlade2: UiInventoryItem = {
...colossusBlade1,
id: 'bar-2h-cb2',
name: 'Colossus Blade II',
}
const stormshield: UiInventoryItem = {
id: 'bar-shield',
code: 'uit',
name: 'Stormshield',
nameZh: '暴风之盾',
baseNameZh: '统治者大盾',
quality: 'unique',
invFile: 'invkts',
invWidth: 2,
invHeight: 3,
allowedSlots: ['weapon2'],
reqLevel: 73,
reqStr: 156,
stats: [],
identified: true,
}
const ikMaul: UiInventoryItem = {
id: 'bar-ik-maul',
code: '7m7', // Ogre Maul (`2handed = 1, 1or2handed = 0`)
name: "Immortal King's Stone Crusher",
nameZh: '不朽之王的碎魂者',
baseNameZh: '食人魔之槌',
quality: 'set',
invFile: 'invgom',
invWidth: 2,
invHeight: 4,
allowedSlots: ['weapon1', 'weapon2'],
reqLevel: 76,
reqStr: 225,
stats: [],
identified: true,
}
// 1. Barbarian can dual-wield 1H weapon + 2H sword (`1or2handed=1`)
inv.cursorItem = phaseBlade
expect(inv.clickEquipSlot('weapon1')).toBe(true)
inv.cursorItem = colossusBlade1
expect(inv.canEquipItemInSlot(colossusBlade1, 'weapon2')).toBe(true)
expect(inv.clickEquipSlot('weapon2')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('bar-1h-pb')
expect(inv.equipped.weapon2?.id).toBe('bar-2h-cb1')
// 2. Barbarian can dual-wield two 2H swords (`Colossus Blade I` + `Colossus Blade II`)
inv.cursorItem = colossusBlade2
expect(inv.canEquipItemInSlot(colossusBlade2, 'weapon1')).toBe(true)
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('bar-2h-cb2')
expect(inv.equipped.weapon2?.id).toBe('bar-2h-cb1')
// 3. Barbarian can wield 2H sword in weapon1 + Stormshield in weapon2
inv.cursorItem = stormshield
expect(inv.canEquipItemInSlot(stormshield, 'weapon2')).toBe(true)
expect(inv.clickEquipSlot('weapon2')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('bar-2h-cb2')
expect(inv.equipped.weapon2?.id).toBe('bar-shield')
// 4. Barbarian CANNOT equip strict 2H Ogre Maul while Stormshield is in weapon2
inv.cursorItem = ikMaul
expect(inv.canEquipItemInSlot(ikMaul, 'weapon1')).toBe(false)
expect(inv.clickEquipSlot('weapon1')).toBe(false)
// 5. After removing Stormshield from weapon2, Barbarian CAN equip strict 2H Ogre Maul in weapon1,
// and while Ogre Maul is equipped, Barbarian CANNOT equip a 1H sword or 2H sword or shield in weapon2!
inv.cursorItem = null
expect(inv.clickEquipSlot('weapon2')).toBe(true) // unequip shield
inv.cursorItem = ikMaul
expect(inv.clickEquipSlot('weapon1')).toBe(true)
expect(inv.equipped.weapon1?.id).toBe('bar-ik-maul')
inv.cursorItem = phaseBlade
expect(inv.canEquipItemInSlot(phaseBlade, 'weapon2')).toBe(false)
expect(inv.clickEquipSlot('weapon2')).toBe(false)
inv.cursorItem = colossusBlade1
expect(inv.canEquipItemInSlot(colossusBlade1, 'weapon2')).toBe(false)
inv.cursorItem = stormshield
expect(inv.canEquipItemInSlot(stormshield, 'weapon2')).toBe(false)
})
it('verifies all 7 starter classes meet requirements for their starter equipped sets', () => {
const classes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
for (const classCode of classes) {
const attrs = CLASS_STARTER_ATTRS[classCode]
const profile = createStarterProfileForClass(classCode)
const ctx = {
level: attrs.level,
str: attrs.str,
dex: attrs.dex,
classCode,
}
for (const [slotId, item] of Object.entries(profile.equipped)) {
if (!item) continue
expect(
canPlayerUseUiItem(item, ctx),
`Starter ${classCode} item ${item.id} in slot ${slotId} should meet requirements`,
).toBe(true)
}
}
})
it('populates twoHanded and oneOrTwoHanded via itemToUiInventoryItem', () => {
const tables = getEmbeddedDropTables()
const cbBase = tables.weapons.get('7gd')!
const uiCb = itemToUiInventoryItem(
{
id: '9001',
name: 'Colossus Blade',
base: cbBase,
quality: 2,
level: 85,
identified: true,
ethereal: false,
socketed: false,
sockets: 0,
stats: {},
prefix: 0,
suffix: 0,
location: 0,
x: 0,
y: 0,
},
tables,
)
expect(uiCb.twoHanded).toBe(true)
expect(uiCb.oneOrTwoHanded).toBe(true)
expect(classifyHandSlotItem(uiCb)).toBe('barbarian_1or2h_weapon')
})
})