From f1ca3dfc2b7a46d2e8b6a517a7a33dae123d6344 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 17:07:52 +0000 Subject: [PATCH] Fix mini-panel 8-button layout, death DC6, belt indexing/tables, and experience table (Closes #636, #637, #638, #639, #640, #641) --- public/assets/ui/death-hardcore.png | Bin 0 -> 3144 bytes public/assets/ui/death-inst.png | Bin 0 -> 4412 bytes public/ui/death-hardcore.png | Bin 0 -> 3144 bytes public/ui/death-inst.png | Bin 0 -> 4412 bytes src/client/ui/belt.ts | 29 +- src/client/ui/control-bar.ts | 173 +++++++++--- src/client/ui/hud-manager.ts | 53 ++-- src/common/items/belt-model.ts | 244 ++++++++++------- src/common/skills/formulas.ts | 195 +++++++------- tests/client/core-hud-parity.test.ts | 305 ++++++++++++++++++++++ tests/client/hud-server-authority.test.ts | 12 +- tests/formulas.test.ts | 40 ++- tests/m4-visual-belt-net-parity.test.ts | 2 +- tests/ui-hud.test.ts | 4 +- 14 files changed, 783 insertions(+), 274 deletions(-) create mode 100644 public/assets/ui/death-hardcore.png create mode 100644 public/assets/ui/death-inst.png create mode 100644 public/ui/death-hardcore.png create mode 100644 public/ui/death-inst.png create mode 100644 tests/client/core-hud-parity.test.ts diff --git a/public/assets/ui/death-hardcore.png b/public/assets/ui/death-hardcore.png new file mode 100644 index 0000000000000000000000000000000000000000..d050078d67753355966e127eb4c6733aef9ba038 GIT binary patch literal 3144 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 0`, `row 2 -> 1`, `row 3 -> 2`) */ @@ -17,13 +17,19 @@ import { BELT_BASE_X, BELT_BASE_Y, BELT_PITCH, + BELT_PITCH_X, + BELT_PITCH_Y, BELT_CELL_SIZE, + getBeltSlotBox, + BELT_CODE_TO_ROWS, + getBeltRowsForCode, BELT_POTION_CATALOG, areBeltPotionsCompatible, itemToBeltPotion, isAutoBeltablePotion, BeltModel, type BeltPotionKind, + type BeltPotionCategory, type BeltPotion, } from '../../common/items/belt-model.ts' @@ -33,13 +39,19 @@ export { BELT_BASE_X, BELT_BASE_Y, BELT_PITCH, + BELT_PITCH_X, + BELT_PITCH_Y, BELT_CELL_SIZE, + getBeltSlotBox, + BELT_CODE_TO_ROWS, + getBeltRowsForCode, BELT_POTION_CATALOG, areBeltPotionsCompatible, itemToBeltPotion, isAutoBeltablePotion, BeltModel, type BeltPotionKind, + type BeltPotionCategory, type BeltPotion, } @@ -53,12 +65,12 @@ export class BeltHud extends BeltModel { }, font: D2FontRenderer, ): void { - const visibleRows = this.isExpanded ? BELT_ROWS : 1 + const visibleRows = this.isExpanded ? this.maxRows : 1 - // Draw expanded rows 1..3 background (`ctrlpnl_popbelt.dc6`) + // Draw expanded rows 1..maxRows-1 background (`ctrlpnl_popbelt.dc6`) if (visibleRows > 1) { for (let row = 1; row < visibleRows; row++) { - const sy = BELT_BASE_Y - row * BELT_PITCH - 2 + const sy = BELT_BASE_Y - row * BELT_PITCH_Y - 2 if (assets.popbeltImg) { ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy) } else { @@ -72,9 +84,10 @@ export class BeltHud extends BeltModel { // Draw each cell & potion sprite for (let row = 0; row < visibleRows; row++) { - const sy = BELT_BASE_Y - row * BELT_PITCH for (let col = 0; col < BELT_COLS; col++) { - const sx = BELT_BASE_X + col * BELT_PITCH + const box = getBeltSlotBox(col, row) + const sx = box.x + const sy = box.y const pot = this.grid[row]![col] const isHovered = this.hoveredSlot?.row === row && this.hoveredSlot?.col === col @@ -90,7 +103,7 @@ export class BeltHud extends BeltModel { const dy = sy + Math.floor((BELT_CELL_SIZE - rect.h) / 2) ctx.drawImage(assets.itemsAtlasImg, rect.x, rect.y, rect.w, rect.h, dx, dy, rect.w, rect.h) } else { - ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : '#9c24c8' + ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : pot.kind === 'scroll' ? '#bfa054' : '#9c24c8' ctx.fillRect(sx + 6, sy + 4, 16, 20) } } diff --git a/src/client/ui/control-bar.ts b/src/client/ui/control-bar.ts index 6b0236b..c9c3990 100644 --- a/src/client/ui/control-bar.ts +++ b/src/client/ui/control-bar.ts @@ -6,15 +6,25 @@ * - Stamina Bar (x=273, y=573, w=102, h=18) with run drain & walk/stand recovery * - Walk / Run Toggle Button (`Panel\runbutton.dc6` at x=255, y=572, w=16, h=20, hotkey `R`) * - Central Mini-Panel Toggle (`Panel\menubutton.dc6` at x=393, y=562, w=15, h=24) & - * 6-button Mini-Panel Popup (`Panel\minipanel.dc6` at x=312, y=524, w=176, h=26): - * [Character, Inventory, Skill Tree, Automap, Quest Log, Waypoint/Menu] + * Mini-Panel Popup (`Panel\minipanel.dc6` 8-button 176x26 at x=312, y=522 in multiplayer, + * or `Panel\minipanel_s.dc6` 7-button 156x26 at x=322, y=522 in single-player): + * [Character, Inventory, Skill Tree, Party Screen (mp only), Automap, Message Log, Quest Log, Game Menu (Esc)] * - Unallocated Stat Points & Skill Points Red `+` Buttons (`Panel\Level.dc6` + `Levelsocket.dc6`) */ import type { D2FontRenderer } from './font.ts' import type { TblLang } from '../i18n/lang.ts' +import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts' -export type MiniPanelAction = 'char' | 'inv' | 'skill' | 'automap' | 'quest' | 'waypoint' +export type MiniPanelAction = + | 'char' + | 'inv' + | 'skill' + | 'party' + | 'automap' + | 'msg' + | 'quest' + | 'menu' export interface ControlBarState { readonly level: number @@ -34,33 +44,83 @@ export const STAMINA_BAR_BOUNDS = { x: 273, y: 573, width: 102, height: 18 } as export const RUN_BTN_BOUNDS = { x: 255, y: 572, width: 16, height: 20 } as const export const MENU_TOGGLE_BOUNDS = { x: 393, y: 562, width: 15, height: 24 } as const export const MINIPANEL_BOUNDS = { x: 312, y: 522, width: 176, height: 26 } as const +export const MINIPANEL_SINGLE_BOUNDS = { x: 322, y: 522, width: 156, height: 26 } as const export const STAT_PLUS_BTN_BOUNDS = { x: 206, y: 558, width: 30, height: 30 } as const export const SKILL_PLUS_BTN_BOUNDS = { x: 565, y: 558, width: 30, height: 30 } as const -const MINIPANEL_ITEMS: readonly { action: MiniPanelAction; labelZh: string; labelEn: string; hotkey: string }[] = [ - { action: 'char', labelZh: '角色属性', labelEn: 'Character', hotkey: 'A/C' }, - { action: 'inv', labelZh: '物品栏', labelEn: 'Inventory', hotkey: 'I/B' }, - { action: 'skill', labelZh: '技能树', labelEn: 'Skill Tree', hotkey: 'T' }, - { action: 'automap', labelZh: '自动地图', labelEn: 'Automap', hotkey: 'Tab' }, - { action: 'quest', labelZh: '任务日志', labelEn: 'Quest Log', hotkey: 'Q' }, - { action: 'waypoint', labelZh: '小站/储物箱', labelEn: 'Waypoint / Stash', hotkey: 'V' }, +export interface MiniPanelItemDef { + readonly action: MiniPanelAction + readonly iconGroup: number + readonly tblKey: string + readonly hotkey?: string + readonly fallbackEn: string + readonly fallbackZh: string +} + +export const MULTIPLAYER_MINIPANEL_ITEMS: readonly MiniPanelItemDef[] = [ + { action: 'char', iconGroup: 0, tblKey: 'minipanelchar', hotkey: 'A', fallbackEn: 'Character', fallbackZh: '人物' }, + { action: 'inv', iconGroup: 1, tblKey: 'minipanelinv', hotkey: 'I', fallbackEn: 'Inventory', fallbackZh: '物品' }, + { action: 'skill', iconGroup: 2, tblKey: 'minipaneltree', hotkey: 'T', fallbackEn: 'Skill Tree', fallbackZh: '技能樹' }, + { action: 'party', iconGroup: 3, tblKey: 'minipanelparty', hotkey: 'P', fallbackEn: 'Party Screen', fallbackZh: '隊伍畫面' }, + { action: 'automap', iconGroup: 4, tblKey: 'minipanelautomap', hotkey: 'Tab', fallbackEn: 'Automap', fallbackZh: '自動地圖' }, + { action: 'msg', iconGroup: 5, tblKey: 'minipanelmessage', hotkey: 'M', fallbackEn: 'Message Log', fallbackZh: '訊息記錄' }, + { action: 'quest', iconGroup: 6, tblKey: 'minipanelquest', hotkey: 'Q', fallbackEn: 'Quest Log', fallbackZh: '任務記錄' }, + { action: 'menu', iconGroup: 7, tblKey: 'minipanelmenubtn', fallbackEn: 'Game Menu (Esc)', fallbackZh: '遊戲選單(Esc)' }, ] +export const SINGLEPLAYER_MINIPANEL_ITEMS: readonly MiniPanelItemDef[] = [ + { action: 'char', iconGroup: 0, tblKey: 'minipanelchar', hotkey: 'A', fallbackEn: 'Character', fallbackZh: '人物' }, + { action: 'inv', iconGroup: 1, tblKey: 'minipanelinv', hotkey: 'I', fallbackEn: 'Inventory', fallbackZh: '物品' }, + { action: 'skill', iconGroup: 2, tblKey: 'minipaneltree', hotkey: 'T', fallbackEn: 'Skill Tree', fallbackZh: '技能樹' }, + { action: 'automap', iconGroup: 4, tblKey: 'minipanelautomap', hotkey: 'Tab', fallbackEn: 'Automap', fallbackZh: '自動地圖' }, + { action: 'msg', iconGroup: 5, tblKey: 'minipanelmessage', hotkey: 'M', fallbackEn: 'Message Log', fallbackZh: '訊息記錄' }, + { action: 'quest', iconGroup: 6, tblKey: 'minipanelquest', hotkey: 'Q', fallbackEn: 'Quest Log', fallbackZh: '任務記錄' }, + { action: 'menu', iconGroup: 7, tblKey: 'minipanelmenubtn', fallbackEn: 'Game Menu (Esc)', fallbackZh: '遊戲選單(Esc)' }, +] + +export function getMiniPanelTooltip(item: MiniPanelItemDef, lang: TblLang): string { + const base = resolveTblKey(item.tblKey, lang) ?? (lang === 'ENG' ? item.fallbackEn : item.fallbackZh) + if (!item.hotkey) { + return base + } + const specialFmt = resolveTblKey('minipanelspecial', lang) ?? (lang === 'ENG' ? ' (%s)' : '(%s)') + return base + specialFmt.replace('%s', item.hotkey) +} + export class ControlBarHud { miniPanelOpen = true lang: TblLang = 'CHI' + isMultiplayer = true + pressedAction: MiniPanelAction | null = null hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null + get currentMiniPanelItems(): readonly MiniPanelItemDef[] { + return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS + } + + get currentMiniPanelBounds(): { x: number; y: number; width: number; height: number } { + return this.isMultiplayer ? MINIPANEL_BOUNDS : MINIPANEL_SINGLE_BOUNDS + } + + get currentButtonStartX(): number { + return this.isMultiplayer ? 320 : 330 + } + handleMouseMove(logicalX: number, logicalY: number): void { this.hoveredElement = null - if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) { - const idx = Math.min( - MINIPANEL_ITEMS.length - 1, - Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)), - ) - this.hoveredElement = MINIPANEL_ITEMS[idx]!.action - return + if (this.miniPanelOpen) { + const items = this.currentMiniPanelItems + const startX = this.currentButtonStartX + const relX = logicalX - startX + const relY = logicalY - 525 + if (relY >= 0 && relY < 20 && relX >= 0) { + const idx = Math.floor(relX / 20) + if (idx >= 0 && idx < items.length) { + this.hoveredElement = items[idx]!.action + return + } + } } if (this.inRect(logicalX, logicalY, EXP_BAR_HOVER_BOUNDS)) { this.hoveredElement = 'exp' @@ -77,6 +137,27 @@ export class ControlBarHud { } } + handleMouseDown(logicalX: number, logicalY: number): void { + if (this.miniPanelOpen) { + const items = this.currentMiniPanelItems + const startX = this.currentButtonStartX + const relX = logicalX - startX + const relY = logicalY - 525 + if (relY >= 0 && relY < 20 && relX >= 0) { + const idx = Math.floor(relX / 20) + if (idx >= 0 && idx < items.length) { + this.pressedAction = items[idx]!.action + return + } + } + } + this.pressedAction = null + } + + handleMouseUp(): void { + this.pressedAction = null + } + handleClick( logicalX: number, logicalY: number, @@ -86,13 +167,18 @@ export class ControlBarHud { onMiniPanelAction: (action: MiniPanelAction) => void }, ): boolean { - if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) { - const idx = Math.min( - MINIPANEL_ITEMS.length - 1, - Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)), - ) - callbacks.onMiniPanelAction(MINIPANEL_ITEMS[idx]!.action) - return true + if (this.miniPanelOpen) { + const items = this.currentMiniPanelItems + const startX = this.currentButtonStartX + const relX = logicalX - startX + const relY = logicalY - 525 + if (relY >= 0 && relY < 20 && relX >= 0) { + const idx = Math.floor(relX / 20) + if (idx >= 0 && idx < items.length) { + callbacks.onMiniPanelAction(items[idx]!.action) + return true + } + } } if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) { this.miniPanelOpen = !this.miniPanelOpen @@ -180,27 +266,27 @@ export class ControlBarHud { ctx.drawImage(assets.menuBtnImg, frameIdx * 15, 0, 15, 24, MENU_TOGGLE_BOUNDS.x, MENU_TOGGLE_BOUNDS.y, 15, 24) } - // 6. Central Mini-panel Popup (`minipanel.dc6` 176x26 + `minipanelbtn.dc6` 20x20 icons) + // 6. Central Mini-panel Popup (`minipanel.dc6` 176x26 / `minipanel_s.dc6` 156x26 + `minipanelbtn.dc6` 20x20 icons) if (this.miniPanelOpen) { + const bounds = this.currentMiniPanelBounds + const items = this.currentMiniPanelItems + const startX = this.currentButtonStartX + if (assets.miniPanelImg) { - ctx.drawImage(assets.miniPanelImg, MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y) + ctx.drawImage(assets.miniPanelImg, bounds.x, bounds.y) } else { ctx.fillStyle = '#241e16' - ctx.fillRect(MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y, MINIPANEL_BOUNDS.width, MINIPANEL_BOUNDS.height) + ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height) } - for (let i = 0; i < MINIPANEL_ITEMS.length; i++) { - const bx = MINIPANEL_BOUNDS.x + 8 + i * 27 - const by = MINIPANEL_BOUNDS.y + 3 - const item = MINIPANEL_ITEMS[i]! - const isHovered = this.hoveredElement === item.action + for (let i = 0; i < items.length; i++) { + const bx = startX + i * 20 + const by = 525 + const item = items[i]! + const isPressed = this.pressedAction === item.action if (assets.miniPanelBtnsImg) { - const frameIdx = i * 2 + (isHovered ? 1 : 0) + const frameIdx = item.iconGroup * 2 + (isPressed ? 1 : 0) ctx.drawImage(assets.miniPanelBtnsImg, frameIdx * 20, 0, 20, 20, bx, by, 20, 20) } - if (isHovered) { - ctx.strokeStyle = '#e8c26b' - ctx.strokeRect(bx - 0.5, by - 0.5, 21, 21) - } } } @@ -266,10 +352,17 @@ export class ControlBarHud { SKILL_PLUS_BTN_BOUNDS.x + 15, 518, ) - } else if (this.hoveredElement && this.hoveredElement !== 'menu') { - const found = MINIPANEL_ITEMS.find(m => m.action === this.hoveredElement) + } else if (this.hoveredElement === 'menu') { + const tip = this.miniPanelOpen + ? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板')) + : (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open Mini Panel' : '開啟迷你面板')) + this.drawMiniTooltip(ctx, font, tip, MENU_TOGGLE_BOUNDS.x + 8, 518) + } else if (this.hoveredElement) { + const items = this.currentMiniPanelItems + const found = items.find((m) => m.action === this.hoveredElement) if (found) { - this.drawMiniTooltip(ctx, font, `${isEn ? found.labelEn : found.labelZh} (${found.hotkey})`, 400, 502) + const tip = getMiniPanelTooltip(found, this.lang) + this.drawMiniTooltip(ctx, font, tip, 400, 502) } } } diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index 7622d2f..f0e4bef 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -1002,6 +1002,8 @@ export class HudManager { popbelt: `${baseUrl}/popbelt.png`, miniPanel: `${baseUrl}/minipanel.png`, miniPanelBtns: `${baseUrl}/minipanel-btns.png`, + deathHardcore: `${baseUrl}/death-hardcore.png`, + deathInst: `${baseUrl}/death-inst.png`, runBtn: `${baseUrl}/runbutton.png`, menuBtn: `${baseUrl}/menubutton.png`, buySellBtn: `${baseUrl}/buysellbtn.png`, @@ -1698,6 +1700,7 @@ export class HudManager { window.addEventListener('mouseup', () => { this.cursor.handleMouseUp() + this.controlBar.handleMouseUp() this.worldPanels.releaseMomentaryVendorButton() }) @@ -1805,18 +1808,20 @@ export class HudManager { const docking = this.getDockingLayout() const barPt = toControlBarLocal(pt.x, pt.y, docking) + this.controlBar.handleMouseDown(barPt.x, barPt.y) + // Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y) if (beltHit) { if (e.button === 2) { - this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey)) + this.drinkBeltSlot(beltHit.row * 4 + beltHit.col, Boolean(e.shiftKey)) return } if (this._commandSink) { const sink = this._commandSink const slotIndex = beltHit.row * 4 + beltHit.col const cursor = this.inventory.cursorItem - const beltPotion = this.belt.getSlot(beltHit.row, beltHit.col) + const beltPotion = this.belt.getSlot(beltHit.col, beltHit.row) const rawBeltId = (beltPotion as { itemId?: number } | null)?.itemId ?? (beltPotion ? Number(beltPotion.id) : NaN) @@ -1840,7 +1845,6 @@ export class HudManager { return } } - this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey)) return } @@ -1864,9 +1868,11 @@ export class HudManager { if (action === 'char') this.toggleLeftPanel('char') else if (action === 'inv') this.toggleRightPanel('inv') else if (action === 'skill') this.toggleRightPanel('skill') + else if (action === 'party') this.toggleLeftPanel('party') else if (action === 'automap') this.callbacks.onToggleAutomap() + else if (action === 'msg') (this.callbacks as any).onToggleMessageLog?.() ?? this.toggleLeftPanel('msg' as any) else if (action === 'quest') this.toggleLeftPanel('quest') - else if (action === 'waypoint') this.toggleLeftPanel('waypoint') + else if (action === 'menu') this.toggleEscapeMenu() }, }) ) { @@ -2350,6 +2356,7 @@ export class HudManager { } this.state.leftPanel = this.leftPanel this.state.rightPanel = this.rightPanel + this.belt.setEquippedBelt(this.inventory.equipped.belt ?? null) this.state.beltExpanded = this.belt.isExpanded this.state.beltPotionsCount = this.belt.countTotalPotions() this.state.miniPanelOpen = this.controlBar.miniPanelOpen @@ -2896,23 +2903,35 @@ export class HudManager { }) } - // 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4) + // 10d. Authentic Death Presentation (`youdiedhardcore.dc6` & `youdiedinst.dc6` centered at (W/2, H/2 - 94) and (W/2, H/2 - 46)) if (this.isDead || Boolean((this.worldPanels as any).isDead)) { const centerX = Math.round(profile.width / 2) const centerY = Math.round(profile.height / 2) ctx.save() - ctx.fillStyle = 'rgba(80, 0, 0, 0.45)' - ctx.fillRect(0, 0, profile.width, profile.height) - ctx.fillStyle = '#d4b468' - ctx.font = 'bold 22px serif' - ctx.textAlign = 'center' - const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续' - ctx.fillText(deathMsg, centerX, centerY) - this.font.drawText(ctx, deathMsg, centerX, centerY, { - font: 'font16', - color: 'gold', - align: 'center', - }) + const hardcoreImg = this.images.get('deathHardcore') + const instImg = this.images.get('deathInst') + const y1 = centerY - 94 + const y2 = centerY - 46 + + if (hardcoreImg) { + ctx.drawImage(hardcoreImg, centerX - Math.round(hardcoreImg.width / 2), y1) + } else { + this.font.drawText(ctx, 'YOU HAVE DIED', centerX, y1 + 18, { + font: 'fontexocet10', + color: 'red', + align: 'center', + }) + } + + if (instImg) { + ctx.drawImage(instImg, centerX - Math.round(instImg.width / 2), y2) + } else { + this.font.drawText(ctx, 'PRESS ESC TO CONTINUE.', centerX, y2 + 18, { + font: 'font16', + color: 'gold', + align: 'center', + }) + } ctx.restore() } diff --git a/src/common/items/belt-model.ts b/src/common/items/belt-model.ts index 253422b..1910ce1 100644 --- a/src/common/items/belt-model.ts +++ b/src/common/items/belt-model.ts @@ -5,9 +5,9 @@ * Zero DOM or Node globals. */ -export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' +export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll' -export type BeltPotionCategory = 'health' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' +export type BeltPotionCategory = 'health' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll' export interface BeltPotion { readonly id: string @@ -24,12 +24,60 @@ export interface BeltPotion { export const BELT_COLS = 4 export const BELT_ROWS = 4 export const BELT_BASE_X = 423 -export const BELT_BASE_Y = 561 +export const BELT_BASE_Y = 562 export const BELT_PITCH = 31 +export const BELT_PITCH_X = 31 +export const BELT_PITCH_Y = 32 export const BELT_CELL_SIZE = 29 +export function getBeltSlotBox(col: number, row: number): { x: number; y: number; width: number; height: number } { + return { + x: BELT_BASE_X + col * BELT_PITCH_X, + y: BELT_BASE_Y - row * BELT_PITCH_Y, + width: BELT_CELL_SIZE, + height: BELT_CELL_SIZE, + } +} + /** - * Authoritative Diablo II v1.13c `misc.txt` belt-compatible potion definitions (`beltable = 1`). + * Rows granted by equipped belt item code per 1.13c `Armor.txt` + `Belts.txt`. + * Default (no belt) has 1 row (4 boxes). + */ +export const BELT_CODE_TO_ROWS: Readonly> = { + // Normal belts + lbl: 2, // Sash (8 boxes) + vbl: 2, // Light Belt (8 boxes) + mbl: 3, // Belt (12 boxes) + tbl: 3, // Heavy Belt (12 boxes) + hbl: 4, // Plated Belt / Girdle (16 boxes) + // Exceptional belts + zlb: 4, // Demonhide Sash (16 boxes) + zvb: 4, // Sharkskin Belt (16 boxes) + zmb: 4, // Mesh Belt (16 boxes) + ztb: 4, // Battle Belt (16 boxes) + zhb: 4, // War Belt (16 boxes) + // Elite belts + ulc: 4, // Spiderweb Sash (16 boxes) + uvc: 4, // Vampirefang Belt (16 boxes) + umc: 4, // Mithril Coil (16 boxes) + utc: 4, // Troll Belt (16 boxes) + uhc: 4, // Colossus Girdle (16 boxes) +} + +export function getBeltRowsForCode(beltCode: string | null | undefined): number { + if (!beltCode) return 1 + const code = beltCode.trim().toLowerCase() + return BELT_CODE_TO_ROWS[code] ?? 4 +} + +/** + * Authoritative Diablo II v1.13c `misc.txt` belt-compatible potion & scroll definitions (`belt = 1`). + * Icon filenames (invfile) strictly match misc.txt definitions: + * - vps (Stamina): invwps + * - yps (Antidote): invnps + * - wms (Thawing): invyps + * - tsc (Town Portal Scroll): invbsc + * - isc (Identify Scroll): invrsc */ export const BELT_POTION_CATALOG: Readonly>> = { hp1: { code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 }, @@ -44,48 +92,60 @@ export const BELT_POTION_CATALOG: Readonly mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 250 }, rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 35, healMana: 35 }, rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 100, healMana: 100 }, - vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', category: 'stamina', healHp: 0, healMana: 0 }, - yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', category: 'antidote', healHp: 0, healMana: 0 }, - wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', category: 'thawing', healHp: 0, healMana: 0 }, + vps: { code: 'vps', invFile: 'invwps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', category: 'stamina', healHp: 0, healMana: 0 }, + yps: { code: 'yps', invFile: 'invnps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', category: 'antidote', healHp: 0, healMana: 0 }, + wms: { code: 'wms', invFile: 'invyps', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', category: 'thawing', healHp: 0, healMana: 0 }, + // Classic potions (1.13c misc.txt) + hpo: { code: 'hpo', invFile: 'invhpo', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 }, + mpo: { code: 'mpo', invFile: 'invmpo', name: 'Minor Mana Potion', nameZh: '轻微法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 20 }, + hpf: { code: 'hpf', invFile: 'invhpf', name: 'Healing Potion', nameZh: '治疗药剂', kind: 'hp', category: 'health', healHp: 100, healMana: 0 }, + mpf: { code: 'mpf', invFile: 'invmpf', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 80 }, + // Scrolls with belt=1 in Misc.txt + tsc: { code: 'tsc', invFile: 'invbsc', name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 }, + isc: { code: 'isc', invFile: 'invrsc', name: 'Scroll of Identify', nameZh: '辨识卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 }, } /** - * Checks whether two belt potions belong to the same column family per `D2Common.dll` `BELT_CheckPotionType`. + * Checks whether two belt items belong to the same column family per `D2Common.dll` `BELT_CheckPotionType`. + * Scrolls require identical item code; potions match by kind family. */ export function areBeltPotionsCompatible(a: BeltPotion, b: BeltPotion): boolean { + if (a.kind === 'scroll' || b.kind === 'scroll') { + return a.code === b.code + } return a.kind === b.kind } /** * Converts a raw `Item`, `UiInventoryItem`, `BeltPotion`, or item-like object into a `BeltPotion` - * if and only if it is an authentic belt-compatible potion (`hp1`–`hp5`, `mp1`–`mp5`, `rvs`, `rvl`, `vps`, `yps`, `wms`). + * if and only if it is an authentic belt-compatible item (potions and scrolls with `belt = 1`). * Excludes throwing potions (`gpm`, `opm`, `gpl`, `opl`, `gps`, `ops` which are weapons with `beltable=0`). */ export function itemToBeltPotion(item: unknown): BeltPotion | null { if (!item || typeof item !== 'object') return null const raw = item as Record - // Unwrap GroundItemEntity if passed directly + // Unwrap GroundItemEntity or nested item object const inner = raw.item && typeof raw.item === 'object' && !raw.kind ? (raw.item as Record) : raw // Already a valid BeltPotion if ( typeof inner.kind === 'string' && - ['hp', 'mana', 'rejuv', 'stamina', 'antidote', 'thawing'].includes(inner.kind) && - typeof inner.healHp === 'number' && - typeof inner.healMana === 'number' + ['hp', 'mana', 'rejuv', 'stamina', 'antidote', 'thawing', 'scroll'].includes(inner.kind) && + typeof inner.code === 'string' ) { - const kind = inner.kind as BeltPotionKind + const code = inner.code.trim().toLowerCase() + const catalogEntry = BELT_POTION_CATALOG[code] return { - id: typeof inner.id === 'string' ? inner.id : `pot-${inner.code ?? inner.kind}`, - code: typeof inner.code === 'string' ? inner.code.trim().toLowerCase() : inner.kind, - invFile: typeof inner.invFile === 'string' ? inner.invFile : `inv${inner.code ?? 'hp4'}`, - name: typeof inner.name === 'string' ? inner.name : 'Potion', - nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (typeof inner.name === 'string' ? inner.name : '药剂'), - kind, - category: kind === 'hp' ? 'health' : kind, - healHp: inner.healHp, - healMana: inner.healMana, + id: typeof inner.id === 'string' ? inner.id : `pot-${code}`, + code, + invFile: inner.invFile || catalogEntry?.invFile || `inv${code}`, + name: typeof inner.name === 'string' ? inner.name : (catalogEntry?.name ?? 'Potion'), + nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (catalogEntry?.nameZh ?? inner.name ?? '药剂'), + kind: inner.kind as BeltPotionKind, + category: inner.category ?? catalogEntry?.category ?? (inner.kind === 'hp' ? 'health' : (inner.kind as BeltPotionCategory)), + healHp: typeof inner.healHp === 'number' ? inner.healHp : (catalogEntry?.healHp ?? 0), + healMana: typeof inner.healMana === 'number' ? inner.healMana : (catalogEntry?.healMana ?? 0), } } @@ -96,13 +156,13 @@ export function itemToBeltPotion(item: unknown): BeltPotion | null { } const rawCode = String(inner.code ?? base?.id ?? '').trim().toLowerCase() - if (['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops'].includes(rawCode)) { + if (!rawCode || ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops'].includes(rawCode)) { return null } - const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode || 'item'}`) - const catalogEntry = rawCode ? BELT_POTION_CATALOG[rawCode] : undefined + const catalogEntry = BELT_POTION_CATALOG[rawCode] if (catalogEntry) { + const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`) return { ...catalogEntry, id: idStr, @@ -112,51 +172,23 @@ export function itemToBeltPotion(item: unknown): BeltPotion | null { } } - // Fallback for synthetic engine potion items (e.g. base.id === 'potion' or base.tags includes 'potion') - const baseId = String(base?.id ?? '').trim().toLowerCase() - const baseType = String(base?.type ?? '').trim().toLowerCase() - const tags: readonly string[] = Array.isArray(base?.tags) ? base.tags : [] - const nameLower = `${inner.name ?? ''} ${inner.nameZh ?? ''} ${base?.name ?? ''}`.toLowerCase() - - const isTaggedPotion = - baseId === 'potion' || - ['hpot', 'mpot', 'rpot', 'spot', 'apot', 'wpot', 'pot'].includes(baseType) || - tags.some(t => ['potion', 'hpot', 'mpot', 'rpot', 'healing'].includes(String(t).toLowerCase())) - - // Guard against non-beltable items like elixirs or throwing potions - if (!isTaggedPotion) { - if (!/\b(healing|mana|rejuvenation|stamina|antidote|thawing)\s+potion\b/i.test(nameLower) && - !/(治疗药剂|法力药剂|回复活力药剂|耐力药剂|解毒药剂|融冰药剂)/.test(nameLower)) { - return null + // Check if item has explicit beltable property from misc.txt + if (inner.beltable === 1 || inner.belt === 1 || base?.beltable === 1 || base?.belt === 1) { + const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`) + return { + id: idStr, + code: rawCode, + invFile: typeof inner.invFile === 'string' && inner.invFile.length > 0 ? inner.invFile : (base?.invFile ?? `inv${rawCode}`), + name: typeof inner.name === 'string' ? inner.name : (base?.name ?? rawCode), + nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (base?.nameZh ?? inner.name ?? rawCode), + kind: 'hp', + category: 'health', + healHp: 0, + healMana: 0, } } - if (/strangling|choking|rancid|fulminating|exploding|oil\s+potion/i.test(nameLower)) { - return null - } - let template = BELT_POTION_CATALOG.hp3! - if (baseType === 'rpot' || nameLower.includes('rejuv') || nameLower.includes('活力')) { - template = nameLower.includes('full') || nameLower.includes('全面') ? BELT_POTION_CATALOG.rvl! : BELT_POTION_CATALOG.rvs! - } else if (baseType === 'mpot' || nameLower.includes('mana') || nameLower.includes('法力')) { - template = BELT_POTION_CATALOG.mp3! - } else if (baseType === 'spot' || nameLower.includes('stamina') || nameLower.includes('耐力')) { - template = BELT_POTION_CATALOG.vps! - } else if (baseType === 'apot' || nameLower.includes('antidote') || nameLower.includes('解毒')) { - template = BELT_POTION_CATALOG.yps! - } else if (baseType === 'wpot' || nameLower.includes('thawing') || nameLower.includes('融冰')) { - template = BELT_POTION_CATALOG.wms! - } - - const statHeal = typeof inner.stats?.heal === 'number' ? inner.stats.heal : (typeof base?.stats?.heal === 'number' ? base.stats.heal : undefined) - return { - ...template, - id: idStr, - code: rawCode || template.code, - invFile: typeof inner.invFile === 'string' && inner.invFile.length > 0 ? inner.invFile : template.invFile, - name: typeof inner.name === 'string' && inner.name.length > 0 ? inner.name : template.name, - nameZh: typeof inner.nameZh === 'string' && inner.nameZh.length > 0 ? inner.nameZh : template.nameZh, - healHp: template.kind === 'hp' && statHeal !== undefined ? statHeal : template.healHp, - } + return null } export function isAutoBeltablePotion(item: unknown): boolean { @@ -175,8 +207,35 @@ export class BeltModel { /** Currently hovered slot `(row, col)`. */ hoveredSlot: { row: number; col: number } | null = null + private _equippedBeltCode: string | null = null + private _hasEquippedBelt = false + + get maxRows(): number { + if (!this._hasEquippedBelt) return 4 + return getBeltRowsForCode(this._equippedBeltCode) + } + + setEquippedBelt(beltItemOrCode: unknown | string | null): void { + this._hasEquippedBelt = true + if (beltItemOrCode === null || beltItemOrCode === undefined) { + this._equippedBeltCode = null + return + } + if (typeof beltItemOrCode === 'string') { + this._equippedBeltCode = beltItemOrCode.trim().toLowerCase() + } else { + const item = beltItemOrCode as Record + const code = item.code ?? item.base?.id ?? item.baseId ?? item.id + this._equippedBeltCode = typeof code === 'string' ? code.trim().toLowerCase() : null + } + } + get isExpanded(): boolean { - return this.lockedOpen || this.hovered + return (this.lockedOpen || this.hovered) && this.maxRows > 1 + } + + get activeRows(): number { + return this.isExpanded ? this.maxRows : 1 } togglePopup(): void { @@ -187,7 +246,7 @@ export class BeltModel { * Drink the potion in column `col` (0..3) at `row` (default 0) and shift upper rows down. */ useSlot(col: number, row = 0): BeltPotion | null { - if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return null + if (col < 0 || col >= BELT_COLS || row < 0 || row >= this.maxRows) return null const consumed = this.grid[row]?.[col] ?? null if (!consumed) return null for (let r = row; r < BELT_ROWS - 1; r++) { @@ -202,12 +261,13 @@ export class BeltModel { */ placePotion(potion: BeltPotion, col: number, row?: number): BeltPotion | null { if (col < 0 || col >= BELT_COLS) return potion - if (row !== undefined && row >= 0 && row < BELT_ROWS) { + if (row !== undefined) { + if (row < 0 || row >= this.maxRows) return potion const prev = this.grid[row]![col] ?? null this.grid[row]![col] = potion return prev } - for (let r = 0; r < BELT_ROWS; r++) { + for (let r = 0; r < this.maxRows; r++) { if (this.grid[r]![col] === null) { this.grid[r]![col] = potion return null @@ -242,22 +302,13 @@ export class BeltModel { * Set or clear a belt slot by `(col, row)` (`0..3`, `0..3`). */ setSlot(col: number, row: number, item: BeltPotion | unknown | null): boolean { - if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return false + if (col < 0 || col >= BELT_COLS || row < 0 || row >= this.maxRows) return false if (item === null || item === undefined) { this.grid[row]![col] = null return true } - const potion = itemToBeltPotion(item) ?? ({ - id: String((item as any)?.id ?? `pot-${col}-${row}`), - code: (item as any)?.code ?? 'rvl', - name: (item as any)?.name ?? 'Potion', - nameZh: (item as any)?.nameZh ?? (item as any)?.name ?? '药剂', - invFile: (item as any)?.invFile ?? 'invpotl', - kind: 'rejuv', - color: '#a855f7', - healHp: (item as any)?.healHp ?? 0, - healMana: (item as any)?.healMana ?? 0, - } as BeltPotion) + const potion = itemToBeltPotion(item) + if (!potion) return false this.grid[row]![col] = potion return true } @@ -277,11 +328,11 @@ export class BeltModel { const potion = itemToBeltPotion(potionOrItem) if (!potion) return null - // Pass 1: Match existing potion family in row 0, fill lowest empty upper row (1..BELT_ROWS-1) + // Pass 1: Match existing potion family in row 0, fill lowest empty upper row (1..maxRows-1) for (let col = 0; col < BELT_COLS; col++) { const basePot = this.grid[0]![col] if (basePot !== null && areBeltPotionsCompatible(basePot, potion)) { - for (let row = 1; row < BELT_ROWS; row++) { + for (let row = 1; row < this.maxRows; row++) { if (this.grid[row]![col] === null) { this.grid[row]![col] = potion return { row, col, potion } @@ -326,16 +377,15 @@ export class BeltModel { } slotAtPoint(logicalX: number, logicalY: number): { row: number; col: number } | null { - const visibleRows = this.isExpanded ? BELT_ROWS : 1 + const visibleRows = this.isExpanded ? this.maxRows : 1 for (let row = 0; row < visibleRows; row++) { - const sy = BELT_BASE_Y - row * BELT_PITCH for (let col = 0; col < BELT_COLS; col++) { - const sx = BELT_BASE_X + col * BELT_PITCH + const box = getBeltSlotBox(col, row) if ( - logicalX >= sx && - logicalX <= sx + BELT_CELL_SIZE && - logicalY >= sy && - logicalY <= sy + BELT_CELL_SIZE + logicalX >= box.x && + logicalX <= box.x + box.width && + logicalY >= box.y && + logicalY <= box.y + box.height ) { return { row, col } } @@ -345,13 +395,15 @@ export class BeltModel { } handleMouseMove(logicalX: number, logicalY: number): void { - const topY = this.isExpanded ? BELT_BASE_Y - (BELT_ROWS - 1) * BELT_PITCH - 4 : BELT_BASE_Y - 2 + const visibleRows = this.maxRows + const topY = this.isExpanded + ? BELT_BASE_Y - (visibleRows - 1) * BELT_PITCH_Y - 4 + : BELT_BASE_Y - 2 this.hovered = logicalX >= BELT_BASE_X - 4 && - logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH + 4 && + logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH_X + 4 && logicalY >= topY && logicalY <= BELT_BASE_Y + BELT_CELL_SIZE + 4 this.hoveredSlot = this.slotAtPoint(logicalX, logicalY) } } - diff --git a/src/common/skills/formulas.ts b/src/common/skills/formulas.ts index a109e74..5eafe99 100644 --- a/src/common/skills/formulas.ts +++ b/src/common/skills/formulas.ts @@ -1811,110 +1811,101 @@ export function calculateAttack( } } +/** + * Authentic Diablo II 1.13c Experience.txt raw table data and parser. + * Sourced directly from Expansion 1.13c data/global/excel/Experience.txt. + */ +export const EXPERIENCE_TXT_RAW = "Level\tAmazon\tSorceress\tNecromancer\tPaladin\tBarbarian\tDruid\tAssassin\tExpRatio\nMaxLvl\t99\t99\t99\t99\t99\t99\t99\t10\n0\t0\t0\t0\t0\t0\t0\t0\t1024\n1\t500\t500\t500\t500\t500\t500\t500\t1024\n2\t1500\t1500\t1500\t1500\t1500\t1500\t1500\t1024\n3\t3750\t3750\t3750\t3750\t3750\t3750\t3750\t1024\n4\t7875\t7875\t7875\t7875\t7875\t7875\t7875\t1024\n5\t14175\t14175\t14175\t14175\t14175\t14175\t14175\t1024\n6\t22680\t22680\t22680\t22680\t22680\t22680\t22680\t1024\n7\t32886\t32886\t32886\t32886\t32886\t32886\t32886\t1024\n8\t44396\t44396\t44396\t44396\t44396\t44396\t44396\t1024\n9\t57715\t57715\t57715\t57715\t57715\t57715\t57715\t1024\n10\t72144\t72144\t72144\t72144\t72144\t72144\t72144\t1024\n11\t90180\t90180\t90180\t90180\t90180\t90180\t90180\t1024\n12\t112725\t112725\t112725\t112725\t112725\t112725\t112725\t1024\n13\t140906\t140906\t140906\t140906\t140906\t140906\t140906\t1024\n14\t176132\t176132\t176132\t176132\t176132\t176132\t176132\t1024\n15\t220165\t220165\t220165\t220165\t220165\t220165\t220165\t1024\n16\t275207\t275207\t275207\t275207\t275207\t275207\t275207\t1024\n17\t344008\t344008\t344008\t344008\t344008\t344008\t344008\t1024\n18\t430010\t430010\t430010\t430010\t430010\t430010\t430010\t1024\n19\t537513\t537513\t537513\t537513\t537513\t537513\t537513\t1024\n20\t671891\t671891\t671891\t671891\t671891\t671891\t671891\t1024\n21\t839864\t839864\t839864\t839864\t839864\t839864\t839864\t1024\n22\t1049830\t1049830\t1049830\t1049830\t1049830\t1049830\t1049830\t1024\n23\t1312287\t1312287\t1312287\t1312287\t1312287\t1312287\t1312287\t1024\n24\t1640359\t1640359\t1640359\t1640359\t1640359\t1640359\t1640359\t1024\n25\t2050449\t2050449\t2050449\t2050449\t2050449\t2050449\t2050449\t1024\n26\t2563061\t2563061\t2563061\t2563061\t2563061\t2563061\t2563061\t1024\n27\t3203826\t3203826\t3203826\t3203826\t3203826\t3203826\t3203826\t1024\n28\t3902260\t3902260\t3902260\t3902260\t3902260\t3902260\t3902260\t1024\n29\t4663553\t4663553\t4663553\t4663553\t4663553\t4663553\t4663553\t1024\n30\t5493363\t5493363\t5493363\t5493363\t5493363\t5493363\t5493363\t1024\n31\t6397855\t6397855\t6397855\t6397855\t6397855\t6397855\t6397855\t1024\n32\t7383752\t7383752\t7383752\t7383752\t7383752\t7383752\t7383752\t1024\n33\t8458379\t8458379\t8458379\t8458379\t8458379\t8458379\t8458379\t1024\n34\t9629723\t9629723\t9629723\t9629723\t9629723\t9629723\t9629723\t1024\n35\t10906488\t10906488\t10906488\t10906488\t10906488\t10906488\t10906488\t1024\n36\t12298162\t12298162\t12298162\t12298162\t12298162\t12298162\t12298162\t1024\n37\t13815086\t13815086\t13815086\t13815086\t13815086\t13815086\t13815086\t1024\n38\t15468534\t15468534\t15468534\t15468534\t15468534\t15468534\t15468534\t1024\n39\t17270791\t17270791\t17270791\t17270791\t17270791\t17270791\t17270791\t1024\n40\t19235252\t19235252\t19235252\t19235252\t19235252\t19235252\t19235252\t1024\n41\t21376515\t21376515\t21376515\t21376515\t21376515\t21376515\t21376515\t1024\n42\t23710491\t23710491\t23710491\t23710491\t23710491\t23710491\t23710491\t1024\n43\t26254525\t26254525\t26254525\t26254525\t26254525\t26254525\t26254525\t1024\n44\t29027522\t29027522\t29027522\t29027522\t29027522\t29027522\t29027522\t1024\n45\t32050088\t32050088\t32050088\t32050088\t32050088\t32050088\t32050088\t1024\n46\t35344686\t35344686\t35344686\t35344686\t35344686\t35344686\t35344686\t1024\n47\t38935798\t38935798\t38935798\t38935798\t38935798\t38935798\t38935798\t1024\n48\t42850109\t42850109\t42850109\t42850109\t42850109\t42850109\t42850109\t1024\n49\t47116709\t47116709\t47116709\t47116709\t47116709\t47116709\t47116709\t1024\n50\t51767302\t51767302\t51767302\t51767302\t51767302\t51767302\t51767302\t1024\n51\t56836449\t56836449\t56836449\t56836449\t56836449\t56836449\t56836449\t1024\n52\t62361819\t62361819\t62361819\t62361819\t62361819\t62361819\t62361819\t1024\n53\t68384473\t68384473\t68384473\t68384473\t68384473\t68384473\t68384473\t1024\n54\t74949165\t74949165\t74949165\t74949165\t74949165\t74949165\t74949165\t1024\n55\t82104680\t82104680\t82104680\t82104680\t82104680\t82104680\t82104680\t1024\n56\t89904191\t89904191\t89904191\t89904191\t89904191\t89904191\t89904191\t1024\n57\t98405658\t98405658\t98405658\t98405658\t98405658\t98405658\t98405658\t1024\n58\t107672256\t107672256\t107672256\t107672256\t107672256\t107672256\t107672256\t1024\n59\t117772849\t117772849\t117772849\t117772849\t117772849\t117772849\t117772849\t1024\n60\t128782495\t128782495\t128782495\t128782495\t128782495\t128782495\t128782495\t1024\n61\t140783010\t140783010\t140783010\t140783010\t140783010\t140783010\t140783010\t1024\n62\t153863570\t153863570\t153863570\t153863570\t153863570\t153863570\t153863570\t1024\n63\t168121381\t168121381\t168121381\t168121381\t168121381\t168121381\t168121381\t1024\n64\t183662396\t183662396\t183662396\t183662396\t183662396\t183662396\t183662396\t1024\n65\t200602101\t200602101\t200602101\t200602101\t200602101\t200602101\t200602101\t1024\n66\t219066380\t219066380\t219066380\t219066380\t219066380\t219066380\t219066380\t1024\n67\t239192444\t239192444\t239192444\t239192444\t239192444\t239192444\t239192444\t1024\n68\t261129853\t261129853\t261129853\t261129853\t261129853\t261129853\t261129853\t1024\n69\t285041630\t285041630\t285041630\t285041630\t285041630\t285041630\t285041630\t1024\n70\t311105466\t311105466\t311105466\t311105466\t311105466\t311105466\t311105466\t976\n71\t339515048\t339515048\t339515048\t339515048\t339515048\t339515048\t339515048\t928\n72\t370481492\t370481492\t370481492\t370481492\t370481492\t370481492\t370481492\t880\n73\t404234916\t404234916\t404234916\t404234916\t404234916\t404234916\t404234916\t832\n74\t441026148\t441026148\t441026148\t441026148\t441026148\t441026148\t441026148\t784\n75\t481128591\t481128591\t481128591\t481128591\t481128591\t481128591\t481128591\t736\n76\t524840254\t524840254\t524840254\t524840254\t524840254\t524840254\t524840254\t688\n77\t572485967\t572485967\t572485967\t572485967\t572485967\t572485967\t572485967\t640\n78\t624419793\t624419793\t624419793\t624419793\t624419793\t624419793\t624419793\t592\n79\t681027665\t681027665\t681027665\t681027665\t681027665\t681027665\t681027665\t544\n80\t742730244\t742730244\t742730244\t742730244\t742730244\t742730244\t742730244\t496\n81\t809986056\t809986056\t809986056\t809986056\t809986056\t809986056\t809986056\t448\n82\t883294891\t883294891\t883294891\t883294891\t883294891\t883294891\t883294891\t400\n83\t963201521\t963201521\t963201521\t963201521\t963201521\t963201521\t963201521\t352\n84\t1050299747\t1050299747\t1050299747\t1050299747\t1050299747\t1050299747\t1050299747\t304\n85\t1145236814\t1145236814\t1145236814\t1145236814\t1145236814\t1145236814\t1145236814\t256\n86\t1248718217\t1248718217\t1248718217\t1248718217\t1248718217\t1248718217\t1248718217\t192\n87\t1361512946\t1361512946\t1361512946\t1361512946\t1361512946\t1361512946\t1361512946\t144\n88\t1484459201\t1484459201\t1484459201\t1484459201\t1484459201\t1484459201\t1484459201\t108\n89\t1618470619\t1618470619\t1618470619\t1618470619\t1618470619\t1618470619\t1618470619\t81\n90\t1764543065\t1764543065\t1764543065\t1764543065\t1764543065\t1764543065\t1764543065\t61\n91\t1923762030\t1923762030\t1923762030\t1923762030\t1923762030\t1923762030\t1923762030\t46\n92\t2097310703\t2097310703\t2097310703\t2097310703\t2097310703\t2097310703\t2097310703\t35\n93\t2286478756\t2286478756\t2286478756\t2286478756\t2286478756\t2286478756\t2286478756\t26\n94\t2492671933\t2492671933\t2492671933\t2492671933\t2492671933\t2492671933\t2492671933\t20\n95\t2717422497\t2717422497\t2717422497\t2717422497\t2717422497\t2717422497\t2717422497\t15\n96\t2962400612\t2962400612\t2962400612\t2962400612\t2962400612\t2962400612\t2962400612\t11\n97\t3229426756\t3229426756\t3229426756\t3229426756\t3229426756\t3229426756\t3229426756\t8\n98\t3520485254\t3520485254\t3520485254\t3520485254\t3520485254\t3520485254\t3520485254\t6\n99\t3837739017\t3837739017\t3837739017\t3837739017\t3837739017\t3837739017\t3837739017\t5" + +export interface ExperienceTableParseResult { + maxLevel: number + tableByClass: Record + expRatios: number[] +} + +export function parseExperienceTable(rawTxt: string): ExperienceTableParseResult { + const lines = rawTxt.split(/\r?\n/).filter((l) => l.trim().length > 0) + if (lines.length < 2) { + return { maxLevel: 99, tableByClass: {}, expRatios: [] } + } + const header = lines[0]!.split("\t") + const classCols: Record = {} + let expRatioCol = -1 + for (let c = 1; c < header.length; c++) { + const col = header[c]!.trim().toLowerCase() + if (col === "expratio") { + expRatioCol = c + } else if (col) { + classCols[col] = c + } + } + + let maxLevel = 99 + const tableByClass: Record = {} + for (const cls of Object.keys(classCols)) { + tableByClass[cls] = new Array(100).fill(0) + } + const expRatios: number[] = new Array(100).fill(1024) + + for (let i = 1; i < lines.length; i++) { + const parts = lines[i]!.split("\t") + const lvlStr = parts[0]!.trim() + if (lvlStr === "MaxLvl") { + const parsedMax = parseInt(parts[1] ?? "99", 10) + if (!Number.isNaN(parsedMax)) maxLevel = parsedMax + continue + } + const lvlNum = parseInt(lvlStr, 10) + if (Number.isNaN(lvlNum)) continue + // In Experience.txt: + // Row "0" corresponds to XP threshold to reach Level 1 (0) + // Row "1" corresponds to XP threshold to reach Level 2 (500) + // Row "L" corresponds to XP threshold to reach Level L + 1 + const targetLevel = lvlNum + 1 + if (targetLevel >= 1 && targetLevel <= 99) { + for (const [cls, colIdx] of Object.entries(classCols)) { + tableByClass[cls]![targetLevel] = parseInt(parts[colIdx] ?? "0", 10) || 0 + } + if (expRatioCol !== -1) { + expRatios[targetLevel] = parseInt(parts[expRatioCol] ?? "1024", 10) || 1024 + } + } + } + + return { maxLevel, tableByClass, expRatios } +} + +export const PARSED_EXPERIENCE_TABLE = parseExperienceTable(EXPERIENCE_TXT_RAW) +export const EXPERIENCE_TABLE_BY_CLASS = PARSED_EXPERIENCE_TABLE.tableByClass + /** * Canonical Diablo II 1.13c Experience Progression Table (Levels 1 to 99). * * Index corresponds to character level (Index 1 = Level 1). + * Level 1 = 0, Level 2 = 500, Level 17 = 275207, Level 30 = 4663553, Level 90 = 1618470619, Level 99 = 3520485254. */ -export const D2_EXPERIENCE_TABLE_99: readonly number[] = [ - 0, // Index 0 (unused) - 0, // Level 1 - 500, // Level 2 - 1500, // Level 3 - 3750, // Level 4 - 7875, // Level 5 - 14175, // Level 6 - 22680, // Level 7 - 32886, // Level 8 - 44396, // Level 9 - 57715, // Level 10 - 72144, // Level 11 - 90180, // Level 12 - 112725, // Level 13 - 140906, // Level 14 - 176132, // Level 15 - 220165, // Level 16 - 275206, // Level 17 - 344008, // Level 18 - 430010, // Level 19 - 537512, // Level 20 - 671890, // Level 21 - 839862, // Level 22 - 1049828, // Level 23 - 1312285, // Level 24 - 1640356, // Level 25 - 2050445, // Level 26 - 2563056, // Level 27 - 3203820, // Level 28 - 3902664, // Level 29 - 4663904, // Level 30 - 5493655, // Level 31 - 6398084, // Level 32 - 7383912, // Level 33 - 8458464, // Level 34 - 9629726, // Level 35 - 10906401, // Level 36 - 12297977, // Level 37 - 13814795, // Level 38 - 15468127, // Level 39 - 17270258, // Level 40 - 19234581, // Level 41 - 21375693, // Level 42 - 23709505, // Level 43 - 26253360, // Level 44 - 29026162, // Level 45 - 32048517, // Level 46 - 35342884, // Level 47 - 38933744, // Level 48 - 42847781, // Level 49 - 47114081, // Level 50 - 51764348, // Level 51 - 56833139, // Level 52 - 62358122, // Level 53 - 68380353, // Level 54 - 74944585, // Level 55 - 82099598, // Level 56 - 89898562, // Level 57 - 98399433, // Level 58 - 107665382, // Level 59 - 117765266, // Level 60 - 128774140, // Level 61 - 140773813, // Level 62 - 153853456, // Level 63 - 168110267, // Level 64 - 183650191, // Level 65 - 200588708, // Level 66 - 219051692, // Level 67 - 239176344, // Level 68 - 261112215, // Level 69 - 285022314, // Level 70 - 311084322, // Level 71 - 339511911, // Level 72 - 370507983, // Level 73 - 404293701, // Level 74 - 441110134, // Level 75 - 481240046, // Level 76 - 524981650, // Level 77 - 572660000, // Level 78 - 624629400, // Level 79 - 681276046, // Level 80 - 743020890, // Level 81 - 810322770, // Level 82 - 883681819, // Level 83 - 963643183, // Level 84 - 1050801070, // Level 85 - 1145803166, // Level 86 - 1249355451, // Level 87 - 1362227442, // Level 88 - 1485257912, // Level 89 - 1619361124, // Level 90 - 1765533625, // Level 91 - 1924861651, // Level 92 - 2098529199, // Level 93 - 2287826827, // Level 94 - 2494161241, // Level 95 - 2719065753, // Level 96 - 2964211671, // Level 97 - 3231420721, // Level 98 - 3522678586, // Level 99 -] +export const D2_EXPERIENCE_TABLE_99: readonly number[] = + EXPERIENCE_TABLE_BY_CLASS["amazon"] ?? new Array(100).fill(0) + +export function getExperienceTable(charClass?: string): readonly number[] { + if (charClass) { + const key = charClass.trim().toLowerCase() + if (key in EXPERIENCE_TABLE_BY_CLASS) { + return EXPERIENCE_TABLE_BY_CLASS[key]! + } + } + return D2_EXPERIENCE_TABLE_99 +} + +export function getExperienceForLevel(level: number, charClass?: string): number { + if (level <= 1) return 0 + const table = getExperienceTable(charClass) + if (level >= table.length) return table[table.length - 1] ?? 0 + return table[level] ?? 0 +} + +export function getNextLevelExperience(level: number, charClass?: string): number { + if (level <= 0) return getExperienceForLevel(2, charClass) + if (level >= 99) return getExperienceForLevel(99, charClass) + return getExperienceForLevel(level + 1, charClass) +} diff --git a/tests/client/core-hud-parity.test.ts b/tests/client/core-hud-parity.test.ts new file mode 100644 index 0000000..415e4c7 --- /dev/null +++ b/tests/client/core-hud-parity.test.ts @@ -0,0 +1,305 @@ +/** + * Parity Test Suite for Core HUD, Mini-Panel, Belts, Death Screen & Experience Table. + * Covers Issues #636, #637, #638, #639, #640, #641. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + ControlBarHud, + MULTIPLAYER_MINIPANEL_ITEMS, + SINGLEPLAYER_MINIPANEL_ITEMS, + getMiniPanelTooltip, + MINIPANEL_BOUNDS, + MINIPANEL_SINGLE_BOUNDS, +} from '../../src/client/ui/control-bar.ts' +import { + BeltModel, + BELT_POTION_CATALOG, + BELT_CODE_TO_ROWS, + getBeltRowsForCode, + getBeltSlotBox, + itemToBeltPotion, + areBeltPotionsCompatible, + BELT_BASE_X, + BELT_BASE_Y, + BELT_PITCH_X, + BELT_PITCH_Y, + BELT_CELL_SIZE, +} from '../../src/common/items/belt-model.ts' +import { + D2_EXPERIENCE_TABLE_99, + getExperienceForLevel, + getNextLevelExperience, + EXPERIENCE_TABLE_BY_CLASS, + parseExperienceTable, + EXPERIENCE_TXT_RAW, +} from '../../src/common/skills/formulas.ts' + +describe('Core HUD Parity Suite (Issues #636–#641)', () => { + describe('Issue #636: Mini-Panel 8-Button Layout & Authentic Parity', () => { + it('defines 8 authentic buttons for multiplayer and 7 for single-player', () => { + expect(MULTIPLAYER_MINIPANEL_ITEMS.length).toBe(8) + expect(SINGLEPLAYER_MINIPANEL_ITEMS.length).toBe(7) + + const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action) + expect(mpActions).toEqual(['char', 'inv', 'skill', 'party', 'automap', 'msg', 'quest', 'menu']) + + const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action) + expect(spActions).toEqual(['char', 'inv', 'skill', 'automap', 'msg', 'quest', 'menu']) + + // Waypoint / stash must be eliminated + expect(mpActions).not.toContain('waypoint') + expect(spActions).not.toContain('waypoint') + }) + + it('indexes icon frames as iconGroup * 2 + (isPressed ? 1 : 0)', () => { + for (const item of MULTIPLAYER_MINIPANEL_ITEMS) { + expect(typeof item.iconGroup).toBe('number') + const normalFrame = item.iconGroup * 2 + const pressedFrame = item.iconGroup * 2 + 1 + expect(pressedFrame).toBe(normalFrame + 1) + } + // Party iconGroup is 3 + const party = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'party')! + expect(party.iconGroup).toBe(3) + // Game Menu iconGroup is 7 + const menu = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')! + expect(menu.iconGroup).toBe(7) + }) + + it('formats tooltips with canonical .tbl keys and minipanelspecial hotkey format', () => { + const charItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'char')! + expect(getMiniPanelTooltip(charItem, 'ENG')).toBe('Character (A)') + expect(getMiniPanelTooltip(charItem, 'CHI')).toBe('人物(A)') + + const invItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'inv')! + expect(getMiniPanelTooltip(invItem, 'ENG')).toBe('Inventory (I)') + expect(getMiniPanelTooltip(invItem, 'CHI')).toBe('物品(I)') + + // Game Menu already has (Esc) in its tbl string, does not double-append + const menuItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')! + expect(getMiniPanelTooltip(menuItem, 'ENG')).toBe('Game Menu (Esc)') + expect(getMiniPanelTooltip(menuItem, 'CHI')).toBe('遊戲選單(Esc)') + }) + + it('handles mouse clicks and hover correctly across 20px button pitch', () => { + const hud = new ControlBarHud() + hud.miniPanelOpen = true + hud.isMultiplayer = true + + const onMiniPanelAction = vi.fn() + const state = { + level: 1, + xp: 0, + currentLevelXp: 0, + nextLevelXp: 500, + stamina: 100, + maxStamina: 100, + isRunning: false, + unspentStatPoints: 0, + unspentSkillPoints: 0, + } + + // Button 0 (char): x = 320..339, y = 525..544 + hud.handleMouseMove(325, 530) + expect(hud.hoveredElement).toBe('char') + hud.handleClick(325, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction }) + expect(onMiniPanelAction).toHaveBeenCalledWith('char') + + // Button 3 (party): x = 380..399 + hud.handleMouseMove(385, 530) + expect(hud.hoveredElement).toBe('party') + hud.handleClick(385, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction }) + expect(onMiniPanelAction).toHaveBeenCalledWith('party') + + // Button 7 (menu): x = 460..479 + hud.handleMouseMove(465, 530) + expect(hud.hoveredElement).toBe('menu') + hud.handleClick(465, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction }) + expect(onMiniPanelAction).toHaveBeenCalledWith('menu') + }) + }) + + describe('Issue #637: Authentic Death Presentation Parity', () => { + it('calculates exact 1.13c centered screen offsets (W/2, H/2 - 94) and (W/2, H/2 - 46)', () => { + const W = 800 + const H = 600 + const centerX = Math.round(W / 2) + const centerY = Math.round(H / 2) + + const yHardcore = centerY - 94 // 300 - 94 = 206 + const yInst = centerY - 46 // 300 - 46 = 254 + + expect(centerX).toBe(400) + expect(yHardcore).toBe(206) + expect(yInst).toBe(254) + }) + }) + + describe('Issue #638: Belt Slot Grid Indexing & Left-Click Interaction', () => { + it('accesses belt slot using getSlot(col, row) without inverted indexing', () => { + const belt = new BeltModel() + const potion = itemToBeltPotion({ code: 'hp1', id: 'pot-hp1' })! + // Put potion in col 2, row 1 + belt.grid[1]![2] = potion + + expect(belt.getSlot(2, 1)).toBe(potion) + expect(belt.getSlot(1, 2)).toBeNull() + }) + }) + + describe('Issue #639: Experience Table & Experience.txt 1.13c Parity', () => { + it('parses raw Experience.txt correctly into 100 levels (indices 0..99)', () => { + const parsed = parseExperienceTable(EXPERIENCE_TXT_RAW) + expect(parsed.maxLevel).toBe(99) + expect(Object.keys(parsed.tableByClass).length).toBe(7) + + // All classes identical progression + for (const cls of ['amazon', 'sorceress', 'necromancer', 'paladin', 'barbarian', 'druid', 'assassin']) { + const table = parsed.tableByClass[cls]! + expect(table.length).toBe(100) + expect(table[1]).toBe(0) + expect(table[2]).toBe(500) + expect(table[17]).toBe(275207) + expect(table[30]).toBe(4663553) + expect(table[90]).toBe(1618470619) + expect(table[99]).toBe(3520485254) + } + }) + + it('getExperienceForLevel and getNextLevelExperience conform to 1.13c', () => { + expect(getExperienceForLevel(1)).toBe(0) + expect(getExperienceForLevel(2)).toBe(500) + expect(getExperienceForLevel(17)).toBe(275207) + expect(getExperienceForLevel(30)).toBe(4663553) + expect(getExperienceForLevel(90)).toBe(1618470619) + expect(getExperienceForLevel(99)).toBe(3520485254) + + expect(getNextLevelExperience(1)).toBe(500) + expect(getNextLevelExperience(16)).toBe(275207) + expect(getNextLevelExperience(29)).toBe(4663553) + expect(getNextLevelExperience(89)).toBe(1618470619) + expect(getNextLevelExperience(98)).toBe(3520485254) + expect(getNextLevelExperience(99)).toBe(3520485254) + }) + }) + + describe('Issue #640: Belts.txt Dynamic Rows & Coordinate Parity', () => { + it('conforms to 1.13c belts.txt coordinates (base x=423, base y=562, pitch x=31, pitch y=32, box=29x29)', () => { + expect(BELT_BASE_X).toBe(423) + expect(BELT_BASE_Y).toBe(562) + expect(BELT_PITCH_X).toBe(31) + expect(BELT_PITCH_Y).toBe(32) + expect(BELT_CELL_SIZE).toBe(29) + + // Row 0 box + const box0 = getBeltSlotBox(0, 0) + expect(box0).toEqual({ x: 423, y: 562, width: 29, height: 29 }) + + // Row 1 box + const box1 = getBeltSlotBox(0, 1) + expect(box1).toEqual({ x: 423, y: 530, width: 29, height: 29 }) + + // Row 3 box (top row of 4x4 belt) + const box3 = getBeltSlotBox(3, 3) + expect(box3).toEqual({ x: 423 + 3 * 31, y: 562 - 3 * 32, width: 29, height: 29 }) + }) + + it('dynamically adapts maxRows according to equipped belt code', () => { + const belt = new BeltModel() + + // Default (no equipped belt): 1 row (4 boxes) + belt.setEquippedBelt(null) + expect(belt.maxRows).toBe(1) + expect(getBeltRowsForCode(null)).toBe(1) + + // Sash (lbl): 2 rows (8 boxes) + belt.setEquippedBelt('lbl') + expect(belt.maxRows).toBe(2) + + // Light Belt (vbl): 2 rows (8 boxes) + belt.setEquippedBelt('vbl') + expect(belt.maxRows).toBe(2) + + // Belt (mbl): 3 rows (12 boxes) + belt.setEquippedBelt('mbl') + expect(belt.maxRows).toBe(3) + + // Heavy Belt (tbl): 3 rows (12 boxes) + belt.setEquippedBelt('tbl') + expect(belt.maxRows).toBe(3) + + // Girdle (hbl): 4 rows (16 boxes) + belt.setEquippedBelt('hbl') + expect(belt.maxRows).toBe(4) + + // Exceptional & Elite belts: 4 rows + for (const code of ['zlb', 'zvb', 'zmb', 'ztb', 'zhb', 'ulc', 'uvc', 'umc', 'utc', 'uhc']) { + belt.setEquippedBelt(code) + expect(belt.maxRows).toBe(4) + } + }) + + it('slotAtPoint hit tests only visible rows according to maxRows', () => { + const belt = new BeltModel() + belt.setEquippedBelt('lbl') // 2 rows + belt.lockedOpen = true // expanded to maxRows (2) + + // Row 0 col 0 + expect(belt.slotAtPoint(425, 565)).toEqual({ row: 0, col: 0 }) + // Row 1 col 0 + expect(belt.slotAtPoint(425, 535)).toEqual({ row: 1, col: 0 }) + // Row 2 col 0 (outside 2-row belt) + expect(belt.slotAtPoint(425, 500)).toBeNull() + }) + }) + + describe('Issue #641: Belt Potion Assets & Scroll Eligibility', () => { + it('maps authentic invFile names per 1.13c misc.txt', () => { + expect(BELT_POTION_CATALOG['vps']?.invFile).toBe('invwps') // Stamina + expect(BELT_POTION_CATALOG['yps']?.invFile).toBe('invnps') // Antidote + expect(BELT_POTION_CATALOG['wms']?.invFile).toBe('invyps') // Thawing + + expect(BELT_POTION_CATALOG['rvs']?.invFile).toBe('invvps') // Rejuv + expect(BELT_POTION_CATALOG['rvl']?.invFile).toBe('invvpl') // Full Rejuv + + expect(BELT_POTION_CATALOG['tsc']?.invFile).toBe('invbsc') // Town Portal Scroll + expect(BELT_POTION_CATALOG['isc']?.invFile).toBe('invrsc') // Identify Scroll + }) + + it('allows scrolls (tsc/isc) to be placed into belt slots', () => { + const tsc = itemToBeltPotion({ code: 'tsc', id: 'scroll-tp' }) + expect(tsc).not.toBeNull() + expect(tsc?.kind).toBe('scroll') + expect(tsc?.invFile).toBe('invbsc') + + const isc = itemToBeltPotion({ code: 'isc', id: 'scroll-id' }) + expect(isc).not.toBeNull() + expect(isc?.kind).toBe('scroll') + expect(isc?.invFile).toBe('invrsc') + + const belt = new BeltModel() + expect(belt.setSlot(0, 0, tsc)).toBe(true) + expect(belt.getSlot(0, 0)?.code).toBe('tsc') + }) + + it('rejects weapon throwing potions and invalid non-beltable items', () => { + for (const throwPot of ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops']) { + expect(itemToBeltPotion({ code: throwPot })).toBeNull() + } + expect(itemToBeltPotion({ code: 'axe' })).toBeNull() + expect(itemToBeltPotion({ code: 'key' })).toBeNull() + }) + + it('requires scrolls to have identical code for column compatibility', () => { + const tsc1 = itemToBeltPotion({ code: 'tsc' })! + const tsc2 = itemToBeltPotion({ code: 'tsc' })! + const isc = itemToBeltPotion({ code: 'isc' })! + const hp = itemToBeltPotion({ code: 'hp1' })! + + expect(areBeltPotionsCompatible(tsc1, tsc2)).toBe(true) + expect(areBeltPotionsCompatible(tsc1, isc)).toBe(false) + expect(areBeltPotionsCompatible(tsc1, hp)).toBe(false) + }) + }) +}) diff --git a/tests/client/hud-server-authority.test.ts b/tests/client/hud-server-authority.test.ts index 4ad36a7..8f26992 100644 --- a/tests/client/hud-server-authority.test.ts +++ b/tests/client/hud-server-authority.test.ts @@ -512,6 +512,9 @@ describe('client hardcode audit — server-authoritative HUD', () => { const { world, hud, model, commands } = setup() assignSelf(world, 1) const ctx = { + save: vi.fn(), + restore: vi.fn(), + drawImage: vi.fn(), fillRect: vi.fn(), fillText: vi.fn(), } as any @@ -520,11 +523,14 @@ describe('client hardcode audit — server-authoritative HUD', () => { model.syncFromWorld() expect(hud.isDead).toBe(true) + const fontSpy = vi.spyOn(hud.font, 'drawText') hud.render() - expect(ctx.fillText).toHaveBeenCalledWith( - expect.stringContaining('You Have Died'), + expect(fontSpy).toHaveBeenCalledWith( + ctx, + 'YOU HAVE DIED', 400, - 300, + expect.any(Number), + expect.objectContaining({ color: 'red' }), ) hud.handleClick(400, 300) diff --git a/tests/formulas.test.ts b/tests/formulas.test.ts index 096ca70..fe1b0cc 100644 --- a/tests/formulas.test.ts +++ b/tests/formulas.test.ts @@ -21,6 +21,11 @@ import { checkHitRecovery, calculateAttack, D2_EXPERIENCE_TABLE_99, + getExperienceForLevel, + getNextLevelExperience, + getExperienceTable, + parseExperienceTable, + EXPERIENCE_TABLE_BY_CLASS, } from '../src/common/skills/formulas.ts' describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => { @@ -435,18 +440,19 @@ describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => { }) }) - describe('D2_EXPERIENCE_TABLE_99', () => { + describe('D2_EXPERIENCE_TABLE_99 and Experience.txt Parity (Issue #639)', () => { it('contains exact 100 elements (indices 0 to 99)', () => { expect(D2_EXPERIENCE_TABLE_99.length).toBe(100) }) - it('matches known progression thresholds', () => { + it('matches authentic 1.13c progression thresholds across all classes', () => { expect(D2_EXPERIENCE_TABLE_99[1]).toBe(0) expect(D2_EXPERIENCE_TABLE_99[2]).toBe(500) expect(D2_EXPERIENCE_TABLE_99[10]).toBe(57715) - expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663904) - expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1619361124) - expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3522678586) + expect(D2_EXPERIENCE_TABLE_99[17]).toBe(275207) + expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663553) + expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1618470619) + expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3520485254) }) it('is strictly monotonic increasing from level 1 to 99', () => { @@ -454,6 +460,30 @@ describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => { expect(D2_EXPERIENCE_TABLE_99[i]!).toBeGreaterThan(D2_EXPERIENCE_TABLE_99[i - 1]!) } }) + + it('provides getExperienceForLevel and getNextLevelExperience helper parity', () => { + expect(getExperienceForLevel(1)).toBe(0) + expect(getExperienceForLevel(2)).toBe(500) + expect(getExperienceForLevel(17)).toBe(275207) + expect(getExperienceForLevel(30)).toBe(4663553) + expect(getExperienceForLevel(90)).toBe(1618470619) + expect(getExperienceForLevel(99)).toBe(3520485254) + + expect(getNextLevelExperience(1)).toBe(500) + expect(getNextLevelExperience(16)).toBe(275207) + expect(getNextLevelExperience(29)).toBe(4663553) + expect(getNextLevelExperience(89)).toBe(1618470619) + expect(getNextLevelExperience(98)).toBe(3520485254) + expect(getNextLevelExperience(99)).toBe(3520485254) + + // Test across specific classes + const classes = ['amazon', 'sorceress', 'necromancer', 'paladin', 'barbarian', 'druid', 'assassin'] + for (const cls of classes) { + expect(getExperienceForLevel(30, cls)).toBe(4663553) + expect(getExperienceForLevel(99, cls)).toBe(3520485254) + expect(EXPERIENCE_TABLE_BY_CLASS[cls]?.[99]).toBe(3520485254) + } + }) }) describe('PvE Depth Mechanics (Issue #39)', () => { diff --git a/tests/m4-visual-belt-net-parity.test.ts b/tests/m4-visual-belt-net-parity.test.ts index f9144d0..7f4e105 100644 --- a/tests/m4-visual-belt-net-parity.test.ts +++ b/tests/m4-visual-belt-net-parity.test.ts @@ -112,7 +112,7 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay belt.clear() // Verify catalog & compatibility rules - expect(Object.keys(BELT_POTION_CATALOG).length).toBe(15) + expect(Object.keys(BELT_POTION_CATALOG).length).toBe(21) expect(isAutoBeltablePotion({ code: 'hp3' })).toBe(true) expect(isAutoBeltablePotion({ code: 'rvs' })).toBe(true) expect(isAutoBeltablePotion({ code: 'vps' })).toBe(true) diff --git a/tests/ui-hud.test.ts b/tests/ui-hud.test.ts index e90896c..41a0ada 100644 --- a/tests/ui-hud.test.ts +++ b/tests/ui-hud.test.ts @@ -193,8 +193,8 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => { expect(belt.grid[1]![0]?.code).toBe('vps') // Antidote does not match Stamina in col 0, so Pass 2 places it in empty col 1 row 0 expect(belt.autoPlacePotionSlot({ code: 'yps', name: 'Antidote Potion' })).toMatchObject({ col: 1, row: 0 }) - // Non-beltable items (scrolls, equipment, throwing potions) return false - expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(false) + // Beltable scrolls ('tsc', 'isc' per 1.13c Misc.txt belt=1) return true; non-beltable throwing potions return false + expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(true) expect(belt.autoPlacePotion({ code: 'gpm', name: 'Rancid Gas Potion' })).toBe(false) })