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)

This commit is contained in:
troytt 2026-10-03 03:14:56 +00:00
parent 8f48c4b1ec
commit f209f4e6e2
13 changed files with 1065 additions and 277 deletions

View File

@ -1186,7 +1186,7 @@ export class CommandMapper {
{
type: 'ItemPickup',
itemId: pendingItem.id,
toCursor: false,
toCursor: this.hudManager?.rightPanel === 'inv',
},
nowMs,
)

View File

@ -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)

View File

@ -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')
}
}
}

View File

@ -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<number, HTMLImageElement>()
private cooldowns = new Map<number, { untilMs: number; totalMs: number }>()
private skillCharges = new Map<number, { charges: number; maxCharges?: number | undefined }>()
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,

View File

@ -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,8 +1665,11 @@ export function applySocketToUiItem(
applyStatDeltaToTargetBase(target, addedZh, addedEn)
if (target.rawItem) {
try {
if (
target.rawItem &&
target.rawItem.base &&
(target.rawItem.totalSockets ?? 0) > (target.rawItem.socketedItems?.length ?? target.rawItem.socketedCount ?? 0)
) {
const domainSocketable: Item =
socketable.rawItem ??
({
@ -1689,9 +1699,6 @@ export function applySocketToUiItem(
stats: {},
} as unknown as Item)
mutable.rawItem = socketItemIntoHost(target.rawItem, domainSocketable)
} catch {
// Keep UI target updated even if rawItem lacks full base metadata
}
}
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<PlayerItemRequirementContext>): 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,8 +2192,13 @@ export class InventoryPanel {
return false
}
if (Array.isArray(item.allowedSlots) && !item.allowedSlots.includes(slotId)) {
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 ?? ''}"`,

View File

@ -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<Record<string, string>> = {
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<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
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',

View File

@ -64,6 +64,17 @@ export const BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES: ReadonlySet<string> = 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<string> = 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?.itemTypes) {
try {
if (isA(t, target, dropTables.itemTypes)) return true
} catch {
// ignore
if (dropTables?.isA && dropTables.isA(t, target)) {
return true
}
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?.itemTypes) {
try {
if (isA(typeCode, target, dropTables.itemTypes)) return true
} catch {
// ignore
if (dropTables?.isA && dropTables.isA(typeCode, target)) {
return true
}
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(
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',
)
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'
(dropTables?.weapons?.get?.(code) as any)?.twohanded === '1',
)
if (isTwoHanded) {
if (isStrictTwoHanded) {
return ['weapon1']
}
return ['weapon1', 'weapon2']
@ -625,27 +646,23 @@ 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'
} else if (rawVariant !== undefined) {
invFile = varGfxList[rawVariant % varGfxList.length]
} 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')) {
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 {
invFile = varGfxList[0]
}
}
}
// 2. Pre-baked Manifest Lookup by Item Display Name (handles dyed unique/set sprites e.g. "invcap_cgrn")
if (!invFile && item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name]) {

View File

@ -214,28 +214,43 @@ const CANONICAL_PASSIVE_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
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<number> = new Set<number>([
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<Record<CharacterClassCode, number>> = {
ama: 6,
sor: 36,
nec: 66,
pal: 96,
bar: 126,
dru: 221,
ass: 251,
const CANONICAL_CLASS_SKILL_ICON_CEL: Readonly<Record<number, number>> = {
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<Record<number, SkillTableMeta>> {
@ -435,15 +450,14 @@ function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
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,

View File

@ -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()

View File

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

View File

@ -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)

View File

@ -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', () => {

View File

@ -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',