feat(ui,items): implement 1.13c character equipment restrictions and red requirement display (#506)
This commit is contained in:
parent
d4cd19d96c
commit
4e461e1fdb
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue