632 lines
21 KiB
TypeScript
632 lines
21 KiB
TypeScript
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')
|
|
})
|
|
})
|