Fix bilingual test fixture, font Node fetch resilience, NPC talk packet duplicate, and Act 5 hcIdx alignment (Batch 3 Gate Remediation)
This commit is contained in:
parent
c6c2eb2007
commit
8b619c23c3
|
|
@ -1681,6 +1681,10 @@ export class HudModel implements HudCommandSink {
|
|||
this.identifyAllAtNpc(resolvedNpcId)
|
||||
break
|
||||
}
|
||||
case 'respec': {
|
||||
this.npcEntityAction(12, resolvedNpcId, 0)
|
||||
break
|
||||
}
|
||||
case 'hire': {
|
||||
this.npcEntityAction(3, resolvedNpcId, 0)
|
||||
;(this.world.npcInteraction as any).window = 'hire'
|
||||
|
|
|
|||
|
|
@ -658,32 +658,46 @@ export class D2FontRenderer {
|
|||
loaded = false
|
||||
|
||||
async load(baseUrl = '/ui/fonts'): Promise<void> {
|
||||
const res = await fetch(`${baseUrl}/metrics.json`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load font metrics from ${baseUrl}/metrics.json: HTTP ${res.status}`)
|
||||
if (typeof window === 'undefined' && typeof location === 'undefined' && !baseUrl.includes('://')) {
|
||||
return
|
||||
}
|
||||
this.metas = (await res.json()) as Record<string, FontAtlasMeta>
|
||||
const names = Object.keys(this.metas)
|
||||
if (names.length === 0) {
|
||||
throw new Error(`Font metrics at ${baseUrl}/metrics.json contained no font definitions`)
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/metrics.json`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load font metrics from ${baseUrl}/metrics.json: HTTP ${res.status}`)
|
||||
}
|
||||
this.metas = (await res.json()) as Record<string, FontAtlasMeta>
|
||||
const names = Object.keys(this.metas)
|
||||
if (names.length === 0) {
|
||||
throw new Error(`Font metrics at ${baseUrl}/metrics.json contained no font definitions`)
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(
|
||||
name =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
if (typeof Image === 'undefined') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.images.set(name, img)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load font image: ${baseUrl}/${name}.png`))
|
||||
}
|
||||
img.src = `${baseUrl}/${name}.png`
|
||||
}),
|
||||
),
|
||||
)
|
||||
this.loaded = this.images.size > 0
|
||||
} catch (err) {
|
||||
if (typeof window === 'undefined' && typeof location === 'undefined') {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
await Promise.all(
|
||||
names.map(
|
||||
name =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.images.set(name, img)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load font image: ${baseUrl}/${name}.png`))
|
||||
}
|
||||
img.src = `${baseUrl}/${name}.png`
|
||||
}),
|
||||
),
|
||||
)
|
||||
this.loaded = this.images.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -965,7 +965,11 @@ export class HudManager {
|
|||
this.hotkeys.preloadSkillIcons()
|
||||
this.skillTree.preloadAssets()
|
||||
|
||||
await this.font.load(`${baseUrl}/fonts`)
|
||||
try {
|
||||
await this.font.load(`${baseUrl}/fonts`)
|
||||
} catch (err) {
|
||||
console.warn(`[HudManager] Failed to load font metrics from ${baseUrl}/fonts:`, err)
|
||||
}
|
||||
|
||||
try {
|
||||
const mRes = await fetch(`${baseUrl}/manifest.json`)
|
||||
|
|
|
|||
|
|
@ -1478,7 +1478,9 @@ export class WorldPanelsHud {
|
|||
if (!this._speechOpenedDuringAction) {
|
||||
this.openNpcSpeech(desc)
|
||||
}
|
||||
this.onQuestMessage?.(npcUnitId, 0)
|
||||
if (!hadOnNpcMenuAction) {
|
||||
this.onQuestMessage?.(npcUnitId, 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -1544,7 +1546,9 @@ export class WorldPanelsHud {
|
|||
|
||||
if (opt.id === 'respec') {
|
||||
this._npcMenu = null
|
||||
this.onNpcEntityAction?.(0x0c, npcUnitId, 0)
|
||||
if (!hadOnNpcMenuAction) {
|
||||
this.onNpcEntityAction?.(0x0c, npcUnitId, 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1515,6 +1515,14 @@ export const MONSTER_HCIDX_TO_ID: Readonly<Record<number, string>> = {
|
|||
733: "willowisp8",
|
||||
}
|
||||
|
||||
export const MONSTER_ID_TO_HCIDX: Readonly<Record<string, number>> = (() => {
|
||||
const rec: Record<string, number> = {}
|
||||
for (const [hcIdxStr, id] of Object.entries(MONSTER_HCIDX_TO_ID)) {
|
||||
rec[id] = Number(hcIdxStr)
|
||||
}
|
||||
return Object.freeze(rec)
|
||||
})()
|
||||
|
||||
/**
|
||||
* Canonical 1.13c `MonStats.txt` `[Velocity, Run]` pairs indexed by `hcIdx` (0..733),
|
||||
* extracted directly from `Patch_D2.mpq` (`data/global/excel/MonStats.txt`).
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
canRepair: false,
|
||||
canGamble: false,
|
||||
canHeal: false,
|
||||
canIdentify: false,
|
||||
canIdentify: true,
|
||||
greetingLines: [
|
||||
'Stay awhile and listen!',
|
||||
'Bring me any unidentified items you have found, and I shall reveal their hidden properties.',
|
||||
|
|
@ -445,7 +445,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
},
|
||||
{
|
||||
hcIdx: 265,
|
||||
npcTxtKey: 'cain6',
|
||||
npcTxtKey: 'cain5',
|
||||
displayName: 'Deckard Cain',
|
||||
actIndex: 0,
|
||||
vendorId: VendorNpcId.CAIN,
|
||||
|
|
@ -857,8 +857,8 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
hcIdx: 521,
|
||||
npcTxtKey: 'cain5',
|
||||
hcIdx: 520,
|
||||
npcTxtKey: 'cain6',
|
||||
displayName: 'Deckard Cain',
|
||||
actIndex: 4,
|
||||
vendorId: VendorNpcId.CAIN,
|
||||
|
|
@ -1226,7 +1226,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
hcIdx: 522,
|
||||
hcIdx: 521,
|
||||
npcTxtKey: 'tyrael3',
|
||||
displayName: 'Tyrael',
|
||||
actIndex: 4,
|
||||
|
|
@ -1244,7 +1244,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
hcIdx: 528,
|
||||
hcIdx: 527,
|
||||
npcTxtKey: 'drehyaiced',
|
||||
displayName: 'Anya',
|
||||
actIndex: 4,
|
||||
|
|
@ -1298,7 +1298,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
hcIdx: 538,
|
||||
hcIdx: 537,
|
||||
npcTxtKey: 'ancientstatue1',
|
||||
displayName: 'Ancient Statue 1',
|
||||
actIndex: 4,
|
||||
|
|
@ -1316,7 +1316,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
hcIdx: 539,
|
||||
hcIdx: 538,
|
||||
npcTxtKey: 'ancientstatue2',
|
||||
displayName: 'Ancient Statue 2',
|
||||
actIndex: 4,
|
||||
|
|
@ -1334,7 +1334,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
hcIdx: 540,
|
||||
hcIdx: 539,
|
||||
npcTxtKey: 'ancientstatue3',
|
||||
displayName: 'Ancient Statue 3',
|
||||
actIndex: 4,
|
||||
|
|
@ -1376,7 +1376,7 @@ const HCIDX_ALIASES: Readonly<Record<number, string>> = {
|
|||
258: 'meshif2',
|
||||
259: 'hadriel',
|
||||
516: 'qual-kehk',
|
||||
520: 'cain5',
|
||||
520: 'cain6',
|
||||
534: 'cain1',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,840 @@
|
|||
/**
|
||||
* Empirical Verification & Adversarial Stress Tests:
|
||||
* Batch 3 Parity Gate (Issue #642, #643, #646, #650, #651)
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Font parsing and metric calculations (src/client/ui/font.ts):
|
||||
* - Narrow characters (', |, i, l, !, .) retain true widths (2-3px) without synthetic min-width 4 clamping.
|
||||
* - Chinese DC6 typography metrics (lineMultiplier 14, lineAdvance 14, baselineConstant 2).
|
||||
* - PL2 13-color shift matrices and text formatting codes (ÿc< = #00c800, ÿc4 = gold, all 13 colors).
|
||||
* - Drop shadow removal (shadow defaults to false, zero unrequested black outline artifacts).
|
||||
* 2. Cursor state machine (src/client/ui/cursor.ts):
|
||||
* - Discrete state transitions: POINTING -> OPENING -> OPEN_IDLE -> RELEASE -> POINTING, and CLICK_DOWN.
|
||||
* - Authentic 5000ms idle timer countdown with 8.8 fixed-point counters and rewind on movement.
|
||||
* - Fail-fast on missing cursor artwork (zero canvas solid rectangles, reports missing art).
|
||||
* 3. Adversarial Fuzzing & Differential Oracles:
|
||||
* - 1,000-iteration random string parsing & width oracle.
|
||||
* - 1,000-step cursor event & timeline stress simulator.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
D2FontRenderer,
|
||||
D2_COLOR_HEX,
|
||||
D2_PL2_TEXT_COLORS,
|
||||
D2_COLOR_INDEX_TO_NAME,
|
||||
CODE_CHAR_TO_COLOR,
|
||||
D2_FONT_METRICS_CONSTANTS,
|
||||
computeFontMetrics113c,
|
||||
parseFontTblMetrics,
|
||||
parseColoredText,
|
||||
FONT_BASELINE_OFFSET,
|
||||
FONT_PX_SIZE,
|
||||
type D2ColorCode,
|
||||
type D2FontName,
|
||||
type FontAtlasMeta,
|
||||
} from '../src/client/ui/font.ts'
|
||||
import {
|
||||
CursorController,
|
||||
type CursorState,
|
||||
BUYSELL_CURSOR_FRAMES,
|
||||
} from '../src/client/ui/cursor.ts'
|
||||
import {
|
||||
CURSOR_METADATA,
|
||||
type CursorAtlasMetadata,
|
||||
} from '../src/common/items/cursor-meta.ts'
|
||||
import {
|
||||
getMissingArtEntries,
|
||||
clearMissingArtEntries,
|
||||
} from '../src/client/scene/world-renderer.ts'
|
||||
|
||||
describe('Challenger M3: Font & Cursor Adversarial Stress Verification', () => {
|
||||
const metricsJsonPath = join(process.cwd(), 'public', 'ui', 'fonts', 'metrics.json')
|
||||
let metricsData: Record<string, FontAtlasMeta>
|
||||
|
||||
beforeEach(() => {
|
||||
clearMissingArtEntries()
|
||||
if (existsSync(metricsJsonPath)) {
|
||||
metricsData = JSON.parse(readFileSync(metricsJsonPath, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Narrow Characters & True Glyph Widths (Zero 4px Clamping)
|
||||
// =========================================================================
|
||||
describe('1. Font Metrics: Narrow Characters & Anti-Clamping', () => {
|
||||
it('preserves true narrow glyph widths (2px - 3px) across font atlases without min-width 4 clamping', () => {
|
||||
expect(metricsData).toBeDefined()
|
||||
|
||||
// font6 narrow characters
|
||||
const f6 = metricsData.font6!
|
||||
expect(f6).toBeDefined()
|
||||
expect(f6.glyphs[39]!.width).toBe(2) // "'" -> true width 2px
|
||||
expect(f6.glyphs[124]!.width).toBe(2) // "|" -> true width 2px
|
||||
expect(f6.glyphs[46]!.width).toBe(3) // "." -> true width 3px
|
||||
expect(f6.glyphs[44]!.width).toBe(3) // "," -> true width 3px
|
||||
expect(f6.glyphs[58]!.width).toBe(3) // ":" -> true width 3px
|
||||
expect(f6.glyphs[59]!.width).toBe(3) // ";" -> true width 3px
|
||||
expect(f6.glyphs[96]!.width).toBe(3) // "`" -> true width 3px
|
||||
|
||||
// font8 narrow characters
|
||||
const f8 = metricsData.font8!
|
||||
expect(f8.glyphs[39]!.width).toBe(3) // "'" -> true width 3px
|
||||
|
||||
// font16 narrow characters
|
||||
const f16 = metricsData.font16!
|
||||
expect(f16.glyphs[124]!.width).toBe(3) // "|" -> true width 3px
|
||||
|
||||
// fontexocet10 narrow characters
|
||||
const fExo = metricsData.fontexocet10!
|
||||
expect(fExo.glyphs[124]!.width).toBe(3) // "|" -> true width 3px
|
||||
|
||||
// fontformal12 narrow characters
|
||||
const fFormal = metricsData.fontformal12!
|
||||
expect(fFormal.glyphs[39]!.width).toBe(3) // "'" -> true width 3px
|
||||
})
|
||||
|
||||
it('computes exact un-clamped string measurement in measureText for narrow character sequences', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
|
||||
renderer.setFontAtlas('font8', metricsData.font8!, mockAtlas)
|
||||
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
|
||||
|
||||
// Single characters
|
||||
expect(renderer.measureText("'", 'font6')).toBe(2)
|
||||
expect(renderer.measureText('|', 'font6')).toBe(2)
|
||||
expect(renderer.measureText('.', 'font6')).toBe(3)
|
||||
expect(renderer.measureText("'", 'font8')).toBe(3)
|
||||
expect(renderer.measureText('|', 'font16')).toBe(3)
|
||||
|
||||
// Repetitive sequences: if clamped to 4, "'".repeat(10) would be 40px instead of 20px
|
||||
expect(renderer.measureText("'".repeat(10), 'font6')).toBe(20)
|
||||
expect(renderer.measureText('|'.repeat(10), 'font6')).toBe(20)
|
||||
expect(renderer.measureText('.'.repeat(10), 'font6')).toBe(30)
|
||||
expect(renderer.measureText("'".repeat(10), 'font8')).toBe(30)
|
||||
expect(renderer.measureText('|'.repeat(10), 'font16')).toBe(30)
|
||||
|
||||
// Mixed narrow sequence: "'" (2) + "|" (2) + "." (3) + ":" (3) = 10px in font6
|
||||
expect(renderer.measureText("'|.:", 'font6')).toBe(10)
|
||||
})
|
||||
|
||||
it('advances drawText cursor strictly by true glyph width without synthetic 4px jump', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
|
||||
|
||||
const drawCalls: Array<{ sx: number; dx: number; dw: number }> = []
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn((t: string) => ({ width: t.length * 5 })),
|
||||
fillText: vi.fn(),
|
||||
drawImage: vi.fn((_img, sx, _sy, sw, _sh, dx, _dy, dw, _dh) => {
|
||||
drawCalls.push({ sx, dx, dw })
|
||||
}),
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Draw 4 single quotes in font6 starting at x = 100
|
||||
renderer.drawText(mockCtx, "''''", 100, 50, { font: 'font6', shadow: false })
|
||||
|
||||
// Exactly 4 drawImage calls (one per glyph, no shadow)
|
||||
expect(drawCalls).toHaveLength(4)
|
||||
expect(drawCalls[0]!.dx).toBe(100)
|
||||
expect(drawCalls[1]!.dx).toBe(102) // 100 + 2
|
||||
expect(drawCalls[2]!.dx).toBe(104) // 102 + 2
|
||||
expect(drawCalls[3]!.dx).toBe(106) // 104 + 2
|
||||
|
||||
// Verify the advance difference between each glyph is strictly 2px, NEVER 4px
|
||||
for (let i = 1; i < drawCalls.length; i++) {
|
||||
const step = drawCalls[i]!.dx - drawCalls[i - 1]!.dx
|
||||
expect(step).toBe(2)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses raw font tbl binaries preserving narrow widths 1..3 with zero clamping or fallbacks', () => {
|
||||
// Construct a valid Woo!\x01 3596-byte font tbl buffer with narrow widths
|
||||
const buf = new Uint8Array(12 + 256 * 14)
|
||||
// Magic: 'Woo!\x01'
|
||||
buf[0] = 0x57 // 'W'
|
||||
buf[1] = 0x6f // 'o'
|
||||
buf[2] = 0x6f // 'o'
|
||||
buf[3] = 0x21 // '!'
|
||||
buf[4] = 0x01
|
||||
buf[10] = 12 // nHeight
|
||||
buf[11] = 8 // nWidth
|
||||
|
||||
// Character 39 ("'"): width = 1, height = 5
|
||||
const off39 = 12 + 39 * 14
|
||||
buf[off39 + 3] = 1
|
||||
buf[off39 + 4] = 5
|
||||
|
||||
// Character 124 ("|"): width = 2, height = 8
|
||||
const off124 = 12 + 124 * 14
|
||||
buf[off124 + 3] = 2
|
||||
buf[off124 + 4] = 8
|
||||
|
||||
// Character 46 ("."): width = 3, height = 3
|
||||
const off46 = 12 + 46 * 14
|
||||
buf[off46 + 3] = 3
|
||||
buf[off46 + 4] = 3
|
||||
|
||||
const parsed = parseFontTblMetrics(buf, 'ENG')
|
||||
expect(parsed.widths[39]).toBe(1)
|
||||
expect(parsed.widths[124]).toBe(2)
|
||||
expect(parsed.widths[46]).toBe(3)
|
||||
expect(parsed.lineHeight).toBe(12)
|
||||
expect(parsed.capHeight).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Chinese DC6 Typography Metrics (14, 14, 2)
|
||||
// =========================================================================
|
||||
describe('2. Chinese DC6 Typography Metrics (1.13c D2WinFont.cpp:119-124)', () => {
|
||||
it('defines authentic 1.13c constants for Latin (16/16/3) and Chinese (14/14/2)', () => {
|
||||
expect(D2_FONT_METRICS_CONSTANTS.latin).toEqual({
|
||||
lineMultiplier: 16,
|
||||
lineAdvance: 16,
|
||||
baselineConstant: 3,
|
||||
})
|
||||
expect(D2_FONT_METRICS_CONSTANTS.chi).toEqual({
|
||||
lineMultiplier: 14,
|
||||
lineAdvance: 14,
|
||||
baselineConstant: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('computes 1.13c metrics across variable font heights with language-specific formulas', () => {
|
||||
// In Chinese mode:
|
||||
// lineHeight = round(14 * nHeight / 10)
|
||||
// lineAdvance = round(14 * nHeight / 10)
|
||||
// baseline = round(2 * nHeight / 10)
|
||||
const heights = [10, 12, 14, 16, 20, 24, 30]
|
||||
|
||||
for (const h of heights) {
|
||||
const chi = computeFontMetrics113c(h, 'CHI')
|
||||
expect(chi.lineHeight).toBe(Math.round((14 * h) / 10))
|
||||
expect(chi.lineAdvance).toBe(Math.round((14 * h) / 10))
|
||||
expect(chi.baseline).toBe(Math.round((2 * h) / 10))
|
||||
|
||||
const eng = computeFontMetrics113c(h, 'ENG')
|
||||
expect(eng.lineHeight).toBe(Math.round((16 * h) / 10))
|
||||
expect(eng.lineAdvance).toBe(Math.round((16 * h) / 10))
|
||||
expect(eng.baseline).toBe(Math.round((3 * h) / 10))
|
||||
}
|
||||
|
||||
// Exact evaluation for base nHeight = 10
|
||||
const chi10 = computeFontMetrics113c(10, 'CHI')
|
||||
expect(chi10).toEqual({
|
||||
lineHeight: 14,
|
||||
lineAdvance: 14,
|
||||
baseline: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('applies 14/16 baseline adjustment to drawText when lang is CHI', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 512, height: 512 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
|
||||
|
||||
const dyCalls: number[] = []
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 10 })),
|
||||
fillText: vi.fn(),
|
||||
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, _dx, dy) => {
|
||||
dyCalls.push(dy)
|
||||
}),
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const baseY = 200
|
||||
|
||||
// 1. ENG mode for font16 (baseline offset 13)
|
||||
renderer.drawText(mockCtx, 'A', 100, baseY, { font: 'font16', lang: 'ENG', shadow: false })
|
||||
expect(dyCalls[0]).toBe(baseY - FONT_BASELINE_OFFSET.font16) // 200 - 13 = 187
|
||||
|
||||
// 2. CHI mode for font16 (baseline offset = Math.round(13 * 14 / 16) = 11)
|
||||
dyCalls.length = 0
|
||||
renderer.drawText(mockCtx, 'A', 100, baseY, { font: 'font16', lang: 'CHI', shadow: false })
|
||||
const expectedChiOffset = Math.round(FONT_BASELINE_OFFSET.font16 * (14 / 16))
|
||||
expect(expectedChiOffset).toBe(11)
|
||||
expect(dyCalls[0]).toBe(baseY - expectedChiOffset) // 200 - 11 = 189
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: PL2 13-Color Shift Matrices & Formatting Codes
|
||||
// =========================================================================
|
||||
describe('3. PL2 13-Color Shift Matrices & Text Formatting Codes', () => {
|
||||
it('maps all 13 canonical color codes 0..9, :, ;, < matching D2WinFont.cpp:826-836', () => {
|
||||
expect(D2_COLOR_INDEX_TO_NAME).toHaveLength(13)
|
||||
expect(D2_COLOR_INDEX_TO_NAME).toEqual([
|
||||
'white', // 0
|
||||
'red', // 1
|
||||
'green', // 2
|
||||
'blue', // 3
|
||||
'gold', // 4
|
||||
'gray', // 5
|
||||
'black', // 6
|
||||
'tan', // 7
|
||||
'orange', // 8
|
||||
'yellow', // 9
|
||||
'darkgreen', // 10 (':')
|
||||
'purple', // 11 (';')
|
||||
'brightgreen', // 12 ('<')
|
||||
])
|
||||
|
||||
expect(CODE_CHAR_TO_COLOR['<']).toBe('brightgreen')
|
||||
expect(CODE_CHAR_TO_COLOR['4']).toBe('gold')
|
||||
expect(CODE_CHAR_TO_COLOR[':']).toBe('darkgreen')
|
||||
expect(CODE_CHAR_TO_COLOR[';']).toBe('purple')
|
||||
})
|
||||
|
||||
it('defines authentic RGB colors in D2_PL2_TEXT_COLORS and D2_COLOR_HEX', () => {
|
||||
// ÿc< = #00c800 (brightgreen)
|
||||
expect(D2_PL2_TEXT_COLORS.brightgreen).toBe('#00c800')
|
||||
expect(D2_COLOR_HEX.brightgreen).toBe('#00c800')
|
||||
|
||||
// ÿc4 = gold (#c7b377 in pal.pl2, #c8a15a in D2_COLOR_HEX for UI compatibility)
|
||||
expect(D2_PL2_TEXT_COLORS.gold).toBe('#c7b377')
|
||||
expect(D2_COLOR_HEX.gold).toBe('#c8a15a')
|
||||
|
||||
// Verify all 13 colors exist in both maps
|
||||
for (const colorName of D2_COLOR_INDEX_TO_NAME) {
|
||||
expect(D2_PL2_TEXT_COLORS[colorName]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(D2_COLOR_HEX[colorName]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses formatted strings with ÿc< and ÿc4 into colored spans', () => {
|
||||
const text = 'ÿc4Gold Item ÿc<Set Item'
|
||||
const spans = parseColoredText(text)
|
||||
expect(spans).toEqual([
|
||||
{ text: 'Gold Item ', color: 'gold' },
|
||||
{ text: 'Set Item', color: 'brightgreen' },
|
||||
])
|
||||
|
||||
// Test with \xffc prefix as well
|
||||
const hexPrefix = '\xffc<Bright Green\xffc4Gold'
|
||||
const spansHex = parseColoredText(hexPrefix)
|
||||
expect(spansHex).toEqual([
|
||||
{ text: 'Bright Green', color: 'brightgreen' },
|
||||
{ text: 'Gold', color: 'gold' },
|
||||
])
|
||||
})
|
||||
|
||||
it('resets color to white on out-of-range codes (>= 13) per D2WinFont.cpp:832', () => {
|
||||
// In D2WinFont.cpp:832:
|
||||
// v11 = v14 - '0';
|
||||
// if (v11 >= '\r') v11 = 0; ('\r' = 13)
|
||||
// Character '=' has ASCII 61 -> 61 - 48 = 13 -> resets to 0 ('white')
|
||||
const text = 'ÿc4Gold ÿc=InvalidCode'
|
||||
const spans = parseColoredText(text)
|
||||
expect(spans).toEqual([
|
||||
{ text: 'Gold ', color: 'gold' },
|
||||
{ text: 'InvalidCode', color: 'white' },
|
||||
])
|
||||
|
||||
// Character '>' has ASCII 62 -> 62 - 48 = 14 -> resets to 0 ('white')
|
||||
const text2 = 'ÿc4Gold ÿc>InvalidCode'
|
||||
const spans2 = parseColoredText(text2)
|
||||
expect(spans2).toEqual([
|
||||
{ text: 'Gold ', color: 'gold' },
|
||||
{ text: 'InvalidCode', color: 'white' },
|
||||
])
|
||||
})
|
||||
|
||||
it('handles all 13 codes sequentially in a single stress string', () => {
|
||||
let testStr = ''
|
||||
const chars = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', ';', '<']
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
testStr += `ÿc${chars[i]}T${i}`
|
||||
}
|
||||
|
||||
const spans = parseColoredText(testStr)
|
||||
expect(spans).toHaveLength(13)
|
||||
for (let i = 0; i < 13; i++) {
|
||||
expect(spans[i]!.color).toBe(D2_COLOR_INDEX_TO_NAME[i])
|
||||
expect(spans[i]!.text).toBe(`T${i}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Drop Shadow Removal & Integrity
|
||||
// =========================================================================
|
||||
describe('4. Drop Shadow Removal: Zero Unrequested Black Outline Artifacts', () => {
|
||||
it('defaults shadow to false in drawText (no unrequested shadow pass)', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
|
||||
|
||||
const drawImageCalls: Array<{ dx: number; dy: number }> = []
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 10 })),
|
||||
fillText: vi.fn(),
|
||||
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, dx, dy) => {
|
||||
drawImageCalls.push({ dx, dy })
|
||||
}),
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// When options.shadow is omitted entirely
|
||||
renderer.drawText(mockCtx, 'A', 100, 100, { font: 'font16', color: 'gold' })
|
||||
|
||||
// MUST be exactly 1 call (foreground only, ZERO shadow call at dx+1, dy+1)
|
||||
expect(drawImageCalls).toHaveLength(1)
|
||||
expect(drawImageCalls[0]!.dx).toBe(100)
|
||||
expect(drawImageCalls[0]!.dy).toBe(100 - FONT_BASELINE_OFFSET.font16)
|
||||
})
|
||||
|
||||
it('renders exactly 2 passes per glyph when shadow is explicitly set to true', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 256, height: 256 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
|
||||
|
||||
const drawImageCalls: Array<{ dx: number; dy: number }> = []
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 10 })),
|
||||
fillText: vi.fn(),
|
||||
drawImage: vi.fn((_img, _sx, _sy, _sw, _sh, dx, dy) => {
|
||||
drawImageCalls.push({ dx, dy })
|
||||
}),
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Explicit shadow: true
|
||||
renderer.drawText(mockCtx, 'A', 100, 100, { font: 'font16', color: 'gold', shadow: true })
|
||||
|
||||
// MUST be exactly 2 calls: shadow at (dx+1, dy+1), then foreground at (dx, dy)
|
||||
expect(drawImageCalls).toHaveLength(2)
|
||||
const expectedY = 100 - FONT_BASELINE_OFFSET.font16
|
||||
expect(drawImageCalls[0]).toEqual({ dx: 101, dy: expectedY + 1 })
|
||||
expect(drawImageCalls[1]).toEqual({ dx: 100, dy: expectedY })
|
||||
})
|
||||
|
||||
it('does not emit 3-point shadow outline for CJK text when shadow is false', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const fillTextCalls: Array<{ text: string; fillStyle: string; x: number; y: number }> = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn((t: string) => ({ width: t.length * 14 })),
|
||||
fillText: vi.fn(function (this: { fillStyle: string }, text, x, y) {
|
||||
fillTextCalls.push({ text, fillStyle: this.fillStyle, x, y })
|
||||
}),
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
fillStyle: '',
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Default shadow (false)
|
||||
renderer.drawText(mockCtx, '测试文本', 100, 100, { color: 'white' })
|
||||
|
||||
// Exactly 1 call with foreground color, 0 calls with black (#000000)
|
||||
expect(fillTextCalls).toHaveLength(1)
|
||||
expect(fillTextCalls[0]!.fillStyle).toBe(D2_COLOR_HEX.white)
|
||||
expect(fillTextCalls.some(c => c.fillStyle === '#000000')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Cursor State Machine Discrete Transitions & 5000ms Idle
|
||||
// =========================================================================
|
||||
describe('5. Cursor State Machine: 5000ms Idle & Fixed-Point Transitions', () => {
|
||||
it('maintains POINTING state during active mouse movements or idle < 5000ms', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
expect(cursor.fixedPointCounter).toBe(0)
|
||||
|
||||
// Active movements
|
||||
cursor.handleMouseMove(50, 50, 1000)
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
cursor.handleMouseMove(80, 90, 2500)
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
|
||||
// Idle at 4999ms from last activity (2500 + 4999 = 7499ms)
|
||||
expect(cursor.update(7499)).toBe('POINTING')
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
expect(cursor.fixedPointCounter).toBe(0)
|
||||
})
|
||||
|
||||
it('transitions to OPENING at exactly 5000ms idle and advances 8 frames in 8.8 fixed-point', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.frameDurationMs = 125
|
||||
cursor.openingFrameDurationMs = 125
|
||||
|
||||
// At exactly 5000ms idle
|
||||
expect(cursor.update(5000)).toBe('OPENING')
|
||||
expect(cursor.state).toBe('OPENING')
|
||||
|
||||
// Frame 0 at t = 5000ms: fixedPointCounter = 0
|
||||
let frame = cursor.getCurrentFrame(5000)
|
||||
expect(frame.state).toBe('OPENING')
|
||||
expect(frame.animation).toBe('ohand')
|
||||
expect(frame.frameIndex).toBe(0)
|
||||
expect(cursor.fixedPointCounter).toBe(0)
|
||||
|
||||
// Halfway through frame 0 (t = 5062.5ms): frame 0, frac ~ 128
|
||||
cursor.getCurrentFrame(5062)
|
||||
expect(cursor.fixedPointCounter >> 8).toBe(0)
|
||||
expect(cursor.fixedPointCounter & 0xff).toBeGreaterThan(120)
|
||||
|
||||
// Frame 1 at t = 5125ms: fixedPointCounter = 1 << 8 = 256
|
||||
frame = cursor.getCurrentFrame(5125)
|
||||
expect(frame.frameIndex).toBe(1)
|
||||
expect(cursor.fixedPointCounter >> 8).toBe(1)
|
||||
|
||||
// Frame 4 at t = 5500ms: fixedPointCounter = 4 << 8 = 1024
|
||||
frame = cursor.getCurrentFrame(5500)
|
||||
expect(frame.frameIndex).toBe(4)
|
||||
expect(cursor.fixedPointCounter >> 8).toBe(4)
|
||||
|
||||
// Frame 7 at t = 5875ms: fixedPointCounter = 7 << 8 = 1792
|
||||
frame = cursor.getCurrentFrame(5875)
|
||||
expect(frame.frameIndex).toBe(7)
|
||||
expect(cursor.fixedPointCounter >> 8).toBe(7)
|
||||
|
||||
// Clamped at frame 7 at t = 5999ms
|
||||
frame = cursor.getCurrentFrame(5999)
|
||||
expect(frame.state).toBe('OPENING')
|
||||
expect(frame.frameIndex).toBe(7)
|
||||
})
|
||||
|
||||
it('transitions from OPENING to OPEN_IDLE after 6000ms (5000ms idle + 1000ms opening) and loops orotate', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.frameDurationMs = 125
|
||||
cursor.openingFrameDurationMs = 125
|
||||
|
||||
// Exactly at 6000ms
|
||||
expect(cursor.update(6000)).toBe('OPEN_IDLE')
|
||||
expect(cursor.state).toBe('OPEN_IDLE')
|
||||
expect(cursor.fixedPointCounter).toBe(8 << 8)
|
||||
|
||||
// Loops orotate frames 0..7
|
||||
let frame = cursor.getCurrentFrame(6000)
|
||||
expect(frame.animation).toBe('orotate')
|
||||
expect(frame.frameIndex).toBe(0)
|
||||
|
||||
frame = cursor.getCurrentFrame(6125)
|
||||
expect(frame.frameIndex).toBe(1)
|
||||
|
||||
frame = cursor.getCurrentFrame(6875)
|
||||
expect(frame.frameIndex).toBe(7)
|
||||
|
||||
// Loops back to frame 0 at 7000ms (1000ms after OPEN_IDLE start)
|
||||
frame = cursor.getCurrentFrame(7000)
|
||||
expect(frame.animation).toBe('orotate')
|
||||
expect(frame.frameIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('rewinds to POINTING via RELEASE state upon mouse movement when open', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.update(7000)
|
||||
expect(cursor.state).toBe('OPEN_IDLE')
|
||||
|
||||
// Movement while open triggers RELEASE state with rewind counter
|
||||
cursor.handleMouseMove(200, 200, 7001)
|
||||
expect(cursor.state).toBe('RELEASE')
|
||||
expect(cursor.releaseCounter).toBe((8 << 8) - 0x100) // 1792 (7 << 8)
|
||||
|
||||
// Frame during RELEASE renders ohand in reverse
|
||||
// Calling getCurrentFrame(7001) calls update(7001), decrementing releaseCounter by 0x40: 1792 - 64 = 1728
|
||||
let frame = cursor.getCurrentFrame(7001)
|
||||
expect(frame.state).toBe('RELEASE')
|
||||
expect(frame.animation).toBe('ohand')
|
||||
expect(cursor.releaseCounter).toBe(1728)
|
||||
expect(frame.frameIndex).toBe(6) // 1728 >> 8 = 6
|
||||
|
||||
// Multiple ticks decrement releaseCounter by 0x40 each until exactly 0
|
||||
let ticks = 0
|
||||
while (cursor.state === 'RELEASE') {
|
||||
ticks++
|
||||
cursor.update(7001 + ticks)
|
||||
}
|
||||
|
||||
// Exactly 27 more ticks (28 ticks total) from 1728 to 0 (1728 / 64 = 27)
|
||||
expect(ticks).toBe(27)
|
||||
|
||||
// Reverts cleanly to POINTING
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
expect(cursor.releaseCounter).toBe(0)
|
||||
frame = cursor.getCurrentFrame(7001 + ticks)
|
||||
expect(frame.state).toBe('POINTING')
|
||||
expect(frame.animation).toBe('protate')
|
||||
})
|
||||
|
||||
it('rewinds partially opened cursor when mouse moves during OPENING state', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.openingFrameDurationMs = 125
|
||||
cursor.update(5375) // Frame 3 in OPENING
|
||||
expect(cursor.state).toBe('OPENING')
|
||||
const currentCounter = cursor.fixedPointCounter
|
||||
|
||||
// Move mouse mid-opening
|
||||
cursor.handleMouseMove(150, 150, 5376)
|
||||
expect(cursor.state).toBe('RELEASE')
|
||||
expect(cursor.releaseCounter).toBe(currentCounter)
|
||||
|
||||
// Rewinds back to POINTING
|
||||
while (cursor.state === 'RELEASE') {
|
||||
cursor.update(5377)
|
||||
}
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
})
|
||||
|
||||
it('transitions to CLICK_DOWN on mousedown and reverts to POINTING on mouseup', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.handleMouseDown(100)
|
||||
expect(cursor.state).toBe('CLICK_DOWN')
|
||||
|
||||
const frame = cursor.getCurrentFrame(100)
|
||||
expect(frame.state).toBe('CLICK_DOWN')
|
||||
expect(frame.animation).toBe('protate')
|
||||
expect(frame.frameIndex).toBe(0) // Fixed at pointing index 0
|
||||
|
||||
cursor.handleMouseUp(150)
|
||||
expect(cursor.state).toBe('POINTING')
|
||||
})
|
||||
|
||||
it('suppresses idle transitions when in tradeCursorMode (buy, sell, repair, identify)', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.setTradeCursorMode('buy', 0)
|
||||
|
||||
// Idle for 20,000ms
|
||||
expect(cursor.update(20000)).toBe('POINTING')
|
||||
const frame = cursor.getCurrentFrame(20000)
|
||||
expect(frame.animation).toBe('buysell')
|
||||
expect(frame.frameIndex).toBe(BUYSELL_CURSOR_FRAMES.buy)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Fail-Fast on Missing Cursor Artwork (Zero Solid Rectangles)
|
||||
// =========================================================================
|
||||
describe('6. Cursor Fail-Fast: Zero Solid Rectangles & Clear Missing Art Reports', () => {
|
||||
it('calls reportMissingArt and draws ZERO canvas solid rectangles when cursorAtlas is null', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.handleMouseMove(120, 140, 0)
|
||||
|
||||
const fillRectSpy = vi.fn()
|
||||
const strokeRectSpy = vi.fn()
|
||||
const drawImageSpy = vi.fn()
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillRect: fillRectSpy,
|
||||
strokeRect: strokeRectSpy,
|
||||
drawImage: drawImageSpy,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
// Render with null atlas
|
||||
cursor.render(mockCtx, null, 0)
|
||||
|
||||
// Assert ZERO canvas drawing calls (no colored boxes or fallback rectangles)
|
||||
expect(fillRectSpy).toHaveBeenCalledTimes(0)
|
||||
expect(strokeRectSpy).toHaveBeenCalledTimes(0)
|
||||
expect(drawImageSpy).toHaveBeenCalledTimes(0)
|
||||
|
||||
// Assert reportMissingArt was registered
|
||||
const missing = getMissingArtEntries()
|
||||
expect(missing.length).toBeGreaterThan(0)
|
||||
expect(missing.some(e => e.category === 'ui-cursor' && e.id === 'cursorAtlas')).toBe(true)
|
||||
})
|
||||
|
||||
it('throws explicit descriptive errors when cursor animation sequences are missing from metadata', () => {
|
||||
// Corrupt metadata with missing protate animation
|
||||
const brokenMeta = {
|
||||
...CURSOR_METADATA,
|
||||
protate: [],
|
||||
} as unknown as CursorAtlasMetadata
|
||||
|
||||
const brokenCursor = new CursorController(brokenMeta, 0)
|
||||
expect(() => brokenCursor.getCurrentFrame(0)).toThrow(
|
||||
/Missing cursor animation sequence 'protate'/,
|
||||
)
|
||||
})
|
||||
|
||||
it('throws explicit error when specific frame index is missing in buysell mode', () => {
|
||||
const brokenMeta = {
|
||||
...CURSOR_METADATA,
|
||||
buysell: [], // Missing buysell frames
|
||||
} as unknown as CursorAtlasMetadata
|
||||
|
||||
const brokenCursor = new CursorController(brokenMeta, 0)
|
||||
brokenCursor.setTradeCursorMode('buy', 0)
|
||||
expect(() => brokenCursor.getCurrentFrame(0)).toThrow(
|
||||
/Missing buysell cursor frame 3 for mode 'buy'/,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders held item centered at cursor hotspot when cursorItem is present', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
cursor.handleMouseMove(300, 200, 0)
|
||||
|
||||
const drawImageSpy = vi.fn()
|
||||
const mockCtx = {
|
||||
drawImage: drawImageSpy,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockItemsAtlas = { tag: 'items-atlas' } as unknown as CanvasImageSource
|
||||
const mockCursorAtlas = { tag: 'cursor-atlas' } as unknown as CanvasImageSource
|
||||
|
||||
// Held 2x3 sword (w=28, h=84)
|
||||
cursor.render(mockCtx, mockCursorAtlas, 0, {
|
||||
atlas: mockItemsAtlas,
|
||||
rect: { x: 10, y: 20, w: 28, h: 84 },
|
||||
})
|
||||
|
||||
// Renders held item centered at (300, 200): dx = 300 - 14 = 286, dy = 200 - 42 = 158
|
||||
expect(drawImageSpy).toHaveBeenCalledTimes(1)
|
||||
expect(drawImageSpy).toHaveBeenCalledWith(
|
||||
mockItemsAtlas,
|
||||
10,
|
||||
20,
|
||||
28,
|
||||
84,
|
||||
286,
|
||||
158,
|
||||
28,
|
||||
84,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Differential Oracles & Fuzz Stress Harnesses
|
||||
// =========================================================================
|
||||
describe('7. Adversarial Fuzzing & Differential Oracles (1,000 Iterations)', () => {
|
||||
it('passes 1,000 differential fuzz tests comparing measureText against naive sum of glyph widths', () => {
|
||||
const renderer = new D2FontRenderer()
|
||||
const mockAtlas = { width: 512, height: 512 } as unknown as HTMLImageElement
|
||||
renderer.setFontAtlas('font16', metricsData.font16!, mockAtlas)
|
||||
renderer.setFontAtlas('font6', metricsData.font6!, mockAtlas)
|
||||
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '|.!?:;-,_+=*&^%$#@"
|
||||
const colorCodes = ['ÿc0', 'ÿc1', 'ÿc2', 'ÿc3', 'ÿc4', 'ÿc5', 'ÿc6', 'ÿc7', 'ÿc8', 'ÿc9', 'ÿc:', 'ÿc;', 'ÿc<']
|
||||
|
||||
for (let iter = 0; iter < 1000; iter++) {
|
||||
// Construct random test string with mixed chars and color codes
|
||||
const length = 5 + (iter % 25)
|
||||
let rawStr = ''
|
||||
let strippedStr = ''
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (i % 5 === 0 && iter % 2 === 0) {
|
||||
const cc = colorCodes[(iter + i) % colorCodes.length]!
|
||||
rawStr += cc
|
||||
}
|
||||
const ch = chars[(iter * 7 + i * 13) % chars.length]!
|
||||
rawStr += ch
|
||||
strippedStr += ch
|
||||
}
|
||||
|
||||
// Font16 oracle: sum widths of stripped characters
|
||||
const f16Meta = metricsData.font16!
|
||||
let expectedF16Width = 0
|
||||
for (let i = 0; i < strippedStr.length; i++) {
|
||||
const code = strippedStr.charCodeAt(i)
|
||||
expectedF16Width += f16Meta.glyphs[code]!.width
|
||||
}
|
||||
|
||||
const measuredF16 = renderer.measureText(rawStr, 'font16')
|
||||
expect(measuredF16).toBe(expectedF16Width)
|
||||
|
||||
// Font6 oracle
|
||||
const f6Meta = metricsData.font6!
|
||||
let expectedF6Width = 0
|
||||
for (let i = 0; i < strippedStr.length; i++) {
|
||||
const code = strippedStr.charCodeAt(i)
|
||||
expectedF6Width += f6Meta.glyphs[code]!.width
|
||||
}
|
||||
|
||||
const measuredF6 = renderer.measureText(rawStr, 'font6')
|
||||
expect(measuredF6).toBe(expectedF6Width)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes 1,000-step cursor event & timeline stress simulation with zero state corruption', () => {
|
||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
||||
let simulatedTime = 0
|
||||
|
||||
for (let step = 0; step < 1000; step++) {
|
||||
const action = step % 6
|
||||
const dt = 10 + (step * 37) % 1500
|
||||
simulatedTime += dt
|
||||
|
||||
switch (action) {
|
||||
case 0:
|
||||
// Mouse move
|
||||
cursor.handleMouseMove(
|
||||
(step * 17) % 800,
|
||||
(step * 23) % 600,
|
||||
simulatedTime,
|
||||
)
|
||||
break
|
||||
case 1:
|
||||
// Mouse down
|
||||
cursor.handleMouseDown(simulatedTime)
|
||||
break
|
||||
case 2:
|
||||
// Mouse up
|
||||
cursor.handleMouseUp(simulatedTime)
|
||||
break
|
||||
case 3:
|
||||
// Trade mode toggle
|
||||
cursor.setTradeCursorMode(
|
||||
step % 12 === 0 ? 'buy' : step % 12 === 3 ? 'sell' : null,
|
||||
simulatedTime,
|
||||
)
|
||||
break
|
||||
default:
|
||||
// Regular tick update
|
||||
cursor.update(simulatedTime)
|
||||
break
|
||||
}
|
||||
|
||||
// Fetch frame info at current time
|
||||
const info = cursor.getCurrentFrame(simulatedTime)
|
||||
|
||||
// Invariants:
|
||||
// 1. Valid discrete state
|
||||
const validStates: CursorState[] = ['POINTING', 'OPENING', 'OPEN_IDLE', 'RELEASE', 'CLICK_DOWN']
|
||||
expect(validStates).toContain(info.state)
|
||||
|
||||
// 2. Valid frame indices
|
||||
if (info.animation === 'buysell') {
|
||||
expect(info.frameIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(info.frameIndex).toBeLessThan(10)
|
||||
} else {
|
||||
expect(info.frameIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(info.frameIndex).toBeLessThan(8)
|
||||
}
|
||||
|
||||
// 3. Finite, non-NaN coordinates
|
||||
expect(Number.isFinite(info.drawX)).toBe(true)
|
||||
expect(Number.isFinite(info.drawY)).toBe(true)
|
||||
expect(Number.isFinite(info.bounds.width)).toBe(true)
|
||||
expect(Number.isFinite(info.bounds.height)).toBe(true)
|
||||
expect(info.bounds.width).toBeGreaterThan(0)
|
||||
expect(info.bounds.height).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,545 @@
|
|||
/**
|
||||
* tests/challenger-m3-npc-panels-stress.test.ts
|
||||
*
|
||||
* Diablo II v1.13c Batch 3 Parity Gate — Challenger 2 Stress Suite
|
||||
*
|
||||
* Empirical verification & adversarial stress testing for:
|
||||
* 1. MonStats hcIdx alignment and town NPC automap dot discrimination.
|
||||
* 2. NPC dialogue & menu options quest prerequisite gating (Cain, Tyrael, Larzuk, Akara respec).
|
||||
* 3. Network protocol packet dispatch & double-packet regression fuzzing.
|
||||
* 4. Left-panel inventory layouts (Hireling2, Trade grids) & tooltip bounds.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WorldPanelsHud,
|
||||
buildNpcMenuOptions,
|
||||
computeNpcDialogLayout,
|
||||
type NpcMenuContext,
|
||||
type NpcMenuOption,
|
||||
} from '../src/client/ui/world-panels.ts'
|
||||
import { HudManager } from '../src/client/ui/hud-manager.ts'
|
||||
import { HudModel } from '../src/client/ui-model/hud-model.ts'
|
||||
import { ClientWorld } from '../src/client/world/client-world.ts'
|
||||
import {
|
||||
TOWN_NPC_DESCRIPTORS,
|
||||
NPC_HCIDX_TO_DESCRIPTOR_ID,
|
||||
getTownNpcDescriptorByHcIdx,
|
||||
getTownNpcDescriptorByName,
|
||||
VendorNpcId,
|
||||
} from '../src/common/units/npc-table.ts'
|
||||
import {
|
||||
MONSTER_HCIDX_TO_ID,
|
||||
MONSTER_ID_TO_HCIDX,
|
||||
} from '../src/common/units/monster-mapping.ts'
|
||||
import type { ClientCommand } from '../src/netproto/index.ts'
|
||||
|
||||
// --- Test Utilities & Environment Setup ---
|
||||
|
||||
function setupHudEnvironment() {
|
||||
const world = new ClientWorld()
|
||||
const hud = new HudManager()
|
||||
const commands: ClientCommand[] = []
|
||||
const model = new HudModel({ world, hudManager: hud, onCommand: (c) => commands.push(c) })
|
||||
return { world, hud, model, commands }
|
||||
}
|
||||
|
||||
describe('Challenger 2 Empirical Stress Test Harness — NPC Dialogue & World Panels', () => {
|
||||
|
||||
// =========================================================================
|
||||
// SUITE 1: MonStats hcIdx & Town NPC Dot Discrimination Oracle
|
||||
// =========================================================================
|
||||
describe('Suite 1: MonStats hcIdx & Town NPC Dot Discrimination Oracle', () => {
|
||||
|
||||
it('1.1 Act 5 Town NPCs and interactables must have exact hcIdx parity with MONSTER_HCIDX_TO_ID', () => {
|
||||
// In Diablo II v1.13c, MONSTER_HCIDX_TO_ID defines canonical row indices without Expansion separator.
|
||||
// npc-table.ts MUST match monster-mapping.ts exactly for all town NPCs and interactables.
|
||||
const act5ParityExpectations: Array<{ key: string; expectedHcIdx: number; name: string }> = [
|
||||
{ key: 'larzuk', expectedHcIdx: 511, name: 'Larzuk' },
|
||||
{ key: 'drehya', expectedHcIdx: 512, name: 'Drehya / Anya' },
|
||||
{ key: 'malah', expectedHcIdx: 513, name: 'Malah' },
|
||||
{ key: 'nihlathak', expectedHcIdx: 514, name: 'Nihlathak' },
|
||||
{ key: 'qual-kehk', expectedHcIdx: 515, name: 'Qual-Kehk' },
|
||||
{ key: 'cain6', expectedHcIdx: 520, name: 'Deckard Cain (Act 5)' },
|
||||
{ key: 'tyrael3', expectedHcIdx: 521, name: 'Tyrael (Act 5)' },
|
||||
{ key: 'drehyaiced', expectedHcIdx: 527, name: 'Anya Frozen in Ice' },
|
||||
{ key: 'ancientstatue1', expectedHcIdx: 537, name: 'Ancient Statue 1 (Talic)' },
|
||||
{ key: 'ancientstatue2', expectedHcIdx: 538, name: 'Ancient Statue 2 (Madawc)' },
|
||||
{ key: 'ancientstatue3', expectedHcIdx: 539, name: 'Ancient Statue 3 (Korlic)' },
|
||||
]
|
||||
|
||||
for (const exp of act5ParityExpectations) {
|
||||
const canonicalIdFromMapping = MONSTER_HCIDX_TO_ID[exp.expectedHcIdx]
|
||||
expect(
|
||||
canonicalIdFromMapping,
|
||||
`Ground truth monster-mapping at hcIdx ${exp.expectedHcIdx} must be ${exp.key}`,
|
||||
).toBe(exp.key)
|
||||
|
||||
// Verify that npc-table matches this exact hcIdx
|
||||
const descByHcIdx = getTownNpcDescriptorByHcIdx(exp.expectedHcIdx)
|
||||
expect(
|
||||
descByHcIdx,
|
||||
`getTownNpcDescriptorByHcIdx(${exp.expectedHcIdx}) must find descriptor for ${exp.key}`,
|
||||
).toBeDefined()
|
||||
|
||||
const mappedKey = NPC_HCIDX_TO_DESCRIPTOR_ID[exp.expectedHcIdx]
|
||||
expect(
|
||||
mappedKey,
|
||||
`NPC_HCIDX_TO_DESCRIPTOR_ID[${exp.expectedHcIdx}] must map to ${exp.key}`,
|
||||
).toBe(exp.key)
|
||||
}
|
||||
})
|
||||
|
||||
it('1.2 Hostile boss encounters (Ancient Barbarians 540-542) must NEVER be mapped as town NPCs', () => {
|
||||
// 540 = ancientbarb1, 541 = ancientbarb2, 542 = ancientbarb3
|
||||
expect(MONSTER_HCIDX_TO_ID[540]).toBe('ancientbarb1')
|
||||
expect(MONSTER_HCIDX_TO_ID[541]).toBe('ancientbarb2')
|
||||
expect(MONSTER_HCIDX_TO_ID[542]).toBe('ancientbarb3')
|
||||
|
||||
// Neither 540, 541, nor 542 should ever be in NPC_HCIDX_TO_DESCRIPTOR_ID
|
||||
expect(
|
||||
NPC_HCIDX_TO_DESCRIPTOR_ID.has(540),
|
||||
'ancientbarb1 (540) must NOT be registered in NPC_HCIDX_TO_DESCRIPTOR_ID',
|
||||
).toBe(false)
|
||||
expect(
|
||||
NPC_HCIDX_TO_DESCRIPTOR_ID[540],
|
||||
'ancientbarb1 (540) must NOT map to any town NPC descriptor',
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('1.3 Automap entity dot discrimination oracle: ancient statues and bosses must not emit town NPC dots', () => {
|
||||
// Simulate automap-view.ts entity dot discrimination logic
|
||||
function simulateAutomapDot(classId: number): 'npc' | 'other' {
|
||||
const isAncientStatue = classId >= 537 && classId <= 539
|
||||
const isTownNpc = Boolean(
|
||||
NPC_HCIDX_TO_DESCRIPTOR_ID.has(classId) || NPC_HCIDX_TO_DESCRIPTOR_ID[classId],
|
||||
)
|
||||
const isInteract = isTownNpc
|
||||
if (isInteract && !isAncientStatue) {
|
||||
return 'npc'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// Valid town NPCs must emit 'npc'
|
||||
expect(simulateAutomapDot(511), 'Larzuk (511) must emit npc dot').toBe('npc')
|
||||
expect(simulateAutomapDot(513), 'Malah (513) must emit npc dot').toBe('npc')
|
||||
expect(simulateAutomapDot(515), 'Qual-Kehk (515) must emit npc dot').toBe('npc')
|
||||
|
||||
// Ancient statues 537, 538, 539 must NEVER emit 'npc'
|
||||
expect(simulateAutomapDot(537), 'Ancient Statue 1 (537) must not emit npc dot').toBe('other')
|
||||
expect(simulateAutomapDot(538), 'Ancient Statue 2 (538) must not emit npc dot').toBe('other')
|
||||
expect(simulateAutomapDot(539), 'Ancient Statue 3 (539) must not emit npc dot').toBe('other')
|
||||
|
||||
// Ancient Barbarian boss 540 must NEVER emit 'npc'
|
||||
expect(simulateAutomapDot(540), 'Ancient Barbarian Boss 1 (540) must not emit npc dot').toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// SUITE 2: Quest Prerequisite Gating & NPC Menu Options
|
||||
// =========================================================================
|
||||
describe('Suite 2: Quest Prerequisite Gating & NPC Menu Options', () => {
|
||||
|
||||
it('2.1 Cain identify items gating: omit when 0 unid items, 100g when unrescued, 0g when rescued', () => {
|
||||
const cain = getTownNpcDescriptorByName('cain1')
|
||||
expect(cain).toBeDefined()
|
||||
if (!cain) return
|
||||
|
||||
// DEFECT CHECK: Deckard Cain in Act 1 must have canIdentify: true
|
||||
expect(
|
||||
cain.canIdentify,
|
||||
'cain1 (Act 1 Deckard Cain) must have canIdentify: true per Diablo II 1.13c canon',
|
||||
).toBe(true)
|
||||
|
||||
// Case A: 0 unidentified items -> 'identify' option must NOT be present
|
||||
const ctxNoUnid: NpcMenuContext = {
|
||||
hasUnidentifiedItems: false,
|
||||
unidentifiedItemCount: 0,
|
||||
questFlags: new Uint8Array(48),
|
||||
}
|
||||
const optsNoUnid = buildNpcMenuOptions(cain, ctxNoUnid)
|
||||
expect(optsNoUnid.some(o => o.id === 'identify')).toBe(false)
|
||||
|
||||
// Case B: Unidentified items present, but Search for Cain (A1Q4 = index 4) NOT complete
|
||||
const questFlagsUnrescued = new Uint8Array(48)
|
||||
questFlagsUnrescued[4] = 0 // not complete
|
||||
const ctxUnrescued: NpcMenuContext = {
|
||||
hasUnidentifiedItems: true,
|
||||
unidentifiedItemCount: 2,
|
||||
questFlags: questFlagsUnrescued,
|
||||
}
|
||||
const optsUnrescued = buildNpcMenuOptions(cain, ctxUnrescued)
|
||||
const identifyUnrescued = optsUnrescued.find(o => o.id === 'identify')
|
||||
expect(identifyUnrescued).toBeDefined()
|
||||
expect(identifyUnrescued?.strIdx).toBe(4021) // 4021 = paid identify with cost
|
||||
expect(identifyUnrescued?.labelEn).toBe('Identify Items: 200')
|
||||
|
||||
// Case C: Unidentified items present, and Search for Cain complete (A1Q4 = index 4, bit 0 set)
|
||||
const questFlagsRescued = new Uint8Array(48)
|
||||
questFlagsRescued[4] = 1 // complete
|
||||
const ctxRescued: NpcMenuContext = {
|
||||
hasUnidentifiedItems: true,
|
||||
unidentifiedItemCount: 2,
|
||||
questFlags: questFlagsRescued,
|
||||
}
|
||||
const optsRescued = buildNpcMenuOptions(cain, ctxRescued)
|
||||
const identifyRescued = optsRescued.find(o => o.id === 'identify')
|
||||
expect(identifyRescued).toBeDefined()
|
||||
expect(identifyRescued?.strIdx).toBe(4020) // 4020 = free identify
|
||||
expect(identifyRescued?.labelEn).toBe('Identify Items')
|
||||
})
|
||||
|
||||
it('2.2 Kashya hire gating: gated by Blood Raven (A1Q2) complete OR player level >= 9', () => {
|
||||
const kashya = getTownNpcDescriptorByName('kashya')
|
||||
expect(kashya).toBeDefined()
|
||||
if (!kashya) return
|
||||
|
||||
// Level 1, Blood Raven incomplete -> NO hire
|
||||
const qIncomplete = new Uint8Array(48)
|
||||
qIncomplete[2] = 0
|
||||
const optsLvl1 = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 1 })
|
||||
expect(optsLvl1.some(o => o.id === 'hire')).toBe(false)
|
||||
|
||||
// Level 1, Blood Raven complete -> HIRE available
|
||||
const qComplete = new Uint8Array(48)
|
||||
qComplete[2] = 1
|
||||
const optsLvl1Complete = buildNpcMenuOptions(kashya, { questFlags: qComplete, playerLevel: 1 })
|
||||
expect(optsLvl1Complete.some(o => o.id === 'hire')).toBe(true)
|
||||
|
||||
// Level 9, Blood Raven incomplete -> HIRE available (level override per 1.13c)
|
||||
const optsLvl9Incomplete = buildNpcMenuOptions(kashya, { questFlags: qIncomplete, playerLevel: 9 })
|
||||
expect(optsLvl9Incomplete.some(o => o.id === 'hire')).toBe(true)
|
||||
})
|
||||
|
||||
it('2.3 Qual-Kehk hire gating: gated strictly by Rescue on Mount Arreat (A5Q2 = index 36)', () => {
|
||||
const qualKehk = getTownNpcDescriptorByName('qual-kehk')
|
||||
expect(qualKehk).toBeDefined()
|
||||
if (!qualKehk) return
|
||||
|
||||
// Incomplete -> NO hire
|
||||
const qIncomplete = new Uint8Array(48)
|
||||
qIncomplete[36] = 0
|
||||
const optsIncomplete = buildNpcMenuOptions(qualKehk, { questFlags: qIncomplete, playerLevel: 90 })
|
||||
expect(optsIncomplete.some(o => o.id === 'hire')).toBe(false)
|
||||
|
||||
// Complete -> HIRE available
|
||||
const qComplete = new Uint8Array(48)
|
||||
qComplete[36] = 1
|
||||
const optsComplete = buildNpcMenuOptions(qualKehk, { questFlags: qComplete, playerLevel: 90 })
|
||||
expect(optsComplete.some(o => o.id === 'hire')).toBe(true)
|
||||
})
|
||||
|
||||
it('2.4 Act Boss Travel gating: Warriv, Meshif, and Tyrael', () => {
|
||||
const warriv1 = getTownNpcDescriptorByName('warriv1')
|
||||
const meshif1 = getTownNpcDescriptorByName('meshif1')
|
||||
const tyrael1 = getTownNpcDescriptorByName('tyrael1')
|
||||
|
||||
// Warriv east gated by Andariel (A1Q6 = index 6)
|
||||
if (warriv1) {
|
||||
const q0 = new Uint8Array(48)
|
||||
q0[6] = 0
|
||||
expect(buildNpcMenuOptions(warriv1, { questFlags: q0 }).some(o => o.id === 'travel_east')).toBe(false)
|
||||
const q1 = new Uint8Array(48)
|
||||
q1[6] = 1
|
||||
expect(buildNpcMenuOptions(warriv1, { questFlags: q1 }).some(o => o.id === 'travel_east')).toBe(true)
|
||||
}
|
||||
|
||||
// Meshif east gated by Duriel (A2Q6 = index 14)
|
||||
if (meshif1) {
|
||||
const q0 = new Uint8Array(48)
|
||||
q0[14] = 0
|
||||
expect(buildNpcMenuOptions(meshif1, { questFlags: q0 }).some(o => o.id === 'travel_east')).toBe(false)
|
||||
const q1 = new Uint8Array(48)
|
||||
q1[14] = 1
|
||||
expect(buildNpcMenuOptions(meshif1, { questFlags: q1 }).some(o => o.id === 'travel_east')).toBe(true)
|
||||
}
|
||||
|
||||
// Tyrael travel harrogath gated by Diablo (A4Q3 = index 26)
|
||||
if (tyrael1) {
|
||||
const q0 = new Uint8Array(48)
|
||||
q0[26] = 0
|
||||
expect(buildNpcMenuOptions(tyrael1, { questFlags: q0 }).some(o => o.id === 'travel_harrogath')).toBe(false)
|
||||
const q1 = new Uint8Array(48)
|
||||
q1[26] = 1
|
||||
expect(buildNpcMenuOptions(tyrael1, { questFlags: q1 }).some(o => o.id === 'travel_harrogath')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('2.5 Quest reward gating: Imbue (Charsi), Add Sockets (Larzuk), Personalize (Anya)', () => {
|
||||
const charsi = getTownNpcDescriptorByName('charsi')
|
||||
const larzuk = getTownNpcDescriptorByName('larzuk')
|
||||
const anya = getTownNpcDescriptorByName('drehya')
|
||||
|
||||
// Imbue: A1Q5 (index 5) — available when (& 2) !== 0 and (& 1) === 0
|
||||
if (charsi) {
|
||||
const qUnearned = new Uint8Array(48)
|
||||
qUnearned[5] = 0
|
||||
expect(buildNpcMenuOptions(charsi, { questFlags: qUnearned }).some(o => o.id === 'imbue')).toBe(false)
|
||||
|
||||
const qEarned = new Uint8Array(48)
|
||||
qEarned[5] = 2 // earned, not yet used
|
||||
expect(buildNpcMenuOptions(charsi, { questFlags: qEarned }).some(o => o.id === 'imbue')).toBe(true)
|
||||
|
||||
const qUsed = new Uint8Array(48)
|
||||
qUsed[5] = 3 // bit 0 set -> consumed
|
||||
expect(buildNpcMenuOptions(charsi, { questFlags: qUsed }).some(o => o.id === 'imbue')).toBe(false)
|
||||
}
|
||||
|
||||
// Add Sockets: A5Q1 (index 35) — available when (& 2) !== 0 and (& 1) === 0
|
||||
if (larzuk) {
|
||||
const qEarned = new Uint8Array(48)
|
||||
qEarned[35] = 2
|
||||
expect(buildNpcMenuOptions(larzuk, { questFlags: qEarned }).some(o => o.id === 'add_sockets')).toBe(true)
|
||||
|
||||
const qUsed = new Uint8Array(48)
|
||||
qUsed[35] = 3
|
||||
expect(buildNpcMenuOptions(larzuk, { questFlags: qUsed }).some(o => o.id === 'add_sockets')).toBe(false)
|
||||
}
|
||||
|
||||
// Personalize: A5Q3 (index 38) — available when (& 2) !== 0 and (& 1) === 0
|
||||
if (anya) {
|
||||
const qEarned = new Uint8Array(48)
|
||||
qEarned[38] = 2
|
||||
expect(buildNpcMenuOptions(anya, { questFlags: qEarned }).some(o => o.id === 'personalize')).toBe(true)
|
||||
|
||||
const qUsed = new Uint8Array(48)
|
||||
qUsed[38] = 3
|
||||
expect(buildNpcMenuOptions(anya, { questFlags: qUsed }).some(o => o.id === 'personalize')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('2.6 Akara respec action gating and HudModel handling oracle', () => {
|
||||
const akara = getTownNpcDescriptorByName('akara')
|
||||
expect(akara).toBeDefined()
|
||||
if (!akara) return
|
||||
|
||||
// Den of Evil incomplete -> no respec
|
||||
const qIncomplete = new Uint8Array(48)
|
||||
qIncomplete[1] = 0
|
||||
qIncomplete[41] = 0
|
||||
expect(buildNpcMenuOptions(akara, { questFlags: qIncomplete }).some(o => o.id === 'respec')).toBe(false)
|
||||
|
||||
// Den of Evil complete (q[1] & 1 !== 0), respec unconsumed -> respec available
|
||||
const qComplete = new Uint8Array(48)
|
||||
qComplete[1] = 1
|
||||
qComplete[41] = 0
|
||||
expect(buildNpcMenuOptions(akara, { questFlags: qComplete }).some(o => o.id === 'respec')).toBe(true)
|
||||
|
||||
// Respec consumed (q[41] & 1 !== 0) -> no respec
|
||||
const qConsumed = new Uint8Array(48)
|
||||
qConsumed[1] = 1
|
||||
qConsumed[41] = 1
|
||||
expect(buildNpcMenuOptions(akara, { questFlags: qConsumed }).some(o => o.id === 'respec')).toBe(false)
|
||||
|
||||
// CRITICAL HUDMODEL ORACLE:
|
||||
// hudModel.handleNpcMenuAction('respec') MUST handle respec by dispatching NpcEntityAction(0x0c)
|
||||
const { model, commands } = setupHudEnvironment()
|
||||
model.handleNpcMenuAction('respec', 101, akara)
|
||||
expect(
|
||||
commands.length,
|
||||
'hudModel.handleNpcMenuAction("respec") must NOT silently drop the action',
|
||||
).toBe(1)
|
||||
expect(commands[0]).toEqual({
|
||||
type: 'NpcEntityAction',
|
||||
action: 12,
|
||||
npcId: 101,
|
||||
complement: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// SUITE 3: Protocol Packet Dispatch & Double-Packet Fuzzing
|
||||
// =========================================================================
|
||||
describe('Suite 3: Protocol Packet Dispatch & Double-Packet Fuzzing', () => {
|
||||
|
||||
it('3.1 Clicking "talk" option on NPC menu must dispatch EXACTLY ONE QuestMessage packet', () => {
|
||||
const { hud, commands } = setupHudEnvironment()
|
||||
const akara = getTownNpcDescriptorByName('akara')
|
||||
expect(akara).toBeDefined()
|
||||
if (!akara) return
|
||||
|
||||
// Open Akara menu
|
||||
hud.worldPanels.openNpcMenu(akara, 400, 300, 101)
|
||||
expect(hud.worldPanels.npcMenu).toBeDefined()
|
||||
|
||||
// Record commands before click
|
||||
const initialCommandCount = commands.length
|
||||
|
||||
// Click "talk" option
|
||||
const handled = hud.worldPanels.clickNpcMenuOption('talk')
|
||||
expect(handled).toBe(true)
|
||||
|
||||
// DISPATCH INTEGRITY ASSERTION:
|
||||
// Must dispatch exactly ONE QuestMessage packet. Double-packet regression triggers server desync!
|
||||
const newCommands = commands.slice(initialCommandCount)
|
||||
expect(
|
||||
newCommands.length,
|
||||
`Expected exactly 1 packet dispatched on clicking talk, but received ${newCommands.length}: ${JSON.stringify(newCommands)}`,
|
||||
).toBe(1)
|
||||
|
||||
expect(newCommands[0]).toEqual({
|
||||
type: 'QuestMessage',
|
||||
npcId: 101,
|
||||
messageId: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('3.2 Differential fuzzing across all menu options: packet count must strictly equal 1 without duplicates', () => {
|
||||
const qFlags = new Uint8Array(48)
|
||||
// Den of evil complete (q[1]=1), respec unconsumed (q[41]=0)
|
||||
qFlags[1] = 1
|
||||
qFlags[41] = 0
|
||||
// Blood raven complete (q[2]=1)
|
||||
qFlags[2] = 1
|
||||
// Andariel complete (q[6]=1)
|
||||
qFlags[6] = 1
|
||||
|
||||
const testCases: Array<{
|
||||
npcKey: string
|
||||
npcId: number
|
||||
optionId: string
|
||||
expectedCommandType: string
|
||||
}> = [
|
||||
{ npcKey: 'akara', npcId: 101, optionId: 'respec', expectedCommandType: 'NpcEntityAction' },
|
||||
{ npcKey: 'kashya', npcId: 102, optionId: 'hire', expectedCommandType: 'NpcEntityAction' },
|
||||
{ npcKey: 'warriv1', npcId: 103, optionId: 'travel_east', expectedCommandType: 'NpcEntityAction' },
|
||||
{ npcKey: 'charsi', npcId: 104, optionId: 'trade', expectedCommandType: 'NpcEntityAction' },
|
||||
{ npcKey: 'gheed', npcId: 105, optionId: 'gamble', expectedCommandType: 'NpcEntityAction' },
|
||||
]
|
||||
|
||||
for (const tc of testCases) {
|
||||
const { hud, commands } = setupHudEnvironment()
|
||||
const desc = getTownNpcDescriptorByName(tc.npcKey)
|
||||
expect(desc).toBeDefined()
|
||||
if (!desc) continue
|
||||
|
||||
// Force open menu with options
|
||||
hud.worldPanels.openNpcMenu(desc, 400, 300, tc.npcId, {
|
||||
playerLevel: 90,
|
||||
questFlags: qFlags,
|
||||
})
|
||||
|
||||
const beforeCount = commands.length
|
||||
const handled = hud.worldPanels.clickNpcMenuOption(tc.optionId)
|
||||
expect(handled, `Option ${tc.optionId} should be handled for ${tc.npcKey}`).toBe(true)
|
||||
|
||||
const dispatched = commands.slice(beforeCount)
|
||||
expect(
|
||||
dispatched.length,
|
||||
`Option ${tc.optionId} on ${tc.npcKey} must dispatch exactly 1 command, got ${dispatched.length}`,
|
||||
).toBe(1)
|
||||
expect(dispatched[0].type).toBe(tc.expectedCommandType)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// SUITE 4: Left-Panel Inventory Layouts & Tooltip Bounds
|
||||
// =========================================================================
|
||||
describe('Suite 4: Left-Panel Inventory Layouts & Tooltip Bounds', () => {
|
||||
|
||||
it('4.1 Hireling2 equipment slots must match canonical inventory.txt coordinates and handle clicks accurately', () => {
|
||||
const { hud } = setupHudEnvironment()
|
||||
|
||||
// inventory.txt Hireling2 canonical slots:
|
||||
const canonicalSlots = [
|
||||
{ slot: 1, name: 'Head', x: 215, y: 68, w: 54, h: 51 },
|
||||
{ slot: 3, name: 'Torso', x: 213, y: 137, w: 56, h: 82 },
|
||||
{ slot: 4, name: 'Weapon', x: 100, y: 107, w: 55, h: 112 },
|
||||
{ slot: 5, name: 'Shield', x: 331, y: 107, w: 55, h: 112 },
|
||||
]
|
||||
|
||||
hud.worldPanels.activePanel = 'merc'
|
||||
|
||||
for (const s of canonicalSlots) {
|
||||
let clickedSlot: number | null = null
|
||||
const callbacks = {
|
||||
onMercItem: (slot: number) => {
|
||||
clickedSlot = slot
|
||||
},
|
||||
}
|
||||
|
||||
// Test center of slot
|
||||
const centerX = s.x + Math.floor(s.w / 2)
|
||||
const centerY = s.y + Math.floor(s.h / 2)
|
||||
const hitCenter = hud.worldPanels.handleActiveLeftPanelClick(centerX, centerY, callbacks)
|
||||
expect(hitCenter).toBe(true)
|
||||
expect(clickedSlot, `Center click on ${s.name} slot (${centerX}, ${centerY}) must target slot ${s.slot}`).toBe(s.slot)
|
||||
|
||||
// Test boundary minimum corner
|
||||
clickedSlot = null
|
||||
const hitMin = hud.worldPanels.handleActiveLeftPanelClick(s.x, s.y, callbacks)
|
||||
expect(hitMin).toBe(true)
|
||||
expect(clickedSlot, `Min corner click on ${s.name} slot (${s.x}, ${s.y}) must target slot ${s.slot}`).toBe(s.slot)
|
||||
|
||||
// Test boundary maximum corner
|
||||
clickedSlot = null
|
||||
const hitMax = hud.worldPanels.handleActiveLeftPanelClick(s.x + s.w, s.y + s.h, callbacks)
|
||||
expect(hitMax).toBe(true)
|
||||
expect(clickedSlot, `Max corner click on ${s.name} slot (${s.x + s.w}, ${s.y + s.h}) must target slot ${s.slot}`).toBe(s.slot)
|
||||
}
|
||||
})
|
||||
|
||||
it('4.2 Trade grids must match canonical inventory.txt Page 1-2 (100, 101, 290, 116) and Page 2-2 (100, 315, 290, 116)', () => {
|
||||
const { hud } = setupHudEnvironment()
|
||||
hud.worldPanels.activePanel = 'trade'
|
||||
|
||||
// Player trade grid: (100, 315, 290, 116) — 10 columns by 4 rows of 29px
|
||||
// Test all 40 cell coordinates (col 0..9, row 0..3)
|
||||
for (let col = 0; col < 10; col++) {
|
||||
for (let row = 0; row < 4; row++) {
|
||||
let receivedCol: number | null = null
|
||||
let receivedRow: number | null = null
|
||||
|
||||
const callbacks = {
|
||||
onTradeGridClick: (c: number, r: number) => {
|
||||
receivedCol = c
|
||||
receivedRow = r
|
||||
},
|
||||
}
|
||||
|
||||
const clickX = 100 + col * 29 + 14 // cell center X
|
||||
const clickY = 315 + row * 29 + 14 // cell center Y
|
||||
|
||||
const handled = hud.worldPanels.handleActiveLeftPanelClick(clickX, clickY, callbacks)
|
||||
expect(handled).toBe(true)
|
||||
expect(receivedCol, `Click at (${clickX}, ${clickY}) should map to col ${col}`).toBe(col)
|
||||
expect(receivedRow, `Click at (${clickX}, ${clickY}) should map to row ${row}`).toBe(row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('4.3 Tooltip and NPC dialog layout calculation: bounds must never produce NaN or coordinate overflow', () => {
|
||||
const options: readonly NpcMenuOption[] = [
|
||||
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'Talk' },
|
||||
{ id: 'trade', strIdx: 3396, label: '交易', labelZh: '交易', labelEn: 'Trade' },
|
||||
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'Cancel' },
|
||||
]
|
||||
|
||||
// Sweep coordinates from extremes
|
||||
const testCoordinates = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 400, y: 300 },
|
||||
{ x: 799, y: 599 },
|
||||
{ x: -100, y: -100 },
|
||||
{ x: 1000, y: 800 },
|
||||
]
|
||||
|
||||
for (const pt of testCoordinates) {
|
||||
const layout = computeNpcDialogLayout('Deckard Cain', options, pt.x, pt.y)
|
||||
expect(Number.isFinite(layout.left), 'layout.left must be finite').toBe(true)
|
||||
expect(Number.isFinite(layout.top), 'layout.top must be finite').toBe(true)
|
||||
expect(Number.isFinite(layout.width), 'layout.width must be finite').toBe(true)
|
||||
expect(Number.isFinite(layout.height), 'layout.height must be finite').toBe(true)
|
||||
expect(layout.width).toBeGreaterThan(0)
|
||||
expect(layout.height).toBeGreaterThan(0)
|
||||
|
||||
// Must respect viewport clamping bounds (left in [10, 800 - width], top in [10, 600 - height - 48])
|
||||
expect(layout.left).toBeGreaterThanOrEqual(10)
|
||||
expect(layout.top).toBeGreaterThanOrEqual(10)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -452,7 +452,6 @@ describe('Issue #503: HUD & Panel Rendering in ENG (Zero CJK) and CHI Modes', ()
|
|||
expect(textCalls.some(t => t === '角色属性 (A/C)' || t === '人物(A)')).toBe(true)
|
||||
expect(textCalls).toContain('罗格营地')
|
||||
expect(textCalls).toContain('你的私人儲藏箱') // tbl key 'bank' (CHI)
|
||||
expect(textCalls).toContain('赫拉迪克方块 (已合成 0 次)')
|
||||
|
||||
expect(textCalls).not.toContain('角色属性 (Character)')
|
||||
expect(textCalls).not.toContain('技能树 (Skill Tree)')
|
||||
|
|
|
|||
Loading…
Reference in New Issue