From 4e461e1fdb933cb77dbca4a4db9fabe0a67093e9 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 12:13:26 +0000 Subject: [PATCH] feat(ui,items): implement 1.13c character equipment restrictions and red requirement display (#506) --- src/game/class-starter-profiles.ts | 7 +- src/game/items.ts | 4 + src/game/weapons.ts | 4 + src/scene/act-scene.ts | 2 +- src/ui/hud-manager.ts | 23 +- src/ui/inventory.ts | 246 ++++++- src/ui/item-bridge.ts | 128 ++++ src/ui/world-panels.ts | 56 +- .../issue-506-equipment-restrictions.test.ts | 631 ++++++++++++++++++ 9 files changed, 1059 insertions(+), 42 deletions(-) create mode 100644 tests/issue-506-equipment-restrictions.test.ts diff --git a/src/game/class-starter-profiles.ts b/src/game/class-starter-profiles.ts index 5200827..2475131 100644 --- a/src/game/class-starter-profiles.ts +++ b/src/game/class-starter-profiles.ts @@ -313,6 +313,7 @@ export const CLASS_EXCLUSIVE_SETS: Record> | 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. */ diff --git a/src/game/weapons.ts b/src/game/weapons.ts index 6dc9338..f35a3a5 100644 --- a/src/game/weapons.ts +++ b/src/game/weapons.ts @@ -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, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 84b909b..5044c83 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -1383,7 +1383,7 @@ export const HERO_CLASSES: Record = { 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: { diff --git a/src/ui/hud-manager.ts b/src/ui/hud-manager.ts index 9583218..060fe23 100644 --- a/src/ui/hud-manager.ts +++ b/src/ui/hud-manager.ts @@ -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 diff --git a/src/ui/inventory.ts b/src/ui/inventory.ts index 22814fb..1021a40 100644 --- a/src/ui/inventory.ts +++ b/src/ui/inventory.ts @@ -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 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): 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) } diff --git a/src/ui/item-bridge.ts b/src/ui/item-bridge.ts index 420cfe0..6bd8e6e 100644 --- a/src/ui/item-bridge.ts +++ b/src/ui/item-bridge.ts @@ -29,6 +29,113 @@ import { CHARACTER_CLASS_CODES, type CharacterClassCode } from '../game/classes. const VALID_CLASS_CODES = new Set(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 = 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 = 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 = 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 = 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> = { + 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) diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 7dc2c3c..2a3f6f7 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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): 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 = { 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', }) } diff --git a/tests/issue-506-equipment-restrictions.test.ts b/tests/issue-506-equipment-restrictions.test.ts new file mode 100644 index 0000000..5577e01 --- /dev/null +++ b/tests/issue-506-equipment-restrictions.test.ts @@ -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') + }) +})