680 lines
23 KiB
TypeScript
680 lines
23 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
|
import {
|
|
clearMissingArtEntries,
|
|
getMissingArtEntries,
|
|
} from '../../src/client/scene/world-renderer.ts'
|
|
import { CharacterSheetPanel } from '../../src/client/ui/character-sheet.ts'
|
|
import { D2FontRenderer } from '../../src/client/ui/font.ts'
|
|
import {
|
|
createCanonicalGenericSkillEntry,
|
|
LEFT_SKILL_BOUNDS,
|
|
resolveSkillIcon,
|
|
RIGHT_SKILL_BOUNDS,
|
|
SkillHotkeysHud,
|
|
} from '../../src/client/ui/hotkeys.ts'
|
|
import type { HudManager } from '../../src/client/ui/hud-manager.ts'
|
|
import {
|
|
ASSASSIN_HAND_TO_HAND_CLAW_CODES,
|
|
applySocketToUiItem,
|
|
classifyHandSlotItem,
|
|
InventoryPanel,
|
|
isAssassinClawItem,
|
|
resolveAllowedSlots,
|
|
type UiInventoryItem,
|
|
} from '../../src/client/ui/inventory.ts'
|
|
import {
|
|
CLASS_VERTICAL_TABS,
|
|
resolveSkillTreeTbl,
|
|
SkillTreePanel,
|
|
} from '../../src/client/ui/skill-tree-panel.ts'
|
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
|
import { resolveItemInvFile } from '../../src/common/items/item-bridge.ts'
|
|
|
|
function createRecordingCtx() {
|
|
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
|
|
const strokeRectCalls: Array<{ x: number; y: number; w: number; h: number; strokeStyle: string }> = []
|
|
let currentFillStyle = '#000000'
|
|
let currentStrokeStyle = '#000000'
|
|
|
|
const ctx = {
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
drawImage: vi.fn(),
|
|
fillRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
|
fillRectCalls.push({ x, y, w, h, fillStyle: String(currentFillStyle) })
|
|
}),
|
|
strokeRect: vi.fn((x: number, y: number, w: number, h: number) => {
|
|
strokeRectCalls.push({ x, y, w, h, strokeStyle: String(currentStrokeStyle) })
|
|
}),
|
|
beginPath: vi.fn(),
|
|
closePath: vi.fn(),
|
|
arc: vi.fn(),
|
|
fill: vi.fn(),
|
|
stroke: vi.fn(),
|
|
clip: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
fillText: vi.fn(),
|
|
measureText: vi.fn((t: string) => ({ width: t.length * 8 })),
|
|
get fillStyle() {
|
|
return currentFillStyle
|
|
},
|
|
set fillStyle(v: string) {
|
|
currentFillStyle = v
|
|
},
|
|
get strokeStyle() {
|
|
return currentStrokeStyle
|
|
},
|
|
set strokeStyle(v: string) {
|
|
currentStrokeStyle = v
|
|
},
|
|
lineWidth: 1,
|
|
font: '',
|
|
textAlign: 'left',
|
|
textBaseline: 'alphabetic',
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
return { ctx, fillRectCalls, strokeRectCalls }
|
|
}
|
|
|
|
describe('Batch 7 Phase 1: UI Overlays, Missing-Art Fail-Fast, Offline Equip Rules, Skill Tree TBL & Integer Formatting (#690-#695)', () => {
|
|
beforeEach(() => {
|
|
clearMissingArtEntries()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('Issue #690: Remove Non-Canonical Skill Hotkey Overlays & Use Authentic Insufficient-Mana Tint', () => {
|
|
it('does not draw invented yellow aura borders/tints or red cooldown/charge overlays, and tints blue only when mana is insufficient', () => {
|
|
const hud = new SkillHotkeysHud()
|
|
const font = new D2FontRenderer()
|
|
const { ctx, fillRectCalls, strokeRectCalls } = createRecordingCtx()
|
|
const mockAtlas = {} as unknown as HTMLImageElement
|
|
|
|
// 1. Aura skill (Might = 98) on right slot: no #ffd700 border or yellow tint
|
|
hud.leftSkillId = 47 // Fire Ball (manaCost = 5)
|
|
hud.rightSkillId = 98 // Might (Aura, manaCost = 0)
|
|
hud.currentMana = 50
|
|
hud.draw(ctx, mockAtlas, font, true, 1000)
|
|
|
|
expect(strokeRectCalls.some((c) => c.strokeStyle.toLowerCase() === '#ffd700')).toBe(false)
|
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(255, 215, 0'))).toBe(false)
|
|
|
|
// 2. Cooldown & 0 charges on Meteor (56, manaCost = 17) while mana is sufficient (50 >= 17): no red overlay
|
|
fillRectCalls.length = 0
|
|
hud.rightSkillId = 56
|
|
hud.setSkillCooldown(56, 1500, 1000)
|
|
hud.setSkillCharges(56, 0, 10)
|
|
hud.draw(ctx, mockAtlas, font, true, 1200)
|
|
|
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(220, 20, 20'))).toBe(false)
|
|
expect(fillRectCalls.some((c) => c.fillStyle.includes('rgba(24, 60, 180'))).toBe(false)
|
|
|
|
// 3. Insufficient mana (currentMana = 10 < Meteor manaCost 17, but >= Fire Ball manaCost 5)
|
|
fillRectCalls.length = 0
|
|
hud.currentMana = 10
|
|
hud.draw(ctx, mockAtlas, font, true, 1200)
|
|
|
|
const rightManaOverlay = fillRectCalls.find(
|
|
(c) => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.fillStyle === 'rgba(24, 60, 180, 0.42)',
|
|
)
|
|
const leftManaOverlay = fillRectCalls.find(
|
|
(c) => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.fillStyle === 'rgba(24, 60, 180, 0.42)',
|
|
)
|
|
expect(rightManaOverlay).toBeDefined()
|
|
expect(leftManaOverlay).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Issue #691: Fail-Fast via reportMissingArt Instead of Colored Placeholder Rectangles', () => {
|
|
it('reports missing skill icon art via reportMissingArt without drawing #5c2818 or #182c5c boxes', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const hud = new SkillHotkeysHud()
|
|
const font = new D2FontRenderer()
|
|
const { ctx, fillRectCalls } = createRecordingCtx()
|
|
|
|
hud.leftSkillId = 36 // Fire Bolt
|
|
hud.rightSkillId = 54 // Teleport
|
|
hud.draw(ctx, null, font, true, 1000)
|
|
|
|
expect(
|
|
fillRectCalls.some(
|
|
(c) => c.fillStyle.toLowerCase() === '#5c2818' || c.fillStyle.toLowerCase() === '#182c5c',
|
|
),
|
|
).toBe(false)
|
|
const entries = getMissingArtEntries()
|
|
expect(entries.some((e) => e.category === 'skill-icon' && e.id === 'icon_36')).toBe(true)
|
|
expect(entries.some((e) => e.category === 'skill-icon' && e.id === 'icon_54')).toBe(true)
|
|
})
|
|
|
|
it('reports missing inventory, character sheet, and skill tree panel art without drawing #181410 or #a81c1c placeholders', () => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const font = new D2FontRenderer()
|
|
const { ctx, fillRectCalls } = createRecordingCtx()
|
|
|
|
// 1. InventoryPanel with missing invSheetImg
|
|
const inv = new InventoryPanel()
|
|
inv.visible = true
|
|
inv.draw(
|
|
ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: null,
|
|
invTab0Img: null,
|
|
invTab1Img: null,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: null,
|
|
itemRects: {},
|
|
},
|
|
font,
|
|
)
|
|
|
|
// 2. CharacterSheetPanel with missing charSheetImg and levelBtnImg (with statPoints > 0)
|
|
const charSheet = new CharacterSheetPanel()
|
|
charSheet.visible = true
|
|
charSheet.attrs.statPoints = 5
|
|
charSheet.draw(
|
|
ctx,
|
|
{
|
|
hp: 100,
|
|
maxHp: 100,
|
|
mana: 50,
|
|
maxMana: 50,
|
|
stamina: 100,
|
|
maxStamina: 100,
|
|
leftSkillDmg: '1-2',
|
|
rightSkillDmg: '1-2',
|
|
},
|
|
{
|
|
borderLeftImg: null,
|
|
charSheetImg: null,
|
|
buySellBtnImg: null,
|
|
levelBtnImg: null,
|
|
},
|
|
font,
|
|
)
|
|
|
|
// 3. SkillTreePanel with missing skillTreeBgImg, skillTabsImg, skillPointsImg, skillIconAtlas
|
|
const skillTree = new SkillTreePanel()
|
|
skillTree.characterClass = 'sor'
|
|
skillTree.visible = true
|
|
skillTree.draw(
|
|
ctx,
|
|
{
|
|
borderRightImg: null,
|
|
skillPointsImg: null,
|
|
buySellBtnImg: null,
|
|
skillTabsImg: null,
|
|
},
|
|
font,
|
|
)
|
|
|
|
const forbiddenColors = new Set(['#181410', '#a81c1c', '#14110d', '#080605', '#090705', '#1b1610'])
|
|
expect(fillRectCalls.some((c) => forbiddenColors.has(c.fillStyle.toLowerCase()))).toBe(false)
|
|
|
|
const entries = getMissingArtEntries()
|
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id === 'invchar6-inv')).toBe(true)
|
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id === 'invchar6-char')).toBe(true)
|
|
expect(entries.some((e) => e.category === 'ui-button' && e.id === 'level-btn')).toBe(true)
|
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id.startsWith('skltree_bg_'))).toBe(true)
|
|
expect(entries.some((e) => e.category === 'ui-panel' && e.id.startsWith('skltree_tabs_'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Issue #692 & #693: Fail-Fast Item Bridge, Assassin Dual Claws, Shield/1or2handed Slots, and Non-Destructive cursorItem', () => {
|
|
it('resolves shields, quivers, and 1or2handed swords to both weapon1 and weapon2 slots', () => {
|
|
expect(
|
|
resolveAllowedSlots({
|
|
id: 'uit',
|
|
kind: 'armor',
|
|
type: 'shie',
|
|
}),
|
|
).toEqual(['weapon1', 'weapon2'])
|
|
|
|
expect(
|
|
resolveAllowedSlots({
|
|
id: 'aqv',
|
|
kind: 'misc',
|
|
type: 'bowq',
|
|
}),
|
|
).toEqual(['weapon1', 'weapon2'])
|
|
|
|
expect(
|
|
resolveAllowedSlots({
|
|
id: '7gd',
|
|
kind: 'weapon',
|
|
type: 'swor',
|
|
}),
|
|
).toEqual(['weapon1', 'weapon2'])
|
|
})
|
|
|
|
it('allows Assassin (ass) to dual-wield two Hand-to-Hand claws while blocking non-claw dual-wielding', () => {
|
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('ktr')).toBe(true)
|
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('9tw')).toBe(true)
|
|
expect(ASSASSIN_HAND_TO_HAND_CLAW_CODES.has('7qr')).toBe(true)
|
|
|
|
const inv = new InventoryPanel()
|
|
inv.setPlayerContext({
|
|
level: 80,
|
|
str: 150,
|
|
dex: 150,
|
|
classCode: 'ass',
|
|
})
|
|
|
|
const claw1: UiInventoryItem = {
|
|
id: 'claw-1',
|
|
code: '9tw', // Greater Talons
|
|
name: "Bartuc's Cut-Throat",
|
|
nameZh: '巴特克的猛击',
|
|
baseNameZh: '巨鹰爪',
|
|
quality: 'unique',
|
|
invFile: 'invskl',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
allowedSlots: ['weapon1', 'weapon2'],
|
|
reqLevel: 42,
|
|
reqStr: 79,
|
|
reqDex: 79,
|
|
reqClass: 'ass',
|
|
stats: [],
|
|
identified: true,
|
|
}
|
|
const claw2: UiInventoryItem = {
|
|
...claw1,
|
|
id: 'claw-2',
|
|
code: '7qr', // Suwayyah
|
|
name: 'Jade Talon',
|
|
}
|
|
const phaseBlade: UiInventoryItem = {
|
|
id: 'pb-1',
|
|
code: '7cr',
|
|
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,
|
|
}
|
|
|
|
expect(isAssassinClawItem(claw1)).toBe(true)
|
|
expect(isAssassinClawItem(claw2)).toBe(true)
|
|
expect(isAssassinClawItem(phaseBlade)).toBe(false)
|
|
|
|
// Equip claw1 in weapon1, then claw2 in weapon2 -> ALLOWED for Assassin
|
|
inv.cursorItem = claw1
|
|
expect(inv.clickEquipSlot('weapon1')).toBe(true)
|
|
inv.cursorItem = claw2
|
|
expect(inv.canEquipItemInSlot(claw2, 'weapon2')).toBe(true)
|
|
expect(inv.clickEquipSlot('weapon2')).toBe(true)
|
|
expect(inv.equipped.weapon1?.id).toBe('claw-1')
|
|
expect(inv.equipped.weapon2?.id).toBe('claw-2')
|
|
|
|
// Assassin cannot dual-wield a claw + a non-claw 1H sword
|
|
inv.cursorItem = phaseBlade
|
|
expect(inv.canEquipItemInSlot(phaseBlade, 'weapon2')).toBe(false)
|
|
})
|
|
|
|
it('does not classify 1H items as 2H from tooltip damage strings and never wipes starter inventory when setting cursorItem', () => {
|
|
const fakeTooltipOneHander: UiInventoryItem = {
|
|
id: 'fake-tooltip-1h',
|
|
code: 'crs', // Crystal Sword (1H)
|
|
name: 'Crystal Sword',
|
|
nameZh: '水晶剑',
|
|
baseNameZh: '水晶剑',
|
|
quality: 'normal',
|
|
invFile: 'invcrs',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
allowedSlots: ['weapon1', 'weapon2'],
|
|
damage: '双手伤害: 10 - 20',
|
|
damageEn: 'Two-Hand Damage: 10 to 20',
|
|
stats: [],
|
|
identified: true,
|
|
}
|
|
expect(classifyHandSlotItem(fakeTooltipOneHander)).toBe('one_handed_weapon')
|
|
|
|
const inv = new InventoryPanel()
|
|
const ringItem: UiInventoryItem = {
|
|
id: 'starter-ring',
|
|
code: 'rin',
|
|
name: 'Ring',
|
|
nameZh: '戒指',
|
|
baseNameZh: '戒指',
|
|
quality: 'magic',
|
|
invFile: 'invrin',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: ['ring1', 'ring2'],
|
|
stats: [],
|
|
identified: true,
|
|
}
|
|
inv.gridItems = [{ item: ringItem, col: 0, row: 0 }]
|
|
inv.equipped.ring1 = ringItem
|
|
|
|
// Setting a raw item without allowedSlots onto cursorItem must NOT clear gridItems or equipped
|
|
inv.cursorItem = { id: 'raw-cursor', code: 'cap', name: 'Cap' } as unknown as UiInventoryItem
|
|
expect(inv.gridItems).toHaveLength(1)
|
|
expect(inv.equipped.ring1).toBe(ringItem)
|
|
})
|
|
|
|
it('applies runeword socketing cleanly without throwing on unknown candidate itemType codes (#692)', () => {
|
|
const hostShield: UiInventoryItem = {
|
|
id: 'host-monarch',
|
|
code: 'uit',
|
|
name: 'Monarch',
|
|
nameZh: '统治者大盾',
|
|
baseNameZh: '统治者大盾',
|
|
baseNameEn: 'Monarch',
|
|
quality: 'normal',
|
|
invFile: 'invkts',
|
|
invWidth: 2,
|
|
invHeight: 3,
|
|
allowedSlots: ['weapon1', 'weapon2'],
|
|
sockets: 4,
|
|
socketedItems: [],
|
|
stats: [{ text: '有凹槽 (4)', color: 'blue' }],
|
|
identified: true,
|
|
}
|
|
const makeRune = (code: string, name: string): UiInventoryItem => ({
|
|
id: `rune-${code}`,
|
|
code,
|
|
name,
|
|
nameZh: name,
|
|
baseNameZh: name,
|
|
quality: 'rune',
|
|
invFile: `inv${code}`,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
allowedSlots: [],
|
|
stats: [],
|
|
identified: true,
|
|
})
|
|
|
|
applySocketToUiItem(hostShield, makeRune('r07', 'Tal Rune'))
|
|
applySocketToUiItem(hostShield, makeRune('r10', 'Thul Rune'))
|
|
applySocketToUiItem(hostShield, makeRune('r09', 'Ort Rune'))
|
|
applySocketToUiItem(hostShield, makeRune('r11', 'Amn Rune'))
|
|
|
|
expect(hostShield.quality).toBe('rune')
|
|
expect(hostShield.name).toBe('Spirit')
|
|
})
|
|
})
|
|
|
|
describe('Issue #694: Skill Tree Panel TBL String Keys StrSklTree1-StrSklTree34', () => {
|
|
it('resolves all 21 class tab titles and 3-line remaining skill points header from TBL keys in ENG and CHI', () => {
|
|
expect(resolveSkillTreeTbl('StrSklTree1', 'ENG')).toBe('Skill')
|
|
expect(resolveSkillTreeTbl('StrSklTree2', 'ENG')).toBe('Choices')
|
|
expect(resolveSkillTreeTbl('StrSklTree3', 'ENG')).toBe('Remaining')
|
|
|
|
expect(resolveSkillTreeTbl('StrSklTree1', 'CHI')).toBe('技能')
|
|
expect(resolveSkillTreeTbl('StrSklTree2', 'CHI')).toBe('選擇')
|
|
expect(resolveSkillTreeTbl('StrSklTree3', 'CHI')).toBe('剩餘')
|
|
|
|
// Verify 1-line tabs have empty subEn/subZh
|
|
expect(CLASS_VERTICAL_TABS.nec[2].titleKey).toBe('StrSklTree19')
|
|
expect(CLASS_VERTICAL_TABS.nec[2].subEn).toBe('')
|
|
expect(CLASS_VERTICAL_TABS.bar[0].titleKey).toBe('StrSklTree20')
|
|
expect(CLASS_VERTICAL_TABS.bar[0].subEn).toBe('')
|
|
expect(CLASS_VERTICAL_TABS.ass[2].titleKey).toBe('StrSklTree30')
|
|
expect(CLASS_VERTICAL_TABS.ass[2].subEn).toBe('')
|
|
|
|
// Verify Sorceress & Expansion Druid/Assassin tab titles match canonical TBL
|
|
expect(CLASS_VERTICAL_TABS.sor[0].titleEn).toBe('Cold')
|
|
expect(CLASS_VERTICAL_TABS.sor[0].subEn).toBe('Spells')
|
|
expect(CLASS_VERTICAL_TABS.dru[0].titleEn).toBe('Elemental')
|
|
expect(CLASS_VERTICAL_TABS.dru[0].subEn).toBe('Skills')
|
|
expect(CLASS_VERTICAL_TABS.ass[0].titleEn).toBe('Martial')
|
|
expect(CLASS_VERTICAL_TABS.ass[0].subEn).toBe('Arts')
|
|
})
|
|
})
|
|
|
|
describe('Issue #695: Unformatted Raw Integer Strings for Gold and Experience', () => {
|
|
it('renders CharacterSheetPanel experience and InventoryPanel gold without locale thousands separators', () => {
|
|
const { ctx } = createRecordingCtx()
|
|
const drawnTexts: string[] = []
|
|
const font = new D2FontRenderer()
|
|
vi.spyOn(font, 'drawText').mockImplementation((_c, text) => {
|
|
drawnTexts.push(String(text))
|
|
return 20
|
|
})
|
|
|
|
const charSheet = new CharacterSheetPanel()
|
|
charSheet.visible = true
|
|
charSheet.attrs.xp = 1234567
|
|
charSheet.attrs.nextLevelXp = 5000000
|
|
charSheet.draw(
|
|
ctx,
|
|
{
|
|
hp: 100,
|
|
maxHp: 100,
|
|
mana: 50,
|
|
maxMana: 50,
|
|
stamina: 100,
|
|
maxStamina: 100,
|
|
leftSkillDmg: '1-2',
|
|
rightSkillDmg: '1-2',
|
|
},
|
|
{
|
|
borderLeftImg: null,
|
|
charSheetImg: {} as HTMLImageElement,
|
|
buySellBtnImg: null,
|
|
levelBtnImg: {} as HTMLImageElement,
|
|
},
|
|
font,
|
|
)
|
|
|
|
expect(drawnTexts).toContain('1234567')
|
|
expect(drawnTexts).toContain('5000000')
|
|
expect(drawnTexts).not.toContain('1,234,567')
|
|
expect(drawnTexts).not.toContain('5,000,000')
|
|
|
|
drawnTexts.length = 0
|
|
const inv = new InventoryPanel()
|
|
inv.visible = true
|
|
inv.gold = 2500000
|
|
inv.draw(
|
|
ctx,
|
|
{
|
|
borderRightImg: null,
|
|
invSheetImg: {} as HTMLImageElement,
|
|
invTab0Img: null,
|
|
invTab1Img: null,
|
|
buySellBtnImg: null,
|
|
itemsAtlasImg: null,
|
|
itemRects: {},
|
|
},
|
|
font,
|
|
)
|
|
|
|
expect(drawnTexts).toContain('2500000')
|
|
expect(drawnTexts).not.toContain('2,500,000')
|
|
})
|
|
})
|
|
|
|
describe('M6 Gate Challenger 2 Remediation (#679, #681, #686, #688)', () => {
|
|
it('restricts createCanonicalGenericSkillEntry to generic skills (#0..#5, #217..#220) and omits charges on #0..#5', () => {
|
|
expect(createCanonicalGenericSkillEntry(36)).toBeNull()
|
|
expect(createCanonicalGenericSkillEntry(126)).toBeNull()
|
|
expect(createCanonicalGenericSkillEntry(251)).toBeNull()
|
|
|
|
const attack = createCanonicalGenericSkillEntry(0)
|
|
expect(attack?.charges).toBeUndefined()
|
|
const throwSkill = createCanonicalGenericSkillEntry(2)
|
|
expect(throwSkill?.charges).toBeUndefined()
|
|
|
|
const scrollTp = createCanonicalGenericSkillEntry(219, 12)
|
|
expect(scrollTp?.charges).toBe(12)
|
|
const tomeTp = createCanonicalGenericSkillEntry(220, 1, 18)
|
|
expect(tomeTp?.charges).toBe(18)
|
|
})
|
|
|
|
it('matches MPQ skilldesc.txt IconCel for Paladin, Druid, and Assassin skills (#679, #688)', () => {
|
|
expect(resolveSkillIcon(125).frameIndex).toBe(58) // Salvation
|
|
expect(resolveSkillIcon(221).frameIndex).toBe(0) // Raven
|
|
expect(resolveSkillIcon(240).frameIndex).toBe(42) // Twister
|
|
expect(resolveSkillIcon(241).frameIndex).toBe(14) // Solar Creeper
|
|
expect(resolveSkillIcon(251).frameIndex).toBe(0) // Fire Blast
|
|
expect(resolveSkillIcon(280).frameIndex).toBe(56) // Royal Strike
|
|
})
|
|
|
|
it('resolves Annihilus and Hellfire Torch invFile from uniqueItemDef without hardcoded name checks (#681)', () => {
|
|
expect(
|
|
resolveItemInvFile(
|
|
{
|
|
id: 1,
|
|
code: 'cm1',
|
|
name: 'Annihilus',
|
|
quality: 7,
|
|
level: 110,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
identified: true,
|
|
uniqueItemDef: { invfile: 'invmss' } as any,
|
|
base: { id: 'cm1', name: 'Small Charm', kind: 'misc', type: 'scha', invWidth: 1, invHeight: 1, maxStack: 1, value: 1, damage: 0, defense: 0, tags: ['scha'], level: 1 },
|
|
rolledProps: [],
|
|
stats: {},
|
|
} as any,
|
|
'unique',
|
|
'cm1',
|
|
).invFile,
|
|
).toBe('invmss')
|
|
|
|
expect(
|
|
resolveItemInvFile(
|
|
{
|
|
id: 2,
|
|
code: 'cm2',
|
|
name: 'Hellfire Torch',
|
|
quality: 7,
|
|
level: 110,
|
|
invWidth: 1,
|
|
invHeight: 2,
|
|
identified: true,
|
|
uniqueItemDef: { invfile: 'invtrch' } as any,
|
|
base: { id: 'cm2', name: 'Large Charm', kind: 'misc', type: 'mcha', invWidth: 1, invHeight: 2, maxStack: 1, value: 1, damage: 0, defense: 0, tags: ['mcha'], level: 1 },
|
|
rolledProps: [],
|
|
stats: {},
|
|
} as any,
|
|
'unique',
|
|
'cm2',
|
|
).invFile,
|
|
).toBe('invtrch')
|
|
})
|
|
})
|
|
|
|
describe('Deferred Ground Item Pickup toCursor Parity (command-mapper.ts)', () => {
|
|
it('sets toCursor = true when inventory panel is open upon reaching pending ground item', () => {
|
|
const world = new ClientWorld()
|
|
world.applyEvent({
|
|
type: 'LifeMana',
|
|
life: 100,
|
|
mana: 100,
|
|
stamina: 100,
|
|
at: { x: 100, y: 100 },
|
|
vx: 0,
|
|
vy: 0,
|
|
})
|
|
world.applyEvent({
|
|
type: 'ItemEvent',
|
|
action: 0x00,
|
|
container: 'ground',
|
|
item: {
|
|
id: 42,
|
|
itemId: 42,
|
|
opcode: 0x9c,
|
|
action: 0x00,
|
|
category: 0,
|
|
flags: 0,
|
|
rawFlags: 0,
|
|
version: 101,
|
|
location: 3,
|
|
container: 'ground',
|
|
bodyLoc: 0,
|
|
equippedSlot: 0,
|
|
gridX: 0,
|
|
gridY: 0,
|
|
storePage: 0,
|
|
storagePage: 0,
|
|
worldX: 110,
|
|
worldY: 100,
|
|
x: 110,
|
|
y: 100,
|
|
code: 'rin',
|
|
rawCode: 'rin ',
|
|
name: 'Gold Ring',
|
|
compact: true,
|
|
simple: true,
|
|
identified: true,
|
|
socketed: false,
|
|
ethereal: false,
|
|
runeword: false,
|
|
personalized: false,
|
|
isEar: false,
|
|
newItem: false,
|
|
starterItem: false,
|
|
broken: false,
|
|
inSocket: false,
|
|
equipped: false,
|
|
socketedChildCount: 0,
|
|
usedSockets: 0,
|
|
totalSockets: 0,
|
|
quality: 4,
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
stats: [],
|
|
setBonusStats: [],
|
|
runewordStats: [],
|
|
} as any,
|
|
})
|
|
|
|
const camera = new ViewportCamera(800, 600)
|
|
camera.snapTo(100, 100)
|
|
const hudStub = {
|
|
screenWidth: 800,
|
|
screenHeight: 600,
|
|
rightPanel: 'inv',
|
|
isRunning: false,
|
|
hotkeys: { leftSkillId: 0, rightSkillId: 0 },
|
|
handleMouseMove: () => {},
|
|
isPointInterceptedByHud: () => false,
|
|
} as unknown as HudManager
|
|
const mapper = new CommandMapper({
|
|
world,
|
|
camera,
|
|
hudManager: hudStub,
|
|
sendCommand: () => {},
|
|
})
|
|
|
|
// Click distant item (dist = 10 > 4) -> sets pendingItemPickupId = 42
|
|
const itemScreen = camera.subtileToScreen(110, 100)
|
|
const walkRecord = mapper.handlePointerDown(itemScreen.x, itemScreen.y, 0, 1000)
|
|
mapper.handlePointerUp(0)
|
|
expect(walkRecord?.command.type).toBe('ItemPickup')
|
|
|
|
// Move player within reach (dist = 2 <= 4) while rightPanel === 'inv'
|
|
world.applyEvent({
|
|
type: 'LifeMana',
|
|
life: 100,
|
|
mana: 100,
|
|
stamina: 100,
|
|
at: { x: 108, y: 100 },
|
|
vx: 0,
|
|
vy: 0,
|
|
})
|
|
const pickupCmd = mapper.tick(2000)
|
|
expect(pickupCmd).toEqual({
|
|
type: 'ItemPickup',
|
|
itemId: 42,
|
|
toCursor: true,
|
|
})
|
|
})
|
|
})
|
|
})
|