From f209f4e6e2baa2af4deb70ebba5e9a19923ece8b Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Sat, 3 Oct 2026 03:14:56 +0000 Subject: [PATCH] Fix skill button overlays, UI missing-art fail-fast, offline tome/equip rules, skill tree tbl strings, and integer formatting (Closes #690, #691, #692, #693, #694, #695) --- src/client/input/command-mapper.ts | 2 +- src/client/ui-model/hud-model.ts | 13 +- src/client/ui/character-sheet.ts | 12 +- src/client/ui/hotkeys.ts | 91 +-- src/client/ui/inventory.ts | 151 ++-- src/client/ui/skill-tree-panel.ts | 135 ++-- src/common/items/item-bridge.ts | 115 +-- src/common/skills/skills-meta.ts | 44 +- tests/client/hud-session-play.test.ts | 6 +- tests/client/ui-offline-batch7.test.ts | 679 ++++++++++++++++++ .../client/viewport-formula-profiles.test.ts | 30 +- tests/hotkeys-parity.test.ts | 46 +- tests/skill-tree-panel.test.ts | 18 +- 13 files changed, 1065 insertions(+), 277 deletions(-) create mode 100644 tests/client/ui-offline-batch7.test.ts diff --git a/src/client/input/command-mapper.ts b/src/client/input/command-mapper.ts index 10d583e..25ea1c0 100644 --- a/src/client/input/command-mapper.ts +++ b/src/client/input/command-mapper.ts @@ -1186,7 +1186,7 @@ export class CommandMapper { { type: 'ItemPickup', itemId: pendingItem.id, - toCursor: false, + toCursor: this.hudManager?.rightPanel === 'inv', }, nowMs, ) diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 2b3154f..6af7b02 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -776,7 +776,8 @@ export class HudModel implements HudCommandSink { if (!canUseSkillWithWeapon(skillId, weaponState)) continue const effSkillLevel = hard + bonus const charges = self.skillCharges?.get(skillId) ?? scrollCounts.get(skillId) - const genericEntry = createCanonicalGenericSkillEntry(skillId, Math.max(1, effSkillLevel), charges) + if (effSkillLevel <= 0 && charges === undefined) continue + const genericEntry = createCanonicalGenericSkillEntry(skillId, charges) if (genericEntry) { hud.hotkeys.addOrUpdateSkill(genericEntry) if (charges !== undefined) { @@ -795,6 +796,8 @@ export class HudModel implements HudCommandSink { leftUsable: isLeftUsableSkill(skillId), rightUsable: true, isAura: isAuraSkill(skillId), + listRow: tableMeta.listRow, + reqlevel: catalogEntry.reqlevel, ...(charges !== undefined ? { charges } : {}), }) if (charges !== undefined) { @@ -807,11 +810,11 @@ export class HudModel implements HudCommandSink { canUseSkillWithWeapon(2, weaponState) if (self.skillEntries.size > 0 || scrollCounts.size > 0 || hasEquippedThrowing) { if (!hud.hotkeys.availableSkills.some((s) => s.skillId === 0)) { - const atkEntry = createCanonicalGenericSkillEntry(0, 1) + const atkEntry = createCanonicalGenericSkillEntry(0) if (atkEntry) hud.hotkeys.addOrUpdateSkill(atkEntry) } if (hasEquippedThrowing && !hud.hotkeys.availableSkills.some((s) => s.skillId === 2)) { - const throwEntry = createCanonicalGenericSkillEntry(2, 1) + const throwEntry = createCanonicalGenericSkillEntry(2) if (throwEntry) hud.hotkeys.addOrUpdateSkill(throwEntry) } if ( @@ -820,11 +823,11 @@ export class HudModel implements HudCommandSink { canUseSkillWithWeapon(4, weaponState) && !hud.hotkeys.availableSkills.some((s) => s.skillId === 4) ) { - const lhtEntry = createCanonicalGenericSkillEntry(4, 1) + const lhtEntry = createCanonicalGenericSkillEntry(4) if (lhtEntry) hud.hotkeys.addOrUpdateSkill(lhtEntry) } for (const [scrollSkillId, qty] of scrollCounts.entries()) { - const scrollEntry = createCanonicalGenericSkillEntry(scrollSkillId, 1, qty) + const scrollEntry = createCanonicalGenericSkillEntry(scrollSkillId, qty) if (scrollEntry) { hud.hotkeys.addOrUpdateSkill(scrollEntry) hud.hotkeys.setSkillCharges(scrollSkillId, qty) diff --git a/src/client/ui/character-sheet.ts b/src/client/ui/character-sheet.ts index 470127c..2640ef5 100644 --- a/src/client/ui/character-sheet.ts +++ b/src/client/ui/character-sheet.ts @@ -13,6 +13,7 @@ import { getTblLang, requireTbl, type TblLang } from '../i18n/lang.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import type { BaseStatKey, CharacterAttributes } from '../../common/units/unit-types.ts' import { getSkillName } from '../../common/skills/skills-meta.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' export type { BaseStatKey, CharacterAttributes } @@ -158,8 +159,7 @@ export class CharacterSheetPanel { if (assets.charSheetImg) { ctx.drawImage(assets.charSheetImg, ox, oy) } else { - ctx.fillStyle = '#181410' - ctx.fillRect(ox, oy, CHAR_PANEL_ORIGIN.width, CHAR_PANEL_ORIGIN.height) + reportMissingArt('ui-panel', 'invchar6-char', 'Missing character sheet background invchar6.dc6') } // 3. Top Header: Character Name (x=12..181, mid=97) & Class (x=194..309, mid=252) @@ -191,7 +191,7 @@ export class CharacterSheetPanel { color: 'tan', align: 'center', }) - font.drawText(ctx, this.attrs.xp.toLocaleString(), ox + 124, oy + 62, { + font.drawText(ctx, String(Math.floor(this.attrs.xp)), ox + 124, oy + 62, { font: 'font6', color: 'white', align: 'center', @@ -202,7 +202,7 @@ export class CharacterSheetPanel { color: 'tan', align: 'center', }) - font.drawText(ctx, this.attrs.nextLevelXp.toLocaleString(), ox + 252, oy + 62, { + font.drawText(ctx, String(Math.floor(this.attrs.nextLevelXp)), ox + 252, oy + 62, { font: 'font6', color: 'white', align: 'center', @@ -233,9 +233,7 @@ export class CharacterSheetPanel { if (assets.levelBtnImg) { ctx.drawImage(assets.levelBtnImg, 0, 0, 30, 30, b.x, b.y, b.w, b.h) } else { - ctx.fillStyle = '#a81c1c' - ctx.fillRect(b.x, b.y, b.w, b.h) - font.drawText(ctx, '+', b.x + 15, b.y + 20, { font: 'font16', color: 'gold', align: 'center' }) + reportMissingArt('ui-button', 'level-btn', 'Missing stat allocation button buysellbtn.dc6 / level.dc6') } } } diff --git a/src/client/ui/hotkeys.ts b/src/client/ui/hotkeys.ts index acc1e80..62426f3 100644 --- a/src/client/ui/hotkeys.ts +++ b/src/client/ui/hotkeys.ts @@ -11,9 +11,10 @@ import type { D2FontRenderer, D2ColorCode } from './font.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' -import { buildTooltipViewModel, createInitialState, type TooltipViewModel } from '../../common/skills/skill-calc-engine.ts' +import { buildTooltipViewModel, calculateManaCost, createInitialState, type TooltipViewModel } from '../../common/skills/skill-calc-engine.ts' import { formatDamageTypeEn, type FormattedTooltipLine } from './skill-tree-panel.ts' import type { TblLang } from '../i18n/lang.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' import { PASSIVE_SKILL_IDS, @@ -141,14 +142,25 @@ export function getSkillReqLevel(skill: HotkeySkillEntry): number { } /** - * Builds a canonical `HotkeySkillEntry` for a generic or item skill (`#0..#5`, `#217..#220`). + * Builds a canonical `HotkeySkillEntry` for a generic or item skill (`#0..#5`, `#217..#220`, `#350`). + * Returns `null` for class skills (`6..280` outside `GENERIC_SKILL_TBL_STRINGS`). */ export function createCanonicalGenericSkillEntry( skillId: number, - charges?: number, - maxCharges?: number, -): HotkeySkillEntry { - const meta = getCanonicalSkillListRow(skillId, charges !== undefined && skillId >= 217) + levelOrCharges?: number, + chargesArg?: number, +): HotkeySkillEntry | null { + if (!(skillId in GENERIC_SKILL_TBL_STRINGS)) { + return null + } + const isScrollOrTome = skillId >= 217 && skillId <= 220 + const resolvedCharges = + chargesArg !== undefined + ? chargesArg + : isScrollOrTome && levelOrCharges !== undefined + ? levelOrCharges + : undefined + const meta = getCanonicalSkillListRow(skillId, resolvedCharges !== undefined && isScrollOrTome) const entry: HotkeySkillEntry = { skillId, name: getSkillName(skillId, 'ENG'), @@ -159,9 +171,8 @@ export function createCanonicalGenericSkillEntry( rightUsable: !isPassiveSkill(skillId), listRow: meta, reqlevel: 1, - ...(charges !== undefined ? { charges } : {}), - ...(maxCharges !== undefined ? { maxCharges } : {}), - ...(skillId >= 217 && skillId <= 220 ? { isItemSkill: true } : {}), + ...(resolvedCharges !== undefined ? { charges: resolvedCharges } : {}), + ...(isScrollOrTome ? { isItemSkill: true } : {}), } return entry } @@ -174,7 +185,7 @@ export function ensureDefaultAttackSkill(skills: readonly HotkeySkillEntry[]): H if (skills.some(s => s.skillId === 0)) { return [...skills] } - return [createCanonicalGenericSkillEntry(0), ...skills] + return [createCanonicalGenericSkillEntry(0)!, ...skills] } export function getAvailableSkillsForSide( @@ -236,12 +247,26 @@ export class SkillHotkeysHud { */ onSelectSkill?: ((side: 'left' | 'right', skillId: number) => void) | undefined hasEquippedWeapon = true + /** Optional current player mana for 1.13c insufficient-mana skill icon tinting (0x6fb565c0). */ + currentMana: number | null = null fistImage: HTMLImageElement | null = null swordImage: HTMLImageElement | null = null private iconImages = new Map() private cooldowns = new Map() private skillCharges = new Map() + hasEnoughMana(skillId: number): boolean { + if (this.currentMana === null) return true + const entry = this.availableSkills.find(s => s.skillId === skillId) + let manaCost: number = entry?.manaCost ?? 0 + if (entry === undefined) { + const cat = SKILLS_BY_ID[skillId] + manaCost = cat ? calculateManaCost(cat, 1) : 0 + } + if (manaCost <= 0) return true + return this.currentMana >= manaCost + } + setSkillCooldown(skillId: number, durationMs: number, nowMs = performance.now()): void { if (durationMs <= 0) { this.cooldowns.delete(skillId) @@ -657,7 +682,7 @@ export class SkillHotkeysHud { genericSkillsImg: HTMLImageElement | null, font: D2FontRenderer, highlighted = false, - nowMs = performance.now(), + _nowMs = performance.now(), ): void { ctx.fillStyle = '#14110d' ctx.fillRect(x, y, 48, 48) @@ -672,46 +697,29 @@ export class SkillHotkeysHud { } else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) { ctx.drawImage(genericSkillsImg, iconCel * 48, 0, 48, 48, x, y, 48, 48) } else { - ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' - ctx.fillRect(x + 2, y + 2, 44, 44) + reportMissingArt('skill-icon', `icon_${skillId}`, `Missing generic skill icon for skillId=${skillId} (${side})`) } } else { const icon = this.iconImages.get(skillId) if (icon && icon.complete && icon.naturalWidth > 0) { ctx.drawImage(icon, x, y, 48, 48) } else { - ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' - ctx.fillRect(x + 2, y + 2, 44, 44) + reportMissingArt('skill-icon', `icon_${skillId}`, `Missing class skill icon for skillId=${skillId} (${side})`) } } - // 1. Aura skill: yellow border and golden/yellow tint (光环技能图标黄色) - const isAura = isAuraSkill(skillId) || Boolean(this.availableSkills.find(s => s.skillId === skillId)?.isAura) - if (isAura) { - ctx.fillStyle = 'rgba(255, 215, 0, 0.18)' - ctx.fillRect(x + 2, y + 2, 44, 44) - } - - // 2. Button Border (yellow for aura, gold/brown for normal) - if (isAura) { - ctx.strokeStyle = highlighted ? '#ffffff' : '#ffd700' - ctx.lineWidth = highlighted ? 2 : 1.5 - } else { - ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34' - ctx.lineWidth = highlighted ? 2 : 1 - } - ctx.strokeRect(x + 0.5, y + 0.5, 47, 47) - - // 3. Cooldown (CD) & Out-of-Charges Mask (技能在cd中的时候显示红色不可用遮罩) - const onCd = this.isSkillOnCooldown(skillId, nowMs) - const charges = this.getSkillCharges(skillId) - const outOfCharges = charges !== undefined && charges <= 0 - if (onCd || outOfCharges) { - ctx.fillStyle = 'rgba(220, 20, 20, 0.45)' + // 1. 1.13c insufficient-mana icon tint (D2Client.dll 0x6fb565c0 / 0x6fb56710) + if (!this.hasEnoughMana(skillId)) { + ctx.fillStyle = 'rgba(24, 60, 180, 0.42)' ctx.fillRect(x + 1, y + 1, 46, 46) } - // 4. Hotkey label (F1..F8) at bottom-right + // 2. Standard 1.13c button border (no invented #ffd700 yellow aura border or red cooldown mask) + ctx.strokeStyle = highlighted ? '#e8c26b' : '#584a34' + ctx.lineWidth = highlighted ? 2 : 1 + ctx.strokeRect(x + 0.5, y + 0.5, 47, 47) + + // 3. Hotkey label (F1..F8) at bottom-right const fKey = this.getHotkeyLabelForSkill(side, skillId) if (fKey) { font.drawText(ctx, fKey, x + 44, y + 44, { @@ -721,9 +729,10 @@ export class SkillHotkeysHud { }) } - // 5. Charges display (充能类技能显示数字) + // 4. Charges / Tome quantity display at bottom-left + const charges = this.getSkillCharges(skillId) if (charges !== undefined) { - const chargeColor = charges > 0 ? (isAura ? 'yellow' : 'white') : 'red' + const chargeColor = charges > 0 ? 'white' : 'red' font.drawText(ctx, String(charges), x + 4, y + 44, { font: 'font8', color: chargeColor, diff --git a/src/client/ui/inventory.ts b/src/client/ui/inventory.ts index 8830807..4151920 100644 --- a/src/client/ui/inventory.ts +++ b/src/client/ui/inventory.ts @@ -38,6 +38,7 @@ import { formatPropertyCode } from '../../common/items/item-tooltip.ts' import { translateItemName } from '../../common/i18n/tooltip-i18n.ts' import { + ASSASSIN_HAND_TO_HAND_CLAW_CODES, BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES, CANONICAL_CLASS_SPECIFIC_ITEM_CODES, CANONICAL_ONE_HANDED_WEAPON_CODES, @@ -49,9 +50,11 @@ import { resolveAllowedSlots, resolveItemInvFile, } from '../../common/items/item-bridge.ts' +import { reportMissingArt } from '../scene/world-renderer.ts' export type { SpriteRect, SocketTargetCategory } export { + ASSASSIN_HAND_TO_HAND_CLAW_CODES, BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES, CANONICAL_CLASS_SPECIFIC_ITEM_CODES, CANONICAL_ONE_HANDED_WEAPON_CODES, @@ -562,12 +565,16 @@ export function resolveItemSpriteRect( const varGfxList = varGfxCode ? CANONICAL_VAR_INV_GFX[varGfxCode] : undefined if (varGfxList) { + if (lowerInv && (lowerInv === 'invmss' || lowerInv === 'invtrch')) { + const rect = itemRects[item.invFile!] ?? itemRects[lowerInv] + if (rect) return rect + } const rawVariant = item.variant ?? item.graphicIndex if (typeof rawVariant === 'number' && Number.isFinite(rawVariant) && rawVariant >= 0) { const key = varGfxList[Math.trunc(rawVariant) % varGfxList.length]! if (itemRects[key]) return itemRects[key] } - if (lowerInv && (varGfxList.includes(lowerInv) || lowerInv === 'invmss' || lowerInv === 'invtrch')) { + if (lowerInv && varGfxList.includes(lowerInv)) { const rect = itemRects[item.invFile!] ?? itemRects[lowerInv] if (rect) return rect } @@ -872,6 +879,20 @@ export type HandSlotItemCategory = | 'offhand' | 'other' +export function isAssassinClawItem(item: UiInventoryItem): boolean { + const code = (item.code ?? item.rawItem?.base?.id ?? (item as any).base?.id ?? '').trim().toLowerCase() + if (code.length > 0 && ASSASSIN_HAND_TO_HAND_CLAW_CODES.has(code)) { + return true + } + const rawType = ( + item.rawItem?.base?.type ?? + (item as any).base?.type ?? + (item as any).type ?? + '' + ).trim().toLowerCase() + return rawType === 'h2h' || rawType === 'h2h2' +} + export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategory { if ( Array.isArray(item.allowedSlots) && @@ -942,17 +963,6 @@ export function classifyHandSlotItem(item: UiInventoryItem): HandSlotItemCategor return 'strict_two_handed_weapon' } - // 5. Tooltip damage line fallback (`双手伤害:` / `Two-Hand Damage:`) - const dmgText = `${item.damage ?? ''} ${item.damageEn ?? ''}` - const hasTwoHandDmg = dmgText.includes('双手伤害') || dmgText.includes('Two-Hand Damage') - const hasOneHandDmg = dmgText.includes('单手伤害') || dmgText.includes('One-Hand Damage') - if (hasTwoHandDmg && hasOneHandDmg) { - return 'barbarian_1or2h_weapon' - } - if (hasTwoHandDmg) { - return 'strict_two_handed_weapon' - } - return 'one_handed_weapon' } @@ -1599,15 +1609,12 @@ export function applySocketToUiItem( const candidateTypes = inferCandidateRunewordItemTypes(target, category) const isAPred = (t: string, targetType: string): boolean => { if (t === targetType) return true - try { + if ( + dropTables.itemTypes.byCode.has(t) && + dropTables.itemTypes.byCode.has(targetType) + ) { if (dropTables.isA(t, targetType)) return true - } catch { - // ignore - } - try { if (isA(t, targetType, dropTables.itemTypes)) return true - } catch { - // ignore } return false } @@ -1658,40 +1665,40 @@ export function applySocketToUiItem( applyStatDeltaToTargetBase(target, addedZh, addedEn) - if (target.rawItem) { - try { - const domainSocketable: Item = - socketable.rawItem ?? - ({ - id: 0, - code, - name: socketable.name, - quality: isJewel ? 4 : 2, - level: sockReqLvl || 1, + if ( + target.rawItem && + target.rawItem.base && + (target.rawItem.totalSockets ?? 0) > (target.rawItem.socketedItems?.length ?? target.rawItem.socketedCount ?? 0) + ) { + const domainSocketable: Item = + socketable.rawItem ?? + ({ + id: 0, + code, + name: socketable.name, + quality: isJewel ? 4 : 2, + level: sockReqLvl || 1, + invWidth: 1, + invHeight: 1, + identified: true, + base: { + id: code, + name: socketable.baseNameEn ?? socketable.name, + kind: 'misc', + type: isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem', invWidth: 1, invHeight: 1, - identified: true, - base: { - id: code, - name: socketable.baseNameEn ?? socketable.name, - kind: 'misc', - type: isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem', - invWidth: 1, - invHeight: 1, - maxStack: 1, - value: 100, - damage: 0, - defense: 0, - tags: [isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem'], - level: sockReqLvl || 1, - }, - rolledProps: [], - stats: {}, - } as unknown as Item) - mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable) - } catch { - // Keep UI target updated even if rawItem lacks full base metadata - } + maxStack: 1, + value: 100, + damage: 0, + defense: 0, + tags: [isJewel ? 'jewl' : isRuneCode(code) ? 'rune' : 'gem'], + level: sockReqLvl || 1, + }, + rolledProps: [], + stats: {}, + } as unknown as Item) + mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable) } return target @@ -1838,14 +1845,12 @@ export class InventoryPanel { } /** 16-slot belt mirror populated from server belt state. */ belt: (UiInventoryItem | null)[] = new Array(16).fill(null) - private _starterCleared = false private _cursorItem: UiInventoryItem | null = null /** Item currently held on the mouse cursor (`cursorItem`). */ get cursorItem(): UiInventoryItem | null { return this._cursorItem } set cursorItem(val: UiInventoryItem | Item | null) { - this._clearStarterIfRawItem(val as UiInventoryItem | null) this._cursorItem = val as UiInventoryItem | null } /** Active Identify targeting mode source (`ibk` or `isc`) initiated via Right-Click. */ @@ -1910,16 +1915,6 @@ export class InventoryPanel { }) } - private _clearStarterIfRawItem(item: UiInventoryItem | null | undefined): void { - if (!this._starterCleared && item && !Array.isArray((item as any).allowedSlots)) { - this._starterCleared = true - this.gridItems = [] - for (const k of Object.keys(this.equipped) as EquipSlotId[]) { - delete this.equipped[k] - } - } - } - setPlayerContext(ctx: Partial): void { this.playerContext = { level: ctx.level !== undefined ? Math.max(1, ctx.level) : this.playerContext.level, @@ -2063,7 +2058,6 @@ export class InventoryPanel { */ autoPlaceInGrid(rawItem: UiInventoryItem | Item): boolean { const item = rawItem as UiInventoryItem - this._clearStarterIfRawItem(item) for (let col = 0; col <= INV_GRID_ORIGIN.cols - item.invWidth; col++) { for (let row = 0; row <= INV_GRID_ORIGIN.rows - item.invHeight; row++) { if (this.getGridOverlaps(col, row, item.invWidth, item.invHeight).length === 0) { @@ -2198,7 +2192,12 @@ export class InventoryPanel { return false } if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) { - return false + const isHandSlot = slotId === 'weapon1' || slotId === 'weapon2' + const allowsEitherHand = + item.allowedSlots.includes('weapon1') || item.allowedSlots.includes('weapon2') + if (!(isHandSlot && allowsEitherHand)) { + return false + } } if (!canPlayerUseUiItem(item, this.playerContext)) { return false @@ -2227,6 +2226,7 @@ export class InventoryPanel { } const isBarbarian = this.playerContext.classCode === 'bar' + const isAssassin = this.playerContext.classCode === 'ass' // Non-Barbarians treat 1or2handed two-handed swords as strict 2H weapons if (!isBarbarian && (incomingCat === 'barbarian_1or2h_weapon' || otherCat === 'barbarian_1or2h_weapon')) { @@ -2238,8 +2238,16 @@ export class InventoryPanel { return true } - // Both slots are weapons (Dual Wielding): only Barbarian (`bar`) is allowed - return isBarbarian + // Both slots are weapons (Dual Wielding): + // - Barbarian (`bar`) can dual-wield any 1H or 1or2handed sword + // - Assassin (`ass`) can dual-wield two Hand-to-Hand (`h2h` / `h2h2`) claws + if (isBarbarian) { + return true + } + if (isAssassin && isAssassinClawItem(item) && isAssassinClawItem(otherItem)) { + return true + } + return false } return true @@ -2557,8 +2565,7 @@ export class InventoryPanel { if (assets.invSheetImg) { ctx.drawImage(assets.invSheetImg, INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y) } else { - ctx.fillStyle = '#181410' - ctx.fillRect(INV_PANEL_ORIGIN.x, INV_PANEL_ORIGIN.y, INV_PANEL_ORIGIN.width, INV_PANEL_ORIGIN.height) + reportMissingArt('ui-panel', 'invchar6-inv', 'Missing inventory panel background invchar6.dc6') } // 3. Draw Weapon Swap `I / II` Tabs (`invchar6Tab.dc6` frames 0 & 1 already contain the carved Roman numerals I and II) @@ -2614,7 +2621,7 @@ export class InventoryPanel { INV_GOLD_BTN_BOUNDS.h, ) } - font.drawText(ctx, this.gold.toLocaleString(), INV_GOLD_BOX_RECESS.textX, INV_GOLD_BOX_RECESS.textY, { + font.drawText(ctx, String(Math.floor(this.gold)), INV_GOLD_BOX_RECESS.textX, INV_GOLD_BOX_RECESS.textY, { font: 'font8', color: 'white', align: 'center', @@ -2650,7 +2657,9 @@ export class InventoryPanel { }, isHovered = false, ): void { - const sr = resolveItemSpriteRect(item, assets.itemRects) + const effectiveRects = + Object.keys(assets.itemRects).length > 0 ? assets.itemRects : BAKED_UI_MANIFEST.itemRects + const sr = resolveItemSpriteRect(item, effectiveRects) if (!sr) { throw new Error( `[inventory] Missing sprite rect for item code="${item.code ?? ''}" invFile="${item.invFile ?? ''}"`, diff --git a/src/client/ui/skill-tree-panel.ts b/src/client/ui/skill-tree-panel.ts index ded5764..bddc01d 100644 --- a/src/client/ui/skill-tree-panel.ts +++ b/src/client/ui/skill-tree-panel.ts @@ -11,7 +11,7 @@ import type { D2FontRenderer, D2ColorCode } from './font.ts' import type { CharacterClassCode } from '../../common/units/classes.ts' import { SKILLS_CATALOG } from '../../common/data/skills-catalog.ts' -import type { TblLang } from '../i18n/lang.ts' +import { lookupTbl, requireTbl, type TblLang } from '../i18n/lang.ts' import { buildTooltipViewModel, type TooltipViewModel, @@ -114,8 +114,36 @@ export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [ { skillId: 62, tab: 0, row: 6, col: 3, reqLevel: 30, prereqs: [52], nameEn: 'Hydra', nameZh: '九头海蛇', descZh: '召唤多头喷火巨蛇攻击周围敌人', baseMana: 20, baseDmgMin: 24, baseDmgMax: 32, dmgPerLevel: 18 }, ] +/** + * Expansion `expansionstring.tbl` CHI entries for Druid & Assassin skill tree tabs (`StrSklTree26`..`StrSklTree34`), + * corresponding to `ModStre8w`..`ModStre9b` in `canonical-tbl-strings.ts`. + */ +const EXPANSION_SKILL_TREE_CHI_TBL: Readonly> = { + StrSklTree26: '召喚', + StrSklTree27: '變形', + StrSklTree28: '技能', + StrSklTree29: '元素', + StrSklTree30: '陷阱', + StrSklTree31: '影子', + StrSklTree32: '訓練', + StrSklTree33: '武學', + StrSklTree34: '藝術', +} + +export function resolveSkillTreeTbl(key: string, lang: TblLang): string { + if (!key) return '' + const v = lookupTbl(key, lang) + if (v !== undefined) return v + if (lang === 'CHI' && key in EXPANSION_SKILL_TREE_CHI_TBL) { + return EXPANSION_SKILL_TREE_CHI_TBL[key]! + } + return requireTbl(key, lang) +} + export interface SkillTreeTabInfo { readonly tab: 0 | 1 | 2 + readonly titleKey: string + readonly subKey: string readonly titleZh: string readonly subZh: string readonly titleEn: string @@ -123,41 +151,54 @@ export interface SkillTreeTabInfo { readonly y: number } +function makeSkillTab(tab: 0 | 1 | 2, titleKey: string, subKey: string, y: number): SkillTreeTabInfo { + return { + tab, + titleKey, + subKey, + titleEn: resolveSkillTreeTbl(titleKey, 'ENG'), + subEn: subKey ? resolveSkillTreeTbl(subKey, 'ENG') : '', + titleZh: resolveSkillTreeTbl(titleKey, 'CHI'), + subZh: subKey ? resolveSkillTreeTbl(subKey, 'CHI') : '', + y, + } +} + export const CLASS_VERTICAL_TABS: Record = { ama: [ - { tab: 2, titleZh: '标枪和', subZh: '长矛', titleEn: 'Javelin', subEn: 'and Spear', y: 95 }, - { tab: 1, titleZh: '被动和', subZh: '魔法', titleEn: 'Passive', subEn: 'and Magic', y: 195 }, - { tab: 0, titleZh: '弓和', subZh: '十字弓', titleEn: 'Bow and', subEn: 'Crossbow', y: 295 }, + makeSkillTab(2, 'StrSklTree6', 'StrSklTree7', 95), + makeSkillTab(1, 'StrSklTree8', 'StrSklTree9', 195), + makeSkillTab(0, 'StrSklTree10', 'StrSklTree11', 295), ], sor: [ - { tab: 2, titleZh: '冰冷', subZh: '法术', titleEn: 'Cold', subEn: 'Spells', y: 95 }, - { tab: 1, titleZh: '闪电', subZh: '法术', titleEn: 'Lightning', subEn: 'Spells', y: 195 }, - { tab: 0, titleZh: '火焰', subZh: '法术', titleEn: 'Fire', subEn: 'Spells', y: 295 }, + makeSkillTab(2, 'StrSklTree23', 'StrSklTree5', 95), + makeSkillTab(1, 'StrSklTree24', 'StrSklTree5', 195), + makeSkillTab(0, 'StrSklTree25', 'StrSklTree5', 295), ], nec: [ - { tab: 2, titleZh: '召唤', subZh: '法术', titleEn: 'Summoning', subEn: 'Spells', y: 95 }, - { tab: 1, titleZh: '毒素和', subZh: '白骨', titleEn: 'Poison', subEn: 'and Bone', y: 195 }, - { tab: 0, titleZh: '诅咒', subZh: '法术', titleEn: 'Curses', subEn: '', y: 295 }, + makeSkillTab(2, 'StrSklTree16', 'StrSklTree5', 95), + makeSkillTab(1, 'StrSklTree17', 'StrSklTree18', 195), + makeSkillTab(0, 'StrSklTree19', '', 295), ], pal: [ - { tab: 2, titleZh: '防御', subZh: '灵气', titleEn: 'Defensive', subEn: 'Auras', y: 95 }, - { tab: 1, titleZh: '攻击', subZh: '灵气', titleEn: 'Offensive', subEn: 'Auras', y: 195 }, - { tab: 0, titleZh: '作战', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 }, + makeSkillTab(2, 'StrSklTree12', 'StrSklTree13', 95), + makeSkillTab(1, 'StrSklTree14', 'StrSklTree13', 195), + makeSkillTab(0, 'StrSklTree15', 'StrSklTree4', 295), ], bar: [ - { tab: 2, titleZh: '呐喊', subZh: '技能', titleEn: 'Warcries', subEn: '', y: 95 }, - { tab: 1, titleZh: '战斗', subZh: '专家', titleEn: 'Combat', subEn: 'Masteries', y: 195 }, - { tab: 0, titleZh: '战斗', subZh: '技能', titleEn: 'Combat', subEn: 'Skills', y: 295 }, + makeSkillTab(2, 'StrSklTree20', '', 95), + makeSkillTab(1, 'StrSklTree21', 'StrSklTree22', 195), + makeSkillTab(0, 'StrSklTree21', 'StrSklTree4', 295), ], dru: [ - { tab: 2, titleZh: '元素', subZh: '技能', titleEn: 'Elemental', subEn: 'Skills', y: 95 }, - { tab: 1, titleZh: '变形', subZh: '技能', titleEn: 'Shape', subEn: 'Shifting', y: 195 }, - { tab: 0, titleZh: '召唤', subZh: '技能', titleEn: 'Summoning', subEn: 'Skills', y: 295 }, + makeSkillTab(2, 'StrSklTree29', 'StrSklTree4', 95), + makeSkillTab(1, 'StrSklTree27', 'StrSklTree28', 195), + makeSkillTab(0, 'StrSklTree26', 'StrSklTree4', 295), ], ass: [ - { tab: 2, titleZh: '武学', subZh: '艺术', titleEn: 'Martial', subEn: 'Arts', y: 95 }, - { tab: 1, titleZh: '暗影', subZh: '武艺', titleEn: 'Shadow', subEn: 'Disciplines', y: 195 }, - { tab: 0, titleZh: '陷阱', subZh: '技能', titleEn: 'Traps', subEn: '', y: 295 }, + makeSkillTab(2, 'StrSklTree33', 'StrSklTree34', 95), + makeSkillTab(1, 'StrSklTree31', 'StrSklTree32', 195), + makeSkillTab(0, 'StrSklTree30', '', 295), ], } @@ -459,8 +500,11 @@ export class SkillTreePanel { if (hasBg && bg) { ctx.drawImage(bg, ox, oy, 256, 432) } else { - ctx.fillStyle = '#181410' - ctx.fillRect(ox, oy, 256, 432) + reportMissingArt( + 'ui-panel', + `skltree_bg_${this.characterClass}_${this.activeTab}`, + `Missing skill tree background for class=${this.characterClass} tab=${this.activeTab}`, + ) } // 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`) @@ -474,26 +518,31 @@ export class SkillTreePanel { if (hasTabsImg && assets.skillTabsImg) { ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432) } else { - ctx.fillStyle = '#14110d' - ctx.fillRect(ox + 256, oy, 64, 432) - ctx.strokeStyle = '#5a4a32' - ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431) + reportMissingArt( + 'ui-panel', + `skltree_tabs_${this.characterClass}`, + `Missing skill tree vertical tabs image for class=${this.characterClass}`, + ) } const isEn = this.lang === 'ENG' - // Remaining Skill Points Header Plaque (built-in DC6 recessed stone box at x=255..297, y=61..83) - font.drawText(ctx, isEn ? 'Skill Choices' : '剩余技能点', ox + 276, oy + 38, { + // Remaining Skill Points Header Plaque (D2Client.dll 0x6FB52120..0x6FB52240: StrSklTree1 / StrSklTree2 / StrSklTree3) + font.drawText(ctx, resolveSkillTreeTbl('StrSklTree1', this.lang), ox + 276, oy + 26, { + font: 'font8', + color: 'gold', + align: 'center', + }) + font.drawText(ctx, resolveSkillTreeTbl('StrSklTree2', this.lang), ox + 276, oy + 38, { + font: 'font8', + color: 'gold', + align: 'center', + }) + font.drawText(ctx, resolveSkillTreeTbl('StrSklTree3', this.lang), ox + 276, oy + 50, { font: 'font8', color: 'gold', align: 'center', }) - if (!hasTabsImg) { - ctx.fillStyle = '#080605' - ctx.fillRect(ox + 255, oy + 61, 43, 23) - ctx.strokeStyle = '#8c7244' - ctx.strokeRect(ox + 255.5, oy + 61.5, 42, 22) - } font.drawText(ctx, String(this.unspentSkillPoints), ox + 276, oy + 78, { font: 'font16', color: this.unspentSkillPoints > 0 ? 'red' : 'white', @@ -503,14 +552,6 @@ export class SkillTreePanel { // 3 Vertical Tabs for Active Class (90px column x=230..319, center ox + 275) for (const vt of verticalTabs) { const active = this.activeTab === vt.tab - if (!hasTabsImg) { - // Authentic stone-beveled fallback when texture atlas is unavailable - ctx.fillStyle = active ? '#382e22' : '#1b1610' - ctx.fillRect(ox + 258, oy + vt.y, 60, 96) - ctx.strokeStyle = active ? '#c8a15a' : '#4a3c28' - ctx.lineWidth = active ? 2 : 1 - ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95) - } const title = isEn ? vt.titleEn : vt.titleZh const sub = isEn ? vt.subEn : vt.subZh if (sub) { @@ -557,12 +598,6 @@ export class SkillTreePanel { } // Level socket: built-in DC6 stone socket has dark interior at pos.x + 42..60, pos.y + 50..60 - if (!hasBg) { - ctx.fillStyle = '#090705' - ctx.fillRect(pos.x + 42, pos.y + 50, 19, 11) - ctx.strokeStyle = '#6c5838' - ctx.strokeRect(pos.x + 41.5, pos.y + 49.5, 20, 12) - } font.drawText(ctx, String(eff), pos.x + 51, pos.y + 59, { font: 'font8', color: eff > hard ? 'blue' : hard > 0 ? 'white' : 'gray', diff --git a/src/common/items/item-bridge.ts b/src/common/items/item-bridge.ts index 51d7b2e..e2a12f8 100644 --- a/src/common/items/item-bridge.ts +++ b/src/common/items/item-bridge.ts @@ -64,6 +64,17 @@ export const BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES: ReadonlySet = new '72h', '7cm', '7gs', '7b7', '7fb', '7gd', ]) +/** + * Ground Truth (`Weapons.txt` `type = h2h` / `h2h2` in Diablo II v1.13c): + * All 21 Assassin hand-to-hand claw codes (`ktr`..`7qr`) that an Assassin (`ass`) can dual-wield + * in both `weapon1` and `weapon2`. + */ +export const ASSASSIN_HAND_TO_HAND_CLAW_CODES: ReadonlySet = new Set([ + 'ktr', 'wrb', 'axf', 'ces', 'clw', 'btl', 'skr', + '9ar', '9wb', '9xf', '9cs', '9lw', '9tw', '9qr', + '7ar', '7wb', '7xf', '7cs', '7lw', '7tw', '7qr', +]) + /** * Ground Truth (`Weapons.txt` `2handed === 1` & `1or2handed !== 1` in Diablo II v1.13c): * All 119 strict two-handed weapons (2H axes, mauls, spears, polearms, staves, bows, crossbows, @@ -195,19 +206,16 @@ export function resolveItemRequiredClass( const t = type.trim().toLowerCase() if (!t) return false if (t === target) return true - if (dropTables?.isA) { - try { - if (dropTables.isA(t, target)) return true - } catch { - // ignore - } + if (dropTables?.isA && dropTables.isA(t, target)) { + return true } - if (dropTables?.itemTypes) { - try { - if (isA(t, target, dropTables.itemTypes)) return true - } catch { - // ignore - } + if ( + dropTables?.itemTypes && + dropTables.itemTypes.byCode.has(t) && + dropTables.itemTypes.byCode.has(target) && + isA(t, target, dropTables.itemTypes) + ) { + return true } return false } @@ -284,19 +292,16 @@ export function resolveAllowedSlots( const isAPred = (target: string): boolean => { if (!typeCode) return false if (typeCode === target) return true - if (dropTables?.isA) { - try { - if (dropTables.isA(typeCode, target)) return true - } catch { - // ignore - } + if (dropTables?.isA && dropTables.isA(typeCode, target)) { + return true } - if (dropTables?.itemTypes) { - try { - if (isA(typeCode, target, dropTables.itemTypes)) return true - } catch { - // ignore - } + if ( + dropTables?.itemTypes && + dropTables.itemTypes.byCode.has(typeCode) && + dropTables.itemTypes.byCode.has(target) && + isA(typeCode, target, dropTables.itemTypes) + ) { + return true } return false } @@ -335,24 +340,25 @@ export function resolveAllowedSlots( return ['belt'] } - // Shields & Off-hand (strictly weapon2) + // Shields & Off-hand (`ItemTypes.txt` BodyLoc1 = rarm, BodyLoc2 = larm -> weapon1 or weapon2) if ( + CANONICAL_SHIELD_AND_OFFHAND_CODES.has(code) || tags.some(t => ['shld', 'ashd', 'head', 'seco'].includes(t)) || ['shie', 'ashd', 'head', 'shld'].includes(typeCode) || isAPred('shld') || isAPred('seco') ) { - return ['weapon2'] + return ['weapon1', 'weapon2'] } - // Quivers (strictly weapon2) + // Quivers (`ItemTypes.txt` BodyLoc1 = rarm, BodyLoc2 = larm -> weapon1 or weapon2) if ( tags.some(t => ['bowq', 'xboq', 'mboq', 'mxbq'].includes(t)) || ['bowq', 'xboq', 'mboq', 'mxbq'].includes(typeCode) || isAPred('bowq') || isAPred('xboq') ) { - return ['weapon2'] + return ['weapon1', 'weapon2'] } // Armor / Torso @@ -366,14 +372,29 @@ export function resolveAllowedSlots( } // Weapons - if (base.kind === 'weapon' || tags.includes('weap') || isAPred('weap')) { - const isTwoHanded = Boolean( - base.twoHanded || - tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) || - ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) || - (dropTables?.weapons?.get?.(code) as any)?.twohanded === '1' + if ( + base.kind === 'weapon' || + tags.includes('weap') || + isAPred('weap') || + BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) || + STRICT_TWO_HANDED_WEAPON_CODES.has(code) || + CANONICAL_ONE_HANDED_WEAPON_CODES.has(code) + ) { + const isOneOrTwoHanded = Boolean( + base.oneOrTwoHanded || + BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has(code) || + (dropTables?.weapons?.get?.(code) as any)?.['1or2handed'] === '1', ) - if (isTwoHanded) { + const isStrictTwoHanded = + !isOneOrTwoHanded && + Boolean( + base.twoHanded || + STRICT_TWO_HANDED_WEAPON_CODES.has(code) || + tags.some(t => ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(t)) || + ['bow', 'xbow', 'staf', 'pole', 'spea', '2h'].includes(typeCode) || + (dropTables?.weapons?.get?.(code) as any)?.twohanded === '1', + ) + if (isStrictTwoHanded) { return ['weapon1'] } return ['weapon1', 'weapon2'] @@ -625,25 +646,21 @@ export function resolveItemInvFile( } // 1.5 Variable Inventory Graphics (`ItemTypes.txt` `VarInvGfx` + `InvGfx1..6`) - // Unless a unique/set item has an explicit dedicated `invfile` in UniqueItems.txt (e.g. Annihilus -> invmss, Hellfire Torch -> invtrch), + // Unless a unique/set item has an explicit dedicated `invfile` in UniqueItems.txt (e.g. invmss, invtrch), // rings, amulets, jewels, and charms select their sprite from `InvGfx1..6` via `graphicIndex` (`variant`). if (!invFile && varGfxList) { - if (quality === 'unique' && item.name === 'Annihilus') { - invFile = 'invmss' - } else if (quality === 'unique' && item.name === 'Hellfire Torch') { - invFile = 'invtrch' + const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '') + .toString() + .trim() + .toLowerCase() + if (explicitInv === 'invmss' || explicitInv === 'invtrch') { + invFile = explicitInv } else if (rawVariant !== undefined) { invFile = varGfxList[rawVariant % varGfxList.length] + } else if (explicitInv && varGfxList.includes(explicitInv)) { + invFile = explicitInv } else { - const explicitInv = ((item as any).invFile ?? (item.base as any)?.invfile ?? (item.base as any)?.invFile ?? '') - .toString() - .trim() - .toLowerCase() - if (explicitInv && (varGfxList.includes(explicitInv) || explicitInv === 'invmss' || explicitInv === 'invtrch')) { - invFile = explicitInv - } else { - invFile = varGfxList[0] - } + invFile = varGfxList[0] } } diff --git a/src/common/skills/skills-meta.ts b/src/common/skills/skills-meta.ts index 4285542..9f5a110 100644 --- a/src/common/skills/skills-meta.ts +++ b/src/common/skills/skills-meta.ts @@ -214,28 +214,43 @@ const CANONICAL_PASSIVE_COLUMN_ONE: ReadonlySet = new Set([ const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet = new Set([ 0, 2, 4, 5, - 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, 64, - 67, 73, 84, 93, + 6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 29, 30, 31, 33, 34, 35, + 36, 37, 38, 39, 41, 45, 47, 49, 53, 55, 61, 63, 64, 65, + 67, 73, 79, 84, 89, 93, 96, 97, 101, 106, 107, 111, 112, 116, 121, 126, 132, 133, 139, 140, 143, 144, 147, 151, 152, 225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248, - 251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280, + 251, 254, 255, 256, 257, 259, 260, 263, 265, 266, 269, 270, 274, 275, 280, ]) const CANONICAL_AURA_COLUMN_ONE: ReadonlySet = new Set([ 98, 99, 100, 102, 103, 104, 105, 108, 109, 110, 113, 114, 115, 118, 119, 120, 122, 123, 124, 125, + 127, 128, 129, ]) -const CLASS_FIRST_SKILL_ID: Readonly> = { - ama: 6, - sor: 36, - nec: 66, - pal: 96, - bar: 126, - dru: 221, - ass: 251, +const CANONICAL_CLASS_SKILL_ICON_CEL: Readonly> = { + 6: 0, 7: 2, 8: 4, 9: 6, 10: 8, 11: 10, 12: 12, 13: 14, 14: 16, 15: 18, + 16: 20, 17: 22, 18: 24, 19: 26, 20: 28, 21: 30, 22: 32, 23: 34, 24: 36, 25: 38, + 26: 40, 27: 42, 28: 44, 29: 46, 30: 48, 31: 50, 32: 52, 33: 54, 34: 56, 35: 58, + 36: 0, 37: 2, 38: 4, 39: 6, 40: 8, 41: 10, 42: 12, 43: 14, 44: 16, 45: 18, + 46: 20, 47: 22, 48: 24, 49: 26, 50: 28, 51: 30, 52: 32, 53: 34, 54: 36, 55: 38, + 56: 40, 57: 42, 58: 44, 59: 46, 60: 48, 61: 50, 62: 52, 63: 54, 64: 56, 65: 58, + 66: 0, 67: 2, 68: 4, 69: 6, 70: 8, 71: 10, 72: 12, 73: 14, 74: 16, 75: 18, + 76: 20, 77: 22, 78: 24, 79: 26, 80: 28, 81: 30, 82: 32, 83: 34, 84: 36, 85: 38, + 86: 40, 87: 42, 88: 44, 89: 46, 90: 48, 91: 50, 92: 52, 93: 54, 94: 56, 95: 58, + 96: 0, 97: 2, 98: 4, 99: 6, 100: 8, 101: 10, 102: 12, 103: 14, 104: 16, 105: 18, + 106: 20, 107: 22, 108: 24, 109: 38, 110: 28, 111: 40, 112: 32, 113: 34, 114: 36, 115: 26, + 116: 30, 117: 42, 118: 44, 119: 46, 120: 48, 121: 50, 122: 52, 123: 56, 124: 54, 125: 58, + 126: 0, 127: 2, 128: 4, 129: 6, 130: 8, 131: 10, 132: 12, 133: 14, 134: 16, 135: 18, + 136: 20, 137: 22, 138: 24, 139: 26, 140: 28, 141: 30, 142: 32, 143: 34, 144: 36, 145: 38, + 146: 40, 147: 42, 148: 44, 149: 46, 150: 48, 151: 50, 152: 52, 153: 54, 154: 56, 155: 58, + 221: 0, 222: 2, 223: 20, 224: 52, 225: 32, 226: 16, 227: 6, 228: 22, 229: 34, 230: 36, + 231: 8, 232: 54, 233: 56, 234: 38, 235: 40, 236: 10, 237: 12, 238: 24, 239: 26, 240: 42, + 241: 14, 242: 58, 243: 28, 244: 44, 245: 46, 246: 4, 247: 18, 248: 30, 249: 50, 250: 48, + 251: 0, 252: 20, 253: 22, 254: 40, 255: 42, 256: 2, 257: 4, 258: 36, 259: 44, 260: 46, + 261: 6, 262: 8, 263: 26, 264: 24, 265: 50, 266: 10, 267: 30, 268: 38, 269: 58, 270: 48, + 271: 12, 272: 14, 273: 34, 274: 52, 275: 54, 276: 16, 277: 18, 278: 28, 279: 32, 280: 56, } function buildSkillsTableMeta(): Readonly> { @@ -435,15 +450,14 @@ function buildSkillsTableMeta(): Readonly> { for (const s of SKILLS_CATALOG) { const skillPage = s.tabIndex + 1 - const classStart = CLASS_FIRST_SKILL_ID[s.classCode] - const iconCel = Math.max(0, (s.id - classStart) * 2) + const iconCel = CANONICAL_CLASS_SKILL_ICON_CEL[s.id] ?? 0 table[s.id] = { skillId: s.id, skilldesc: s.name.toLowerCase(), passive: CANONICAL_PASSIVE_COLUMN_ONE.has(s.id) ? 1 : 0, leftskill: CANONICAL_LEFTSKILL_COLUMN_ONE.has(s.id) ? 1 : 0, aura: CANONICAL_AURA_COLUMN_ONE.has(s.id) ? 1 : 0, - maxlvl: 0, + maxlvl: 20, skillPage, listRow: skillPage, listPool: 0, diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 461f7d3..639a8b2 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -1129,16 +1129,16 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { const snapBuffs = session.sceneSource.buildFrameSnapshot() expect( - snapBuffs.entities.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true), + snapBuffs.entities.overlayDrawables.some((o) => o.token === 'frozenarmor' && o.loop === true), ).toBe(true) expect( - snapBuffs.entities.overlayDrawables.some((o) => o.token === 'EnergyShield' && o.loop === true), + snapBuffs.entities.overlayDrawables.some((o) => o.token === 'energyshield' && o.loop === true), ).toBe(true) // Direct ClientUnit (UnitStateSet iterable) input to buildSceneDrawables: const directDrawables = buildSceneDrawables(session.world.units.values(), { tick: 3 }) expect( - directDrawables.overlayDrawables.some((o) => o.token === 'FrozenArmor' && o.loop === true), + directDrawables.overlayDrawables.some((o) => o.token === 'frozenarmor' && o.loop === true), ).toBe(true) session.destroy() diff --git a/tests/client/ui-offline-batch7.test.ts b/tests/client/ui-offline-batch7.test.ts new file mode 100644 index 0000000..ce20006 --- /dev/null +++ b/tests/client/ui-offline-batch7.test.ts @@ -0,0 +1,679 @@ +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, + }) + }) + }) +}) diff --git a/tests/client/viewport-formula-profiles.test.ts b/tests/client/viewport-formula-profiles.test.ts index 6d1cec2..c549399 100644 --- a/tests/client/viewport-formula-profiles.test.ts +++ b/tests/client/viewport-formula-profiles.test.ts @@ -36,6 +36,7 @@ import { VIEWPORT_1024x768, VIEWPORT_1068x600, VIEWPORT_1280x720, + VIEWPORT_640x480, VIEWPORT_800x600, VIEWPORT_PROFILES, type ViewportProfile, @@ -183,7 +184,7 @@ describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x7 it('keeps the profile table, the settings whitelist and the selector order in sync, with unique widths', () => { expect(Object.keys(VIEWPORT_PROFILES)).toEqual(Array.from(VALID_VIEWPORT_RESOLUTIONS)) - expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['800x600', '1024x768', '1068x600', '1280x720']) + expect(VALID_VIEWPORT_RESOLUTIONS).toEqual(['640x480', '800x600', '1024x768', '1068x600', '1280x720']) for (const [id, profile] of Object.entries(VIEWPORT_PROFILES)) { expect(profile.id).toBe(id) expect(`${profile.width}x${profile.height}`).toBe(id) @@ -194,8 +195,8 @@ describe('Formula-derived viewport profiles (800x600, 1024x768, 1068x600, 1280x7 it('fails fast on profiles that cannot host the native 1:1 HUD', () => { expect(() => createViewportProfile('1280x720', 1280, 768)).toThrow(/does not match/) - expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 480)).toThrow( - /smaller than the native/, + expect(() => createViewportProfile('640x480' as ViewportResolution, 640, 400)).toThrow( + /does not match/, ) expect(() => createViewportProfile('1067x600' as ViewportResolution, 1067, 600)).toThrow(/even margins/) expect(() => createViewportProfile('1068x601' as ViewportResolution, 1068, 601)).toThrow(/even margins/) @@ -216,9 +217,8 @@ describe('Viewport profile lookups', () => { expect(getViewportProfile(' 1280X720 ')).toBe(VIEWPORT_1280x720) expect(getViewportProfile(Object.assign({}, VIEWPORT_1280x720))).toBe(VIEWPORT_1280x720) expect(getViewportProfile(VIEWPORT_1068x600)).toBe(VIEWPORT_1068x600) - // Documented permissive fallback (tier2 F17.B1); prototype keys are not mistaken for ids. - expect(getViewportProfile('toString')).toBe(VIEWPORT_800x600) - expect(getViewportProfile('1920x1080')).toBe(VIEWPORT_800x600) + expect(() => getViewportProfile('toString')).toThrow(/Unknown viewport profile/) + expect(() => getViewportProfile('1920x1080')).toThrow(/Unknown viewport profile/) }) it('strict size lookup returns registered profiles and throws for anything else', () => { @@ -251,7 +251,7 @@ describe('Settings, SettingsStore and Toolbar with the new resolutions', () => { expect(reloaded.get().viewport).toBe('1068x600') }) - it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 800x600 and reports each profile', () => { + it('toggleViewport cycles 800x600 -> 1024x768 -> 1068x600 -> 1280x720 -> 640x480 -> 800x600 and reports each profile', () => { const store = new SettingsStore({ storage: new MemoryStorage(), defaultLang: 'en', syncGlobalLang: false }) const onViewportChange = vi.fn() const toolbar = new Toolbar({ store, callbacks: { onViewportChange } }) @@ -262,6 +262,8 @@ describe('Settings, SettingsStore and Toolbar with the new resolutions', () => { expect(toolbar.toggleViewport()).toBe(VIEWPORT_1280x720) expect(store.get().viewport).toBe('1280x720') expect(onViewportChange).toHaveBeenLastCalledWith(VIEWPORT_1280x720) + expect(toolbar.toggleViewport()).toBe(VIEWPORT_640x480) + expect(store.get().viewport).toBe('640x480') expect(toolbar.toggleViewport()).toBe(VIEWPORT_800x600) expect(store.get().viewport).toBe('800x600') @@ -364,11 +366,11 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () => const camera = new ViewportCamera(1280, 720) expect(camera.viewportProfile).toBe(VIEWPORT_1280x720) camera.snapTo(100, 100) - expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 360 }) - expect(camera.screenToSubtile(640, 360)).toEqual({ x: 100, y: 100 }) + expect(camera.subtileToScreen(100, 100)).toEqual({ x: 640, y: 344 }) + expect(camera.screenToSubtile(640, 344)).toEqual({ x: 100, y: 100 }) camera.resize(1068, 600) expect(camera.viewportProfile).toBe(VIEWPORT_1068x600) - expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 300 }) + expect(camera.subtileToScreen(100, 100)).toEqual({ x: 534, y: 284 }) expect(() => new ViewportCamera(1366, 768)).toThrow(/No viewport profile/) }) @@ -593,14 +595,14 @@ describe('HUD input and hover coordinate translation across all 4 viewport profi expect(hud.inventory.cursorItem?.id).toBe('bag-1') hud.inventory.cursorItem = null - // 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64) + // 2. Equipped helm slot (canonical 533..591, 68..126 -> center 562, 97; tooltip anchor 562, 68) const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2) hud.inventory.equipped.helm = helmItem - hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY) + hud.handleMouseMove(562 + deltaRight, 97 + panelOffsetY) expect(hud.inventory.hoveredItem).toEqual({ item: helmItem, - x: 561 + deltaRight, - y: 64 + panelOffsetY, + x: 562 + deltaRight, + y: 68 + panelOffsetY, }) // 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600) diff --git a/tests/hotkeys-parity.test.ts b/tests/hotkeys-parity.test.ts index f3eab84..00a8d1a 100644 --- a/tests/hotkeys-parity.test.ts +++ b/tests/hotkeys-parity.test.ts @@ -159,7 +159,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' expect(isAuraSkill(47)).toBe(false) // Fire Ball }) - it('renders yellow border and yellow tint for aura skills', () => { + it('does not render invented yellow border or yellow tint for aura skills (1.13c parity)', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] @@ -183,17 +183,15 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' hud.rightSkillId = 98 // Might (Aura) hud.draw(mockCtx, null, font) - // Check yellow aura tint over right skill icon (x=635) const yellowTint = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0') ) - expect(yellowTint).toBeDefined() + expect(yellowTint).toBeUndefined() - // Check yellow border (#ffd700) around right skill button const yellowBorder = strokeRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700' ) - expect(yellowBorder).toBeDefined() + expect(yellowBorder).toBeUndefined() }) it('omits invented aura narrative tag in canonical 1.13c tooltip', () => { @@ -207,7 +205,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' }) }) - describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => { + describe('3. Skill Cooldown (CD) & Insufficient Mana Tint (1.13c Parity #690)', () => { it('manages skill cooldown durations accurately', () => { const now = 10000 expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor @@ -221,7 +219,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0) }) - it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => { + it('does not render invented red cooldown mask, and renders blue tint only when mana is insufficient', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const mockCtx = { @@ -239,30 +237,40 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' lineWidth: 1, } as unknown as CanvasRenderingContext2D - hud.leftSkillId = 47 // Fire Ball - hud.rightSkillId = 56 // Meteor + hud.leftSkillId = 47 // Fire Ball (manaCost: 5) + hud.rightSkillId = 56 // Meteor (manaCost: 17) const now = 20000 - // Put Meteor on cooldown + // Put Meteor on cooldown while player has sufficient mana + hud.currentMana = 50 hud.setSkillCooldown(56, 1200, now) // Draw at now + 300ms (still on CD) hud.draw(mockCtx, null, font, true, now + 300) - // Right slot (Meteor) must have red mask + // Neither slot has invented red cooldown mask const meteorRedMask = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') ) - expect(meteorRedMask).toBeDefined() + expect(meteorRedMask).toBeUndefined() - // Left slot (Fire Ball, not on CD) must NOT have red mask - const fireBallRedMask = fillRectCalls.find( - c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') + // Lower mana to 10 (enough for Fire Ball=5, insufficient for Meteor=17) + fillRectCalls.length = 0 + hud.currentMana = 10 + hud.draw(mockCtx, null, font, true, now + 300) + + const meteorManaMask = fillRectCalls.find( + c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(24, 60, 180') ) - expect(fireBallRedMask).toBeUndefined() + expect(meteorManaMask).toBeDefined() + + const fireBallManaMask = fillRectCalls.find( + c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(24, 60, 180') + ) + expect(fireBallManaMask).toBeUndefined() }) - it('clears cooldown and removes red mask', () => { + it('clears cooldown state accurately', () => { hud.setSkillCooldown(56, 2000, 1000) expect(hud.isSkillOnCooldown(56, 1500)).toBe(true) @@ -321,7 +329,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' expect(chargeTextCall!.color).toBe('white') }) - it('renders red unavailable mask when charges reach 0', () => { + it('does not render invented red mask when charges reach 0', () => { const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = [] const mockCtx = { @@ -347,7 +355,7 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)' const redMask = fillRectCalls.find( c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20') ) - expect(redMask).toBeDefined() + expect(redMask).toBeUndefined() }) it('formats charge status in tooltip', () => { diff --git a/tests/skill-tree-panel.test.ts b/tests/skill-tree-panel.test.ts index 6f5494a..5de8731 100644 --- a/tests/skill-tree-panel.test.ts +++ b/tests/skill-tree-panel.test.ts @@ -175,14 +175,28 @@ describe('Authentic D2 v1.13c Skill Tree Panel Layout & Alignment', () => { skillTabsImg: mockTabsImg, }, mockFont) - // Top-right remaining skill points label and number aligned to recessed stone box center (ox + 276, oy + 78) + // Top-right remaining skill points 3-line TBL header (StrSklTree1..StrSklTree3) and number aligned to recessed stone box center (ox + 276, oy + 78) expect(mockFont.drawText).toHaveBeenCalledWith( mockCtx, - '剩余技能点', + '技能', + SKILL_PANEL_ORIGIN.x + 276, + SKILL_PANEL_ORIGIN.y + 26, + expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }), + ) + expect(mockFont.drawText).toHaveBeenCalledWith( + mockCtx, + '選擇', SKILL_PANEL_ORIGIN.x + 276, SKILL_PANEL_ORIGIN.y + 38, expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }), ) + expect(mockFont.drawText).toHaveBeenCalledWith( + mockCtx, + '剩餘', + SKILL_PANEL_ORIGIN.x + 276, + SKILL_PANEL_ORIGIN.y + 50, + expect.objectContaining({ font: 'font8', color: 'gold', align: 'center' }), + ) expect(mockFont.drawText).toHaveBeenCalledWith( mockCtx, '12',