diablo2-web/tests/client/skills-charsheet-batch6.tes...

494 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
GENERIC_SKILL_TBL_STRINGS,
SKILLS_TABLE_META,
SKILL_TBL_STRINGS,
canUseSkillWithWeapon,
getCanonicalSkillIconCel,
getCanonicalSkillListRow,
getSkillMaxLevel,
isAuraSkill,
isLeftUsableSkill,
isPassiveSkill,
} from '../../src/common/skills/skills-meta.ts'
import {
SkillHotkeysHud,
createCanonicalGenericSkillEntry,
getAvailableSkillsForSide,
getSkillListRow,
resolveSkillIcon,
} from '../../src/client/ui/hotkeys.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
import {
CharacterSheetPanel,
resolveStatValueColor,
} from '../../src/client/ui/character-sheet.ts'
import { D2FontRenderer } from '../../src/client/ui/font.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { WEAPON_SWAP_TABS_BOUNDS } from '../../src/client/ui/inventory.ts'
import {
HudModel,
itemDataToUiInventoryItem,
} from '../../src/client/ui-model/hud-model.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { D2StatId } from '../../src/client/world/stat-list.ts'
import type { ClientCommand, ItemData } from '../../src/netproto/index.ts'
function createMockCanvasCtx() {
const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = []
const font = new D2FontRenderer()
vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => {
drawTextCalls.push({
text: String(text),
x,
y,
...(opts?.color !== undefined ? { color: opts.color } : {}),
...(opts?.font !== undefined ? { font: opts.font } : {}),
})
return String(text).length * 8
})
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn((t: string) => ({ width: t.length * 7 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
return { ctx, font, drawTextCalls }
}
describe('Batch 6 Phase 3: Skills, Tooltips, Character Sheet & HUD Hooks (#679, #686, #687, #688, #689)', () => {
describe('Issue #679: Speedbar generic/item skills, skilldesc.txt ListRow/IconCel, weapon gating & fail-fast', () => {
it('defines canonical skilldesc.txt ListRow, ListPool, and IconCel for generic and scroll/tome skills', () => {
expect(getCanonicalSkillListRow(0)).toBe(0)
expect(getCanonicalSkillIconCel(0)).toBe(2)
expect(getCanonicalSkillListRow(1)).toBe(-1) // Kick: never in speedbar
expect(getCanonicalSkillListRow(5)).toBe(-1) // Left Hand Swing: never in speedbar
expect(getCanonicalSkillListRow(2)).toBe(0)
expect(getCanonicalSkillIconCel(2)).toBe(6)
expect(getCanonicalSkillListRow(3)).toBe(0)
expect(getCanonicalSkillIconCel(3)).toBe(4)
expect(getCanonicalSkillListRow(4)).toBe(0)
expect(getCanonicalSkillIconCel(4)).toBe(12)
expect(getCanonicalSkillListRow(217)).toBe(4)
expect(SKILLS_TABLE_META[217]?.listPool).toBe(1)
expect(getCanonicalSkillIconCel(217)).toBe(14)
expect(getCanonicalSkillListRow(218)).toBe(4)
expect(getCanonicalSkillIconCel(218)).toBe(14)
expect(getCanonicalSkillListRow(219)).toBe(4)
expect(SKILLS_TABLE_META[219]?.listPool).toBe(2)
expect(getCanonicalSkillIconCel(219)).toBe(18)
expect(getCanonicalSkillListRow(220)).toBe(4)
expect(getCanonicalSkillIconCel(220)).toBe(18)
})
it('always includes Attack (#0) in left and right speedbars and excludes ListRow < 0 (#1 Kick, #5 Left Hand Swing)', () => {
const kickEntry = {
skillId: 1,
name: 'Kick',
nameZh: '踢擊',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
}
const lhsEntry = {
skillId: 5,
name: 'Left Hand Swing',
nameZh: '左手揮擊',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
}
const leftList = getAvailableSkillsForSide('left', [kickEntry, lhsEntry])
const rightList = getAvailableSkillsForSide('right', [kickEntry, lhsEntry])
expect(leftList.map((s) => s.skillId)).toEqual([0])
expect(rightList.map((s) => s.skillId)).toEqual([0])
})
it('gates Throw (#2) and Left Hand Throw (#4) on equipped throwing weapons (itypea1 = thro, weapsel = 1)', () => {
expect(canUseSkillWithWeapon(2, { rightHandCode: 'ssd', rightHandType: 'swor' })).toBe(false)
expect(canUseSkillWithWeapon(2, { rightHandCode: 'jav', rightHandType: 'jave' })).toBe(true)
expect(canUseSkillWithWeapon(4, { rightHandCode: 'jav', leftHandCode: 'ssd' })).toBe(false)
expect(canUseSkillWithWeapon(4, { rightHandCode: 'ssd', leftHandCode: 'tkf' })).toBe(true)
})
it('syncs generic skills, throwing weapon skills, and inventory scrolls/tomes into hud.hotkeys and fails fast on unknown skillId', () => {
const world = new ClientWorld()
const hud = new HudManager()
const model = new HudModel({ world, hudManager: hud })
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 4, name: 'Barb', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 4 // Barbarian
world.apply({
type: 'SkillList',
unitId: 1,
skills: [
{ skillId: 3, level: 1, baseLevel: 1, bonus: 0 }, // Unsummon
{ skillId: 126, level: 5, baseLevel: 5, bonus: 0 }, // Bash
],
})
// Equip throwing knife in left hand (bodyLoc 5) and javelin in right hand (bodyLoc 4)
world.apply({
type: 'ItemEvent',
action: 6,
container: 'equipment',
item: {
id: 101,
code: 'jav',
location: 1,
bodyLoc: 4,
container: 'equipment',
invWidth: 1,
invHeight: 3,
} as unknown as ItemData,
})
world.apply({
type: 'ItemEvent',
action: 6,
container: 'equipment',
item: {
id: 102,
code: 'tkf',
location: 1,
bodyLoc: 5,
container: 'equipment',
invWidth: 1,
invHeight: 2,
} as unknown as ItemData,
})
// Add Tome of Town Portal (stat 70 = 14) and Scroll of Identify (2 stacks) to inventory
world.apply({
type: 'ItemEvent',
action: 4,
container: 'inventory',
item: {
id: 103,
code: 'tbk',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 2,
stats: [{ statId: 70, param: 0, value: 14 }],
} as unknown as ItemData,
})
world.apply({
type: 'ItemEvent',
action: 4,
container: 'inventory',
item: {
id: 104,
code: 'isc',
location: 0,
container: 'inventory',
gridX: 2,
gridY: 0,
invWidth: 1,
invHeight: 1,
} as unknown as ItemData,
})
model.syncFromWorld()
const ids = hud.hotkeys.availableSkills.map((s) => s.skillId)
expect(ids).toContain(0) // Attack
expect(ids).toContain(2) // Throw
expect(ids).toContain(3) // Unsummon
expect(ids).toContain(4) // Left Hand Throw
expect(ids).toContain(126) // Bash
expect(ids).toContain(217) // Scroll of Identify
expect(ids).toContain(220) // Book of Town Portal
expect(hud.hotkeys.getSkillCharges(220)).toBe(14)
expect(hud.hotkeys.getSkillCharges(217)).toBe(1)
// Fail-fast on unknown skillId without skilldesc metadata
;(world.self.skillEntries as Map<number, any>).set(9999, { skillId: 9999, level: 1, baseLevel: 1, bonus: 0 })
expect(() => model.syncFromWorld()).toThrow(/skilldesc/)
expect(() =>
getSkillListRow({
skillId: 9999,
name: 'Invalid',
nameZh: 'Invalid',
level: 1,
manaCost: 0,
leftUsable: false,
rightUsable: true,
}),
).toThrow(/ListRow/)
})
})
describe('Issue #686: Canonical skilldesc.txt & .tbl skill tooltips without invented lines', () => {
it('formats SkillTreePanel tooltips using StrSkill17, StrSkill1, StrSkill18, and Sksyn without invented text', () => {
const panel = new SkillTreePanel('sor')
panel.lang = 'ENG'
panel.characterLevel = 1
// Unlearned Fire Bolt (#36) -> shows StrSkill17 ("First Level"), no "Unlearned (Level 1 Preview)", no raw catalog Base Damage
const unlearnedLines = panel.formatSkillTooltipLines(36)
const unlearnedTexts = unlearnedLines.map((l) => l.text)
expect(unlearnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill17)
expect(unlearnedTexts.some((t) => t.includes('Unlearned (Level 1 Preview)'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('Base Damage:'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('[Synergy Bonuses]'))).toBe(false)
expect(unlearnedTexts.some((t) => t.includes('FireBolt Receives Bonuses From:') || t.includes('Fire Bolt Receives Bonuses From:'))).toBe(true)
// Learned Meteor (#56) with hardPoints=5, bonusLevels=2 -> no "(Base 5 + Gear +2)", no "Casting Delay:"
panel.hardPoints.set(56, 5)
panel.bonusLevels.set(56, 2)
const learnedLines = panel.formatSkillTooltipLines(56)
const learnedTexts = learnedLines.map((l) => l.text)
expect(learnedTexts).toContain(SKILL_TBL_STRINGS.ENG.StrSkill1)
expect(learnedTexts.some((t) => t.includes('(Base '))).toBe(false)
expect(learnedTexts.some((t) => t.includes('Casting Delay'))).toBe(false)
// Maxed skill (hardPoints=20) -> no "[Max Base Skill Level Reached (20/20)]"
panel.hardPoints.set(36, 20)
const maxedLines = panel.formatSkillTooltipLines(36)
expect(maxedLines.some((l) => l.text.includes('Max Base Skill Level Reached'))).toBe(false)
})
it('formats SkillHotkeysHud tooltips for generic/item skills (#0, #218) without Mana Cost: 0', () => {
const hotkeys = new SkillHotkeysHud()
hotkeys.lang = 'ENG'
const atkEntry = createCanonicalGenericSkillEntry(0, 1)!
const ibkEntry = createCanonicalGenericSkillEntry(218, 1, 17)!
hotkeys.setAvailableSkills([atkEntry, ibkEntry])
hotkeys.setSkillCharges(218, 17)
const atkLines = hotkeys.formatTooltipLines(0, 'left', true, 'ENG')
expect(atkLines[0]?.text).toBe('Attack')
expect(atkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
const ibkLines = hotkeys.formatTooltipLines(218, 'right', true, 'ENG')
expect(ibkLines[0]?.text).toBe(GENERIC_SKILL_TBL_STRINGS[218]!.nameEn) // "Tome of Identify"
expect(ibkLines.some((l) => l.text === 'Quantity 17')).toBe(true)
expect(ibkLines.some((l) => l.text.includes('Mana Cost'))).toBe(false)
})
})
describe('Issue #687: Character Sheet .tbl labels, dual AR & Defense boxes, dynamic stat colors, and resistance gold threshold', () => {
it('computes dynamic stat value colors (current > base -> blue, < base -> red, === base -> white)', () => {
expect(resolveStatValueColor(25, 20)).toBe('blue')
expect(resolveStatValueColor(15, 20)).toBe('red')
expect(resolveStatValueColor(20, 20)).toBe('white')
})
it('renders canonical .tbl labels, Left/Right AR + Defense boxes, dynamic colors, and maxResCap gold threshold', () => {
const sheet = new CharacterSheetPanel()
sheet.visible = true
sheet.lang = 'ENG'
sheet.attrs.str = 35
sheet.attrs.baseStr = 25 // buffed -> blue
sheet.attrs.dex = 15
sheet.attrs.baseDex = 20 // debuffed -> red
sheet.attrs.vit = 25
sheet.attrs.baseVit = 25 // equal -> white
sheet.attrs.leftSkillName = 'Fire Bolt'
sheet.attrs.rightSkillName = 'Frozen Orb'
sheet.attrs.attackRating = 450
sheet.attrs.baseAttackRating = 300 // buffed -> blue
sheet.attrs.rightAttackRating = 520
sheet.attrs.baseRightAttackRating = 520 // equal -> white
sheet.attrs.defense = 180
sheet.attrs.baseDefense = 120 // buffed -> blue
sheet.attrs.fireRes = 75
sheet.attrs.maxFireRes = 85 // 75 < 85 -> white (NOT gold!)
sheet.attrs.coldRes = 80
sheet.attrs.maxColdRes = 80 // 80 >= 80 -> gold!
sheet.attrs.lightRes = -10
sheet.attrs.maxLightRes = 75 // < 0 -> red
const { ctx, font, drawTextCalls } = createMockCanvasCtx()
sheet.draw(
ctx,
{
hp: 100,
maxHp: 120,
mana: 80,
maxMana: 80,
stamina: 90,
maxStamina: 90,
leftSkillDmg: '12-18',
rightSkillDmg: '240-260',
},
{
borderLeftImg: null,
charSheetImg: null,
buySellBtnImg: null,
levelBtnImg: null,
},
font,
)
const findCall = (text: string) => drawTextCalls.find((c) => c.text === text)
expect(findCall('35')?.color).toBe('blue')
expect(findCall('15')?.color).toBe('red')
expect(findCall('450')?.color).toBe('blue')
expect(findCall('520')?.color).toBe('white')
expect(findCall('180')?.color).toBe('blue')
expect(findCall('12-18')).toBeDefined()
expect(findCall('240-260')).toBeDefined()
// Fire res 75 with maxFireRes 85 must be white, whereas Cold res 80 with maxColdRes 80 must be gold
expect(findCall('75%')?.color).toBe('white')
expect(findCall('80%')?.color).toBe('gold')
expect(findCall('-10%')?.color).toBe('red')
// Two-line .tbl resistance labels ("Fire" + "Resistance")
expect(findCall('Fire')).toBeDefined()
expect(findCall('Resistance')).toBeDefined()
})
})
describe('Issue #688: skills.txt column derivation (passive, leftskill, aura, maxlvl) & canAllocate maxlvl check', () => {
it('derives isPassiveSkill, isAuraSkill, isLeftUsableSkill, and getSkillMaxLevel from skills.txt metadata', () => {
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
expect(isAuraSkill(98)).toBe(true) // Might
expect(isAuraSkill(106)).toBe(false) // Zeal
expect(isLeftUsableSkill(36)).toBe(true) // Fire Bolt (leftskill=1)
expect(isLeftUsableSkill(56)).toBe(false) // Meteor (leftskill=0)
expect(getSkillMaxLevel(36)).toBe(20)
expect(getSkillMaxLevel(36, 25)).toBe(25)
})
it('enforces getSkillMaxLevel(skillId) in SkillTreePanel.canAllocate', () => {
const panel = new SkillTreePanel('sor')
panel.unspentSkillPoints = 5
panel.characterLevel = 99
panel.hardPoints.set(36, getSkillMaxLevel(36))
expect(panel.canAllocate(36)).toBe(false)
panel.hardPoints.set(36, getSkillMaxLevel(36) - 1)
expect(panel.canAllocate(36)).toBe(true)
})
})
describe('Issue #689: Attack (#0) always uses skilldesc.txt IconCel = 2 regardless of hasWeapon', () => {
it('returns frameIndex = 2 (x = 96) for both armed and unarmed resolveSkillIcon(0)', () => {
const unarmed = resolveSkillIcon(0, false)
const armed = resolveSkillIcon(0, true)
expect(unarmed.frameIndex).toBe(2)
expect(unarmed.atlasRect.x).toBe(96)
expect(armed.frameIndex).toBe(2)
expect(armed.atlasRect.x).toBe(96)
})
})
describe('Cross-cutting HudModel & HudManager hooks (#664, #665, #673, #681, #683)', () => {
it('forwards graphicIndex to UiInventoryItem.variant in itemDataToUiInventoryItem (#681)', () => {
const ringItem = {
id: 501,
code: 'rin',
location: 0,
container: 'inventory',
gridX: 0,
gridY: 0,
invWidth: 1,
invHeight: 1,
graphicIndex: 3,
} as unknown as ItemData
const ui = itemDataToUiInventoryItem(ringItem)
expect((ui as any).variant).toBe(3)
})
it('preserves wp.npcMenu.hoveredOptionIdx on sync and anchors menu at Y = Math.max(20, npcScreenY - 150) (#664, #665)', () => {
const world = new ClientWorld()
const hud = new HudManager()
const model = new HudModel({ world, hudManager: hud })
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 1
world.self.x = 5000
world.self.y = 5000
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 77, classId: 148, x: 5002, y: 5002 }, // Akara
})
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 77 } })
model.syncFromWorld()
expect(hud.worldPanels.npcMenu).not.toBeNull()
// npcScreenY = 300 + (2 + 2) * 8 = 332 -> anchorY = 332 - 150 = 182
expect(hud.worldPanels.npcMenu?.y).toBe(182)
// Hover option index 1 and re-sync -> hoveredOptionIdx must be preserved
hud.worldPanels.npcMenu!.hoveredOptionIdx = 1
model.syncFromWorld()
expect(hud.worldPanels.npcMenu?.hoveredOptionIdx).toBe(1)
})
it('switches weapon sets only when clicking the inactive half (I vs II) of either weapon swap tab (#683) and omits drawAreaBanner (#673)', () => {
const world = new ClientWorld()
const hud = new HudManager()
const commands: ClientCommand[] = []
const model = new HudModel({
world,
hudManager: hud,
onCommand: (cmd) => commands.push(cmd),
})
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorc', x: 5000, y: 5000 },
})
world.self.unitId = 1
world.self.charClass = 1
model.syncFromWorld()
hud.toggleRightPanel('inv')
expect(hud.inventory.weaponSwapSet).toBe(0)
// Clicking left half ('I') of right weapon swap tab when set is already 0 -> no SwapWeapons command
const rightTab = WEAPON_SWAP_TABS_BOUNDS.right
hud.handleClick(rightTab.x + 10, rightTab.y + 10, 0)
expect(commands).toEqual([])
// Clicking right half ('II') of left weapon swap tab when set is 0 -> emits SwapWeapons command
const leftTab = WEAPON_SWAP_TABS_BOUNDS.left
hud.handleClick(leftTab.x + leftTab.w - 10, leftTab.y + 10, 0)
expect(commands).toEqual([{ type: 'SwapWeapons' }])
// Verify render() never calls worldPanels.drawAreaBanner
const bannerSpy = vi.spyOn(hud.worldPanels, 'drawAreaBanner')
const { ctx } = createMockCanvasCtx()
hud.ctx = ctx
hud.leftPanel = 'none'
hud.rightPanel = 'none'
hud.render()
expect(bannerSpy).not.toHaveBeenCalled()
})
})
})