diablo2-web/tests/client/ui-offline-batch7.test.ts

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,
})
})
})
})