Fix mini-panel 8-button layout, death DC6, belt indexing/tables, and experience table (Closes #636, #637, #638, #639, #640, #641)

This commit is contained in:
troytt 2026-10-02 17:07:52 +00:00
parent 2d5c23a55f
commit f1ca3dfc2b
14 changed files with 783 additions and 274 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/ui/death-inst.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -3,8 +3,8 @@
*
* Implements:
* - 4 columns × 4 rows (16 slots) potion belt storage
* - Bottom row (slots 1..4) always visible in the 800x600 control panel at x=423, y=561
* - Hover or `~` key toggles full 4×4 pop-up rows (`ctrlpnl_popbelt.dc6`, 125×32 per row)
* - Bottom row (slots 1..4) always visible in the 800x600 control panel at x=423, y=562
* - Hover or `~` key toggles full pop-up rows (`ctrlpnl_popbelt.dc6`, 125×32 per row) based on equipped belt rows
* - Pressing `1`–`4` or right-clicking drinks the bottom potion in that column and automatically
* gravity-shifts upper rows down (`row 1 -> 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)
}
}

View File

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

View File

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

View File

@ -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<Record<string, number>> = {
// 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<Record<string, Omit<BeltPotion, 'id'>>> = {
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<Record<string, Omit<BeltPotion, 'id'>
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<string, any>
// 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<string, any>) : 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<string, any>
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)
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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