diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index 6a6395e..685c7dc 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -27,6 +27,10 @@ export interface HotkeySkillEntry { readonly charges?: number readonly maxCharges?: number readonly cooldownMs?: number + readonly listRow?: number + readonly reqlevel?: number + readonly isItemSkill?: boolean + readonly tabIndex?: number } /** @@ -78,17 +82,26 @@ export function isPassiveSkill(skillId: number): boolean { * Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`). */ export const LEFT_USABLE_SKILL_IDS: ReadonlySet = new Set([ - 0, 2, 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35, - 36, 38, 39, 41, 45, 47, 49, 53, 55, 56, 59, 64, + // Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5) + 0, 2, 4, 5, + // Amazon (20 active skills with leftskill = 1) + 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35, + // Sorceress (10 active skills with leftskill = 1) + 36, 38, 39, 41, 45, 47, 49, 53, 55, 64, + // Necromancer (4 active skills with leftskill = 1) 67, 73, 84, 93, + // Paladin (9 active combat skills with leftskill = 1) 96, 97, 101, 106, 107, 111, 112, 116, 121, + // Barbarian (10 active combat skills with leftskill = 1) 126, 132, 133, 139, 140, 143, 144, 147, 151, 152, - 225, 229, 230, 232, 238, 239, 240, 242, 243, 245, 248, + // Druid (12 active combat/elemental skills with leftskill = 1) + 225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248, + // Assassin (14 active martial/trap skills with leftskill = 1) 251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280, ]) export function isLeftUsableSkill(skillId: number): boolean { - if (isPassiveSkill(skillId)) return false + if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false return LEFT_USABLE_SKILL_IDS.has(skillId) } @@ -182,6 +195,42 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 }, } } + if (skillId === 4) { + return { + skillId: 4, + name: 'Left Hand Throw', + iconPath: '/skills/icon_4.png', + isGeneric: true, + dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', + dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', + frameIndex: 12, + atlasRect: { x: 576, y: 0, width: 48, height: 48, w: 48, h: 48 }, + } + } + if (skillId === 217 || skillId === 218) { + return { + skillId, + name: skillId === 217 ? 'Scroll of Identify' : 'Book of Identify', + iconPath: `/skills/icon_${skillId}.png`, + isGeneric: true, + dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', + dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', + frameIndex: 14, + atlasRect: { x: 672, y: 0, width: 48, height: 48, w: 48, h: 48 }, + } + } + if (skillId === 219 || skillId === 220) { + return { + skillId, + name: skillId === 219 ? 'Scroll of Townportal' : 'Book of Townportal', + iconPath: `/skills/icon_${skillId}.png`, + isGeneric: true, + dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', + dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', + frameIndex: 18, + atlasRect: { x: 864, y: 0, width: 48, height: 48, w: 48, h: 48 }, + } + } return { skillId, name: `Skill ${skillId}`, @@ -194,24 +243,78 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa } } +/** + * Resolves authentic 1.13c ListRow for Speedbar popup (0..4). + * Row 0 (bottom): Basic universal actions (Attack 0, Throw 2, Unsummon 3, etc.) + * Rows 1..3: Character skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) + * Row 4 (top): Item skills (Scrolls/Books of TP & ID, or items marked as item skills) + */ +export function getSkillListRow(skill: HotkeySkillEntry): number { + if (typeof (skill as any).listRow === 'number') { + return (skill as any).listRow + } + const id = skill.skillId + // 1. Item skills (Scrolls/Books of TP & ID, charged items, or marked as item skills) + if (id === 217 || id === 218 || id === 219 || id === 220 || (skill as any).isItemSkill || skill.charges !== undefined) { + return 4 + } + // 2. Universal / basic actions + if (id === 0 || id === 1 || id === 2 || id === 3 || id === 4 || id === 5 || id === 350 || (skill as any).isGeneric) { + return 0 + } + // 3. Class skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3) + const catalogEntry = SKILLS_BY_ID[id] + if (catalogEntry) { + return catalogEntry.tabIndex + 1 + } + if (typeof (skill as any).tabIndex === 'number') { + return (skill as any).tabIndex + 1 + } + return 0 +} + +/** + * Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup. + */ +export function getSkillReqLevel(skill: HotkeySkillEntry): number { + if (typeof (skill as any).reqlevel === 'number') { + return (skill as any).reqlevel + } + const catalogEntry = SKILLS_BY_ID[skill.skillId] + if (catalogEntry && typeof catalogEntry.reqlevel === 'number') { + return catalogEntry.reqlevel + } + return 1 +} + export function getAvailableSkillsForSide( side: 'left' | 'right', skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS, ): readonly HotkeySkillEntry[] { return skills.filter(s => { if (isPassiveSkill(s.skillId) || s.isPassive) return false - return side === 'left' ? s.leftUsable : s.rightUsable + if (side === 'left') { + if (isAuraSkill(s.skillId) || s.isAura) return false + if (!isLeftUsableSkill(s.skillId)) return false + return s.leftUsable !== false + } else { + return s.rightUsable !== false + } }) } export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const -export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ - // Universal Skills +export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [ { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true }, +] + +export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ + // Universal Skills + ...DEFAULT_UNIVERSAL_SKILLS, // Fire Spells (8 active) { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, { skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true }, @@ -219,7 +322,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, { skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true }, { skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true }, - { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true }, + { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true }, { skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true }, // Lightning Spells (9 active) { skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true }, @@ -238,7 +341,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ { skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true }, { skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true }, { skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true }, - { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true }, + { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true }, { skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true }, { skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true }, ] @@ -377,8 +480,12 @@ export class SkillHotkeysHud { ): void { // Passive skills strictly cannot enter the quickbar this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive) - if (defaultLeft !== undefined && !isPassiveSkill(defaultLeft)) this.leftSkillId = defaultLeft - if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) this.rightSkillId = defaultRight + if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) { + this.leftSkillId = defaultLeft + } + if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) { + this.rightSkillId = defaultRight + } this.preloadSkillIcons() } @@ -428,23 +535,24 @@ export class SkillHotkeysHud { assignSkill(side: 'left' | 'right', skillId: number): boolean { if (isPassiveSkill(skillId)) return false - if (side === 'left') this.leftSkillId = skillId - else this.rightSkillId = skillId + if (side === 'left') { + if (isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false + this.leftSkillId = skillId + } else { + this.rightSkillId = skillId + } return true } setDualSkill(skillId: number): boolean { - if (isPassiveSkill(skillId)) return false + if (isPassiveSkill(skillId) || isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false this.leftSkillId = skillId this.rightSkillId = skillId return true } getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] { - return this.availableSkills.filter(s => { - if (isPassiveSkill(s.skillId) || s.isPassive) return false - return side === 'left' ? s.leftUsable : s.rightUsable - }) + return getAvailableSkillsForSide(side, this.availableSkills) } triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null { @@ -455,8 +563,15 @@ export class SkillHotkeysHud { } const bound = this.bindings.get(key) if (!bound) return null - if (bound.side === 'left') this.leftSkillId = bound.skillId - else this.rightSkillId = bound.skillId + if (bound.side === 'left') { + if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) { + this.leftSkillId = bound.skillId + } + } else { + if (!isPassiveSkill(bound.skillId)) { + this.rightSkillId = bound.skillId + } + } return bound } @@ -466,19 +581,74 @@ export class SkillHotkeysHud { y: number w: number h: number + listRow: number + visualRow: number + col: number }[] { const list = this.getAvailableSkillsForSide(side) - const cols = 5 const cellW = 48 const cellH = 48 const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS - return list.map((skill, idx) => { - const col = idx % cols - const row = Math.floor(idx / cols) - const x = side === 'left' ? base.x + col * cellW : base.x - col * cellW - const y = base.y - (row + 1) * cellH - 6 - return { skill, x, y, w: cellW, h: cellH } + + // 1. Group available skills by authentic ListRow (0..4) + const rowMap = new Map() + for (const skill of list) { + const explicitCharges = this.getSkillCharges(skill.skillId) + const effectiveSkill = (explicitCharges !== undefined && skill.charges === undefined) + ? { ...skill, charges: explicitCharges } + : skill + const row = getSkillListRow(effectiveSkill) + let group = rowMap.get(row) + if (!group) { + group = [] + rowMap.set(row, group) + } + group.push(effectiveSkill) + } + + // 2. Intra-row ordering: sort each row by reqlevel ascending, then skillId ascending + for (const group of rowMap.values()) { + group.sort((a, b) => { + const reqA = getSkillReqLevel(a) + const reqB = getSkillReqLevel(b) + if (reqA !== reqB) return reqA - reqB + return a.skillId - b.skillId + }) + } + + // 3. Compaction: only consider rows with >= 1 skill, ordered from bottom (Row 0) to top (Row 4) + // Row 0: Basic actions (bottom, closest to slot) + // Rows 1..3: Class skill tree tabs + // Row 4: Item skills (top) + // Unlearned or inactive rows are compacted without blank gaps + const populatedRows = Array.from(rowMap.keys()) + .filter(r => (rowMap.get(r)?.length ?? 0) > 0) + .sort((a, b) => a - b) + + const cells: { + skill: HotkeySkillEntry + x: number + y: number + w: number + h: number + listRow: number + visualRow: number + col: number + }[] = [] + + populatedRows.forEach((listRow, visualRow) => { + const skillsInRow = rowMap.get(listRow)! + skillsInRow.forEach((skill, col) => { + const rawX = side === 'left' ? base.x + col * cellW : base.x - col * cellW + const rawY = base.y - (visualRow + 1) * cellH - 6 + // Clamp strictly within 800x600 viewport canvas bounds + const x = Math.max(0, Math.min(800 - cellW, rawX)) + const y = Math.max(0, Math.min(600 - cellH, rawY)) + cells.push({ skill, x, y, w: cellW, h: cellH, listRow, visualRow, col }) + }) }) + + return cells } getSpeedbarSkills(side: 'left' | 'right'): { @@ -487,6 +657,9 @@ export class SkillHotkeysHud { y: number w: number h: number + listRow: number + visualRow: number + col: number }[] { return this.getPopupCells(side) } @@ -536,8 +709,15 @@ export class SkillHotkeysHud { const cells = this.getPopupCells(this.openPopup) for (const c of cells) { if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) { - if (this.openPopup === 'left') this.leftSkillId = c.skill.skillId - else this.rightSkillId = c.skill.skillId + if (this.openPopup === 'left') { + if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) { + this.leftSkillId = c.skill.skillId + } + } else { + if (!isPassiveSkill(c.skill.skillId)) { + this.rightSkillId = c.skill.skillId + } + } this.openPopup = null return true } @@ -664,6 +844,36 @@ export class SkillHotkeysHud { ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' ctx.fillRect(x + 2, y + 2, 44, 44) } + } else if (skillId === 4) { + const icon = this.iconImages.get(4) + if (icon && icon.complete && icon.naturalWidth > 0) { + ctx.drawImage(icon, x, y, 48, 48) + } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { + ctx.drawImage(genericSkillsImg, 12 * 48, 0, 48, 48, x, y, 48, 48) + } else { + ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' + ctx.fillRect(x + 2, y + 2, 44, 44) + } + } else if (skillId === 217 || skillId === 218) { + const icon = this.iconImages.get(skillId) + if (icon && icon.complete && icon.naturalWidth > 0) { + ctx.drawImage(icon, x, y, 48, 48) + } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { + ctx.drawImage(genericSkillsImg, 14 * 48, 0, 48, 48, x, y, 48, 48) + } else { + ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' + ctx.fillRect(x + 2, y + 2, 44, 44) + } + } else if (skillId === 219 || skillId === 220) { + const icon = this.iconImages.get(skillId) + if (icon && icon.complete && icon.naturalWidth > 0) { + ctx.drawImage(icon, x, y, 48, 48) + } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { + ctx.drawImage(genericSkillsImg, 18 * 48, 0, 48, 48, x, y, 48, 48) + } else { + ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' + ctx.fillRect(x + 2, y + 2, 44, 44) + } } else { const icon = this.iconImages.get(skillId) if (icon && icon.complete && icon.naturalWidth > 0) { @@ -841,6 +1051,128 @@ export class SkillHotkeysHud { return lines } + if (skillId === 4) { + lines.push({ + text: '左手投掷 (Left Hand Throw)', + color: 'gold', + font: 'fontexocet10', + align: 'center', + }) + const boundKey = this.getHotkeyLabelForSkill(side, skillId) + if (isSpeedbar) { + lines.push({ + text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键', + color: 'tan', + font: 'font8', + align: 'center', + }) + } else if (boundKey) { + lines.push({ + text: `快捷键: ${boundKey}`, + color: 'tan', + font: 'font8', + align: 'center', + }) + } + lines.push({ + text: '使用副手投掷当前装备的投掷类武器。', + color: 'white', + font: 'font8', + align: 'center', + }) + lines.push({ + text: '法力消耗: 0', + color: 'white', + font: 'font8', + align: 'center', + }) + return lines + } + + if (skillId === 217 || skillId === 218) { + const isBook = skillId === 218 + lines.push({ + text: isBook ? '辨识之书 (Tome of Identify)' : '辨识卷轴 (Scroll of Identify)', + color: 'gold', + font: 'fontexocet10', + align: 'center', + }) + const boundKey = this.getHotkeyLabelForSkill(side, skillId) + if (isSpeedbar) { + lines.push({ + text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键', + color: 'tan', + font: 'font8', + align: 'center', + }) + } else if (boundKey) { + lines.push({ + text: `快捷键: ${boundKey}`, + color: 'tan', + font: 'font8', + align: 'center', + }) + } + lines.push({ + text: '使用后辨识未鉴定的魔法、稀有或暗金物品。', + color: 'white', + font: 'font8', + align: 'center', + }) + const charges = this.getSkillCharges(skillId) + if (charges !== undefined) { + lines.push({ + text: `数量: ${charges}`, + color: charges > 0 ? 'white' : 'red', + font: 'font8', + align: 'center', + }) + } + return lines + } + + if (skillId === 219 || skillId === 220) { + const isBook = skillId === 220 + lines.push({ + text: isBook ? '城镇传送之书 (Tome of Town Portal)' : '城镇传送卷轴 (Scroll of Town Portal)', + color: 'gold', + font: 'fontexocet10', + align: 'center', + }) + const boundKey = this.getHotkeyLabelForSkill(side, skillId) + if (isSpeedbar) { + lines.push({ + text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键', + color: 'tan', + font: 'font8', + align: 'center', + }) + } else if (boundKey) { + lines.push({ + text: `快捷键: ${boundKey}`, + color: 'tan', + font: 'font8', + align: 'center', + }) + } + lines.push({ + text: '打开一个通往最近城镇的安全蓝色传送门。', + color: 'white', + font: 'font8', + align: 'center', + }) + const charges = this.getSkillCharges(skillId) + if (charges !== undefined) { + lines.push({ + text: `数量: ${charges}`, + color: charges > 0 ? 'white' : 'red', + font: 'font8', + align: 'center', + }) + } + return lines + } + // 2. Class Skills const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null const entry = this.availableSkills.find(s => s.skillId === skillId) diff --git a/tests/hotkeys.test.ts b/tests/hotkeys.test.ts index 97a82cf..b99a9d2 100644 --- a/tests/hotkeys.test.ts +++ b/tests/hotkeys.test.ts @@ -233,6 +233,13 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => { hud.draw(ctx, mockAtlas, mockFont, true) const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192) expect(unsummonDraw).toBeDefined() + + // 5. Left Hand Throw (skill 4): should draw left hand throw (sx = 576, frame 12) + hud.leftSkillId = 4 + drawImageCalls.length = 0 + hud.draw(ctx, mockAtlas, mockFont, true) + const leftHandThrowDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 576) + expect(leftHandThrowDraw).toBeDefined() }) it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => { diff --git a/tests/speedbar-authentic-parity.test.ts b/tests/speedbar-authentic-parity.test.ts new file mode 100644 index 0000000..b061ca8 --- /dev/null +++ b/tests/speedbar-authentic-parity.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + SkillHotkeysHud, + DEFAULT_UNIVERSAL_SKILLS, + DEFAULT_SORCERESS_SKILLS, + isLeftUsableSkill, + isPassiveSkill, + isAuraSkill, + getSkillListRow, + getSkillReqLevel, + LEFT_SKILL_BOUNDS, + RIGHT_SKILL_BOUNDS, +} from '../src/ui/hotkeys.ts' +import type { HotkeySkillEntry } from '../src/ui/hotkeys.ts' +import { SKILLS_CATALOG, SKILLS_BY_CLASS } from '../src/data/skills-catalog.ts' +import type { SkillCatalogEntry } from '../src/data/skills-catalog.ts' +import { CHARACTER_CLASS_CODES } from '../src/game/classes.ts' + +describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => { + let hud: SkillHotkeysHud + + beforeEach(() => { + hud = new SkillHotkeysHud() + }) + + // Helper to convert catalog entries into complete HotkeySkillEntry objects + function createClassSkillEntries(entries: readonly SkillCatalogEntry[]): HotkeySkillEntry[] { + return entries.map(s => ({ + skillId: s.id, + name: s.name, + nameZh: s.nameZh, + level: 1, + manaCost: s.mana, + leftUsable: isLeftUsableSkill(s.id), + rightUsable: !isPassiveSkill(s.id), + isAura: isAuraSkill(s.id), + isPassive: isPassiveSkill(s.id), + tabIndex: s.tabIndex, + reqlevel: s.reqlevel, + })) + } + + // ========================================================================= + // 1. Ground Truth Catalog Mapping: ListRow & ReqLevel + // ========================================================================= + describe('1. Ground Truth Catalog Mapping', () => { + it('maps all 210 class skills in SKILLS_CATALOG to ListRow = tabIndex + 1 with 100% parity', () => { + expect(SKILLS_CATALOG.length).toBe(210) + for (const s of SKILLS_CATALOG) { + const entry: HotkeySkillEntry = { + skillId: s.id, + name: s.name, + nameZh: s.nameZh, + level: 1, + manaCost: s.mana, + leftUsable: isLeftUsableSkill(s.id), + rightUsable: !isPassiveSkill(s.id), + tabIndex: s.tabIndex, + } + const row = getSkillListRow(entry) + expect(row).toBe(s.tabIndex + 1) + expect(row).toBeGreaterThanOrEqual(1) + expect(row).toBeLessThanOrEqual(3) + } + }) + + it('maps universal / basic actions to ListRow = 0', () => { + const basicActions = [ + { skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true }, + { skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true }, + { skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true }, + { skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true }, + { skillId: 5, name: 'Left Hand Swing', leftUsable: true, rightUsable: true }, + { skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true }, + ] + for (const action of basicActions) { + const row = getSkillListRow({ + skillId: action.skillId, + name: action.name, + nameZh: action.name, + level: 1, + manaCost: 0, + leftUsable: action.leftUsable, + rightUsable: action.rightUsable, + }) + expect(row).toBe(0) + } + }) + + it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => { + const itemSkills = [ + { skillId: 217, name: 'Scroll of Identify' }, + { skillId: 218, name: 'Tome of Identify' }, + { skillId: 219, name: 'Scroll of Town Portal' }, + { skillId: 220, name: 'Tome of Town Portal' }, + { skillId: 999, name: 'Teleport Charge Item', isItemSkill: true }, + ] + for (const item of itemSkills) { + const row = getSkillListRow({ + skillId: item.skillId, + name: item.name, + nameZh: item.name, + level: 1, + manaCost: 0, + leftUsable: false, + rightUsable: true, + isItemSkill: (item as any).isItemSkill, + }) + expect(row).toBe(4) + } + }) + + it('maps charged class skills (e.g. Teleport 54, Life Tap 82, Lower Resist 91) to ListRow = 4', () => { + const chargedSkills: HotkeySkillEntry[] = [ + { skillId: 54, name: 'Teleport', nameZh: '传送', level: 1, manaCost: 24, charges: 20, maxCharges: 20, leftUsable: false, rightUsable: true }, + { skillId: 82, name: 'Life Tap', nameZh: '偷取生命', level: 1, manaCost: 9, charges: 15, maxCharges: 15, leftUsable: false, rightUsable: true }, + { skillId: 91, name: 'Lower Resist', nameZh: '降低抵抗', level: 1, manaCost: 22, charges: 10, maxCharges: 10, leftUsable: false, rightUsable: true }, + ] + for (const skill of chargedSkills) { + expect(getSkillListRow(skill)).toBe(4) + } + }) + + it('retrieves accurate reqlevel from catalog for all skills', () => { + for (const s of SKILLS_CATALOG) { + const entry: HotkeySkillEntry = { + skillId: s.id, + name: s.name, + nameZh: s.nameZh, + level: 1, + manaCost: s.mana, + leftUsable: true, + rightUsable: true, + } + expect(getSkillReqLevel(entry)).toBe(s.reqlevel) + } + // Basic actions default to reqlevel 1 + expect(getSkillReqLevel({ skillId: 0, name: 'Attack', nameZh: '攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1) + expect(getSkillReqLevel({ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1) + expect(getSkillReqLevel({ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true })).toBe(1) + }) + }) + + // ========================================================================= + // 2. Usability Filters (Left vs Right) Across All 7 Classes + // ========================================================================= + describe('2. Usability Filters (Left vs Right) Across All 7 Classes', () => { + CHARACTER_CLASS_CODES.forEach(cls => { + it(`enforces authentic 1.13c left and right filters for ${cls.toUpperCase()}`, () => { + const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) + const allSkills: HotkeySkillEntry[] = [...DEFAULT_UNIVERSAL_SKILLS, ...classSkills] + hud.setAvailableSkills(allSkills) + + // --- Left Click Speedbar --- + const leftCells = hud.getPopupCells('left') + for (const cell of leftCells) { + const id = cell.skill.skillId + // 1. Passive skills strictly filtered out + expect(isPassiveSkill(id)).toBe(false) + expect(cell.skill.isPassive).toBeFalsy() + // 2. Aura skills strictly filtered out + expect(isAuraSkill(id)).toBe(false) + expect(cell.skill.isAura).toBeFalsy() + // 3. Must be an official 1.13c left skill + expect(isLeftUsableSkill(id)).toBe(true) + // 4. Unsummon (3) is right-only + expect(id).not.toBe(3) + } + + // --- Right Click Speedbar --- + const rightCells = hud.getPopupCells('right') + for (const cell of rightCells) { + const id = cell.skill.skillId + // 1. Passive skills strictly filtered out + expect(isPassiveSkill(id)).toBe(false) + expect(cell.skill.isPassive).toBeFalsy() + // 2. Right click allows both active skills and auras + expect(cell.skill.rightUsable !== false).toBe(true) + } + }) + }) + + it('strictly excludes Meteor (56) and Blizzard (59) from Sorceress left speedbar', () => { + hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + const leftCells = hud.getPopupCells('left') + const leftIds = leftCells.map(c => c.skill.skillId) + + expect(leftIds).not.toContain(56) // Meteor + expect(leftIds).not.toContain(59) // Blizzard + expect(leftIds).toContain(47) // Fire Ball + expect(leftIds).toContain(55) // Glacial Spike + expect(leftIds).toContain(38) // Charged Bolt + expect(leftIds).toContain(0) // Normal Attack + + const rightCells = hud.getPopupCells('right') + const rightIds = rightCells.map(c => c.skill.skillId) + expect(rightIds).toContain(56) // Meteor present on right + expect(rightIds).toContain(59) // Blizzard present on right + }) + + it('strictly includes Maul (233) on Druid left speedbar', () => { + const druidSkills = createClassSkillEntries(SKILLS_BY_CLASS.dru) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...druidSkills]) + const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) + expect(leftIds).toContain(233) // Maul + expect(leftIds).not.toContain(228) // Cyclone Armor (buff, leftskill=0) + expect(leftIds).not.toContain(226) // Oak Sage (summon, leftskill=0) + }) + + it('strictly excludes all Paladin auras and Holy Shield from left speedbar', () => { + const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) + const leftCells = hud.getPopupCells('left') + const leftIds = leftCells.map(c => c.skill.skillId) + + // No aura allowed + expect(leftIds).not.toContain(98) // Might + expect(leftIds).not.toContain(102) // Holy Fire + expect(leftIds).not.toContain(113) // Holy Freeze + expect(leftIds).not.toContain(118) // Holy Shock + expect(leftIds).not.toContain(122) // Fanaticism + expect(leftIds).not.toContain(125) // Salvation + expect(leftIds).not.toContain(99) // Prayer + expect(leftIds).not.toContain(109) // Cleansing + expect(leftIds).not.toContain(115) // Vigor + expect(leftIds).not.toContain(124) // Redemption + expect(leftIds).not.toContain(117) // Holy Shield (self-buff, leftskill=0) + + // Combat skills allowed + expect(leftIds).toContain(96) // Sacrifice + expect(leftIds).toContain(97) // Smite + expect(leftIds).toContain(101) // Holy Bolt + expect(leftIds).toContain(106) // Zeal + expect(leftIds).toContain(107) // Charge + expect(leftIds).toContain(111) // Vengeance + expect(leftIds).toContain(112) // Blessed Hammer + expect(leftIds).toContain(116) // Conversion + expect(leftIds).toContain(121) // Fist of the Heavens + + // Right speedbar includes auras with yellow border marker + const rightCells = hud.getPopupCells('right') + const rightAuras = rightCells.filter(c => isAuraSkill(c.skill.skillId)) + expect(rightAuras.length).toBe(20) // All 20 Paladin auras + }) + + it('strictly excludes Barbarian combat mastery passives from both speedbars', () => { + const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) + const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId) + const rightIds = hud.getPopupCells('right').map(c => c.skill.skillId) + + const passives = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153] + for (const p of passives) { + expect(leftIds).not.toContain(p) + expect(rightIds).not.toContain(p) + } + }) + }) + + // ========================================================================= + // 3. Multi-Row Grouping & Row Compaction + // ========================================================================= + describe('3. Multi-Row Grouping & Row Compaction', () => { + it('compacts Barbarian right-click speedbar from [0, 1, 3] into contiguous visualRows [0, 1, 2]', () => { + const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills]) + const rightCells = hud.getPopupCells('right') + + // Barbarian has: + // ListRow 0: Attack (0), Throw (2), Unsummon (3) + // ListRow 1: Combat Skills (10 active) + // ListRow 2: Combat Masteries (10 PASSIVES -> 0 active) + // ListRow 3: Warcries (10 active) + const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b) + expect(listRowsPresent).toEqual([0, 1, 3]) + + const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) + expect(visualRowsPresent).toEqual([0, 1, 2]) + + // Verify that every cell in ListRow 3 is compacted into visualRow 2 + const warcriesCells = rightCells.filter(c => c.listRow === 3) + expect(warcriesCells.length).toBe(10) + for (const cell of warcriesCells) { + expect(cell.visualRow).toBe(2) + } + }) + + it('compacts Paladin left-click speedbar to contiguous visualRows [0, 1]', () => { + const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills]) + const leftCells = hud.getPopupCells('left') + + // ListRow 0 (Universal), ListRow 1 (Combat skills). ListRow 2 and 3 are auras (filtered out). + const listRowsPresent = Array.from(new Set(leftCells.map(c => c.listRow))).sort((a, b) => a - b) + expect(listRowsPresent).toEqual([0, 1]) + + const visualRowsPresent = Array.from(new Set(leftCells.map(c => c.visualRow))).sort((a, b) => a - b) + expect(visualRowsPresent).toEqual([0, 1]) + }) + + it('compacts single-tree character without gap rows', () => { + // Character that only learned Lightning Spells (Tab 2 => ListRow 2) + const lightSpells = createClassSkillEntries(SKILLS_BY_CLASS.sor.filter(s => s.tabIndex === 1 && !isPassiveSkill(s.id))) + + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...lightSpells]) + const rightCells = hud.getPopupCells('right') + + // ListRow 0 (Universal) and ListRow 2 (Lightning) + const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b) + expect(listRowsPresent).toEqual([0, 2]) + + // Compacted to visualRow 0 and 1 + const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) + expect(visualRowsPresent).toEqual([0, 1]) + + const lightCells = rightCells.filter(c => c.listRow === 2) + for (const cell of lightCells) { + expect(cell.visualRow).toBe(1) + } + }) + + it('maintains contiguous visualRow compaction with item skills across all 7 classes', () => { + const itemSkills: HotkeySkillEntry[] = [ + { skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, + { skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, + ] + for (const cls of CHARACTER_CLASS_CODES) { + const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills]) + const rightCells = hud.getPopupCells('right') + const visualRows = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b) + for (let i = 0; i < visualRows.length; i++) { + expect(visualRows[i]).toBe(i) + } + const itemCells = rightCells.filter(c => c.listRow === 4) + const maxRow = Math.max(...visualRows) + for (const cell of itemCells) { + expect(cell.visualRow).toBe(maxRow) + } + } + }) + + it('correctly places charged skills in ListRow 4 on a non-native class (Barbarian with Teleport charges)', () => { + const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar) + const tpChargedItem: HotkeySkillEntry = { + skillId: 54, + name: 'Teleport', + nameZh: '传送', + level: 1, + manaCost: 24, + charges: 20, + maxCharges: 20, + leftUsable: false, + rightUsable: true, + } + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem]) + const rightCells = hud.getPopupCells('right') + const tpCell = rightCells.find(c => c.skill.skillId === 54) + expect(tpCell).toBeDefined() + expect(tpCell!.listRow).toBe(4) + // Visual rows: 0 (Universal), 1 (Combat skills), 2 (Warcries), 3 (Item row with Teleport) + expect(tpCell!.visualRow).toBe(3) + }) + }) + + // ========================================================================= + // 4. Intra-Row Ordering + // ========================================================================= + describe('4. Intra-Row Ordering', () => { + it('strictly sorts skills within each row by reqlevel ascending, then skillId ascending', () => { + for (const cls of CHARACTER_CLASS_CODES) { + const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls]) + hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills]) + + for (const side of ['left', 'right'] as const) { + const cells = hud.getPopupCells(side) + // Group cells by visualRow + const rows = new Map() + for (const c of cells) { + let list = rows.get(c.visualRow) + if (!list) { + list = [] + rows.set(c.visualRow, list) + } + list.push(c) + } + + for (const rowCells of rows.values()) { + for (let i = 0; i < rowCells.length - 1; i++) { + const a = rowCells[i] + const b = rowCells[i + 1] + const reqA = getSkillReqLevel(a.skill) + const reqB = getSkillReqLevel(b.skill) + + if (reqA === reqB) { + expect(a.skill.skillId).toBeLessThan(b.skill.skillId) + } else { + expect(reqA).toBeLessThan(reqB) + } + // Verify column index is sequential 0, 1, 2... + expect(a.col).toBe(i) + expect(b.col).toBe(i + 1) + } + } + } + } + }) + }) + + // ========================================================================= + // 5. Adaptive Alignment & Canvas Bounds Clamping + // ========================================================================= + describe('5. Adaptive Alignment & Canvas Bounds Clamping', () => { + it('expands left speedbar rightward and right speedbar leftward', () => { + hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + + const leftCells = hud.getPopupCells('left') + const rightCells = hud.getPopupCells('right') + + // In left popup, col 0 is at base.x (117), col 1 is at 117 + 48 = 165 + const leftRow0 = leftCells.filter(c => c.visualRow === 0) + if (leftRow0.length >= 2) { + expect(leftRow0[0].x).toBe(LEFT_SKILL_BOUNDS.x) + expect(leftRow0[1].x).toBe(LEFT_SKILL_BOUNDS.x + 48) + } + + // In right popup, col 0 is at base.x (635), col 1 is at 635 - 48 = 587 + const rightRow0 = rightCells.filter(c => c.visualRow === 0) + if (rightRow0.length >= 2) { + expect(rightRow0[0].x).toBe(RIGHT_SKILL_BOUNDS.x) + expect(rightRow0[1].x).toBe(RIGHT_SKILL_BOUNDS.x - 48) + } + }) + + it('strictly clamps all cells within [0, 800 - 48] and [0, 600 - 48]', () => { + // Simulate extreme row with 25 skills + const megaSkills: HotkeySkillEntry[] = [] + for (let i = 1; i <= 25; i++) { + megaSkills.push({ + skillId: 1000 + i, + name: `Custom Skill ${i}`, + nameZh: `自定义技能 ${i}`, + level: 1, + manaCost: 10, + leftUsable: true, + rightUsable: true, + tabIndex: 0, + reqlevel: i, + }) + } + hud.setAvailableSkills(megaSkills) + + for (const side of ['left', 'right'] as const) { + const cells = hud.getPopupCells(side) + for (const cell of cells) { + expect(cell.x).toBeGreaterThanOrEqual(0) + expect(cell.x).toBeLessThanOrEqual(800 - 48) + expect(cell.y).toBeGreaterThanOrEqual(0) + expect(cell.y).toBeLessThanOrEqual(600 - 48) + } + } + }) + }) + + // ========================================================================= + // 6. Item Skills & Tooltips + // ========================================================================= + describe('6. Item Skills & Tooltips', () => { + it('positions item skills at the top row (ListRow = 4)', () => { + const skillsWithItems: HotkeySkillEntry[] = [ + ...DEFAULT_SORCERESS_SKILLS, + { skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, + { skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true }, + ] + hud.setAvailableSkills(skillsWithItems) + + const rightCells = hud.getPopupCells('right') + const itemCells = rightCells.filter(c => c.listRow === 4) + expect(itemCells.length).toBe(2) + + const maxVisualRow = Math.max(...rightCells.map(c => c.visualRow)) + for (const cell of itemCells) { + expect(cell.visualRow).toBe(maxVisualRow) + } + }) + + it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => { + hud.setSkillCharges(219, 12, 20) + const tpLines = hud.formatTooltipLines(219, 'right', true) + expect(tpLines[0].text).toContain('城镇传送卷轴') + expect(tpLines.some(l => l.text.includes('12'))).toBe(true) + + hud.setSkillCharges(218, 20, 20) + const idLines = hud.formatTooltipLines(218, 'right', true) + expect(idLines[0].text).toContain('辨识之书') + expect(idLines.some(l => l.text.includes('20'))).toBe(true) + + const lhtLines = hud.formatTooltipLines(4, 'left', true) + expect(lhtLines[0].text).toContain('左手投掷') + }) + }) + + // ========================================================================= + // 7. Interactive Assignments & Guardrails + // ========================================================================= + describe('7. Interactive Assignments & Guardrails', () => { + beforeEach(() => { + hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS]) + }) + + it('rejects assigning Meteor (56) and Blizzard (59) to left slot', () => { + expect(hud.assignSkill('left', 56)).toBe(false) + expect(hud.assignSkill('left', 59)).toBe(false) + expect(hud.leftSkillId).not.toBe(56) + expect(hud.leftSkillId).not.toBe(59) + + // Right slot succeeds + expect(hud.assignSkill('right', 56)).toBe(true) + expect(hud.rightSkillId).toBe(56) + expect(hud.assignSkill('right', 59)).toBe(true) + expect(hud.rightSkillId).toBe(59) + }) + + it('rejects assigning auras to left slot', () => { + expect(hud.assignSkill('left', 113)).toBe(false) // Holy Freeze + expect(hud.leftSkillId).not.toBe(113) + + expect(hud.assignSkill('right', 113)).toBe(true) + expect(hud.rightSkillId).toBe(113) + }) + + it('rejects assigning passive skills to any slot', () => { + expect(hud.assignSkill('left', 61)).toBe(false) // Fire Mastery + expect(hud.assignSkill('right', 61)).toBe(false) // Fire Mastery + expect(hud.assignSkill('left', 127)).toBe(false) // Sword Mastery + expect(hud.assignSkill('right', 127)).toBe(false) + }) + + it('enforces left-usability on setDualSkill', () => { + expect(hud.setDualSkill(0)).toBe(true) // Attack + expect(hud.leftSkillId).toBe(0) + expect(hud.rightSkillId).toBe(0) + + expect(hud.setDualSkill(56)).toBe(false) // Meteor + expect(hud.setDualSkill(113)).toBe(false) // Holy Freeze + }) + + it('handles speedbar popup opening, cell click selection, and slot closing', () => { + expect(hud.openPopup).toBeNull() + + // 1. Click Left Slot opens left popup + const leftBounds = LEFT_SKILL_BOUNDS + hud.handleClick(leftBounds.x + 10, leftBounds.y + 10) + expect(hud.openPopup).toBe('left') + + // 2. Select a cell in left popup (e.g. Fire Ball 47) + const leftCells = hud.getPopupCells('left') + const fbCell = leftCells.find(c => c.skill.skillId === 47)! + expect(fbCell).toBeDefined() + + hud.handleClick(fbCell.x + 5, fbCell.y + 5) + expect(hud.leftSkillId).toBe(47) + expect(hud.openPopup).toBeNull() + + // 3. Click Right Slot opens right popup + const rightBounds = RIGHT_SKILL_BOUNDS + hud.handleClick(rightBounds.x + 10, rightBounds.y + 10) + expect(hud.openPopup).toBe('right') + + // 4. Select Blizzard (59) in right popup + const rightCells = hud.getPopupCells('right') + const blizCell = rightCells.find(c => c.skill.skillId === 59)! + expect(blizCell).toBeDefined() + + hud.handleClick(blizCell.x + 5, blizCell.y + 5) + expect(hud.rightSkillId).toBe(59) + expect(hud.openPopup).toBeNull() + }) + }) +})