230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
/**
|
|
* Diablo II v1.13c Quest TBL Parity & Dynamic Resolution Tests (Issue #381).
|
|
*
|
|
* Verifies:
|
|
* 1. All 27 canonical Diablo II quests across Acts 1..5 match Blizzard's
|
|
* string.tbl / expansionstring.tbl / patchstring.tbl keys and metadata.
|
|
* 2. WorldPanelsHud dynamic TBL lookup and string table chain resolution.
|
|
* 3. Fallback resolution when TBL archives are not mounted.
|
|
* 4. Multi-stage objective directives and completion speech rendering.
|
|
* 5. Quest parchment layout bounds and text wrapping within 1.13c UI sockets.
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
CANONICAL_QUEST_DATA,
|
|
CANONICAL_QUEST_BY_ID,
|
|
CANONICAL_QUESTS_BY_ACT,
|
|
} from '../src/data/canonical-quest-data.ts'
|
|
import {
|
|
WorldPanelsHud,
|
|
ACT_QUESTS,
|
|
ALL_QUESTS,
|
|
type QuestEntry,
|
|
} from '../src/ui/world-panels.ts'
|
|
import { D2FontRenderer } from '../src/ui/font.ts'
|
|
|
|
describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
|
|
it('contains exactly 27 canonical quests with authentic Blizzard 1.13c TBL keys', () => {
|
|
expect(CANONICAL_QUEST_DATA.length).toBe(27)
|
|
expect(ALL_QUESTS.length).toBe(27)
|
|
|
|
// Act 1: 6, Act 2: 6, Act 3: 6, Act 4: 3, Act 5: 6
|
|
expect(CANONICAL_QUESTS_BY_ACT[1]?.length).toBe(6)
|
|
expect(CANONICAL_QUESTS_BY_ACT[2]?.length).toBe(6)
|
|
expect(CANONICAL_QUESTS_BY_ACT[3]?.length).toBe(6)
|
|
expect(CANONICAL_QUESTS_BY_ACT[4]?.length).toBe(3)
|
|
expect(CANONICAL_QUESTS_BY_ACT[5]?.length).toBe(6)
|
|
|
|
// Verify Blizzard 1.13c socket key ordering parity
|
|
// Act 1: Cain is socket 2 (a1q3) -> qstsa1q4; Tools of Trade is socket 4 (a1q5) -> qstsa1q3
|
|
const a1q1 = CANONICAL_QUEST_BY_ID['a1q1']!
|
|
expect(a1q1.titleKey).toBe('qstsa1q1')
|
|
expect(a1q1.objectiveKey).toBe('qstsa1q11')
|
|
expect(a1q1.completedKey).toBe('A1Q1SuccessfulAkara')
|
|
expect(a1q1.titleEn).toBe('Den of Evil')
|
|
expect(a1q1.titleZh).toBe('邪惡洞穴')
|
|
|
|
const a1q3 = CANONICAL_QUEST_BY_ID['a1q3']!
|
|
expect(a1q3.titleKey).toBe('qstsa1q4') // Blizzard swapped qstsa1q3 and qstsa1q4
|
|
expect(a1q3.titleEn).toBe('The Search for Cain')
|
|
|
|
const a1q5 = CANONICAL_QUEST_BY_ID['a1q5']!
|
|
expect(a1q5.titleKey).toBe('qstsa1q3') // Tools of the Trade
|
|
expect(a1q5.titleEn).toBe('Tools of the Trade')
|
|
|
|
// Act 3: Golden Bird (a3q1) -> qstsa3q4; Blade (a3q2) -> qstsa3q3; Khalim (a3q3) -> qstsa3q2; Lam Esen (a3q4) -> qstsa3q1
|
|
const a3q1 = CANONICAL_QUEST_BY_ID['a3q1']!
|
|
expect(a3q1.titleKey).toBe('qstsa3q4')
|
|
expect(a3q1.titleEn).toBe('The Golden Bird')
|
|
|
|
const a3q4 = CANONICAL_QUEST_BY_ID['a3q4']!
|
|
expect(a3q4.titleKey).toBe('qstsa3q1')
|
|
expect(a3q4.titleEn).toBe("Lam Esen's Tome")
|
|
|
|
// Act 4: Fallen Angel -> qstsa4q1, Hell's Forge -> qstsa4q3, Terror's End -> qstsa4q2
|
|
const a4q1 = CANONICAL_QUEST_BY_ID['a4q1']!
|
|
expect(a4q1.titleKey).toBe('qstsa4q1')
|
|
expect(a4q1.titleEn).toBe('The Fallen Angel')
|
|
|
|
const a4q2 = CANONICAL_QUEST_BY_ID['a4q2']!
|
|
expect(a4q2.titleKey).toBe('qstsa4q3')
|
|
expect(a4q2.titleEn).toBe("Hell's Forge")
|
|
|
|
const a4q3 = CANONICAL_QUEST_BY_ID['a4q3']!
|
|
expect(a4q3.titleKey).toBe('qstsa4q2')
|
|
expect(a4q3.titleEn).toBe("Terror's End")
|
|
|
|
// Act 5: Siege (a5q1) -> qstsa5q1, Rescue (a5q2) -> qstsa5q2, Prison (a5q3) -> qstsa5q3,
|
|
// Betrayal (a5q4) -> qstsa5q4, Rite (a5q5) -> qstsa5q5, Eve of Destruction (a5q6) -> qstsa5q6
|
|
const a5q6 = CANONICAL_QUEST_BY_ID['a5q6']!
|
|
expect(a5q6.titleKey).toBe('qstsa5q6')
|
|
expect(a5q6.titleEn).toBe('Eve of Destruction')
|
|
})
|
|
|
|
it('provides multi-stage objective progression data for all 27 quests', () => {
|
|
for (const q of CANONICAL_QUEST_DATA) {
|
|
expect(q.stages.length).toBeGreaterThan(0)
|
|
for (const stage of q.stages) {
|
|
expect(stage.key).toBeTruthy()
|
|
expect(stage.stage).toBeGreaterThan(0)
|
|
expect(stage.textEn.length).toBeGreaterThan(0)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('resolves quest display text using default fallback when no TBL is provided', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const a1q1 = ACT_QUESTS[1]![0]!
|
|
expect(a1q1.id).toBe('a1q1')
|
|
|
|
// Default status is completed for a1q1
|
|
const displayCompleted = hud.getQuestDisplayText(a1q1)
|
|
expect(displayCompleted.status).toBe('completed')
|
|
expect(displayCompleted.statusTag).toBe('[已完成]')
|
|
expect(displayCompleted.title).toBe('邪惡洞穴')
|
|
expect(displayCompleted.titleKey).toBe('qstsa1q1')
|
|
expect(displayCompleted.body).toContain('清除')
|
|
expect(displayCompleted.rewardText).toContain('技能点')
|
|
|
|
// Toggle status to active
|
|
hud.setQuestStatus('a1q1', 'active')
|
|
const displayActive = hud.getQuestDisplayText(a1q1)
|
|
expect(displayActive.status).toBe('active')
|
|
expect(displayActive.statusTag).toBe('[进行中]')
|
|
expect(displayActive.body).toContain('找尋一個洞穴')
|
|
|
|
// Toggle status to locked
|
|
hud.setQuestStatus('a1q1', 'locked')
|
|
const displayLocked = hud.getQuestDisplayText(a1q1)
|
|
expect(displayLocked.status).toBe('locked')
|
|
expect(displayLocked.statusTag).toBe('[未开启]')
|
|
expect(displayLocked.body).toContain('尚未开启')
|
|
})
|
|
|
|
it('dynamically resolves strings via setQuestTblLookup', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const customTbl = new Map<string, string>([
|
|
['qstsa1q1', '洞穴肃清行动 (Custom TBL)'],
|
|
['qstsa1q11', '前往鲜血荒地消灭全部恶魔。'],
|
|
['A1Q1SuccessfulAkara', '感谢你,阿卡拉赐予你技能。'],
|
|
])
|
|
|
|
hud.setQuestTblLookup(key => customTbl.get(key))
|
|
|
|
const a1q1 = ACT_QUESTS[1]![0]!
|
|
hud.setQuestStatus('a1q1', 'active')
|
|
const display = hud.getQuestDisplayText(a1q1)
|
|
|
|
expect(display.title).toBe('洞穴肃清行动 (Custom TBL)')
|
|
expect(display.body).toBe('前往鲜血荒地消灭全部恶魔。')
|
|
|
|
hud.setQuestStatus('a1q1', 'completed')
|
|
const completedDisplay = hud.getQuestDisplayText(a1q1)
|
|
expect(completedDisplay.body).toBe('感谢你,阿卡拉赐予你技能。')
|
|
})
|
|
|
|
it('dynamically resolves strings via setQuestStringTableChain', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const fakeChain = {
|
|
get(k: string) {
|
|
if (k === 'qstsa2q1') return "Radament's Hideout"
|
|
if (k === 'qstsa2q11') return 'Search the Sewers beneath Lut Gholein.'
|
|
return undefined
|
|
},
|
|
}
|
|
|
|
hud.setQuestStringTableChain(fakeChain)
|
|
|
|
const a2q1 = ACT_QUESTS[2]![0]!
|
|
hud.setQuestStatus('a2q1', 'active')
|
|
const display = hud.getQuestDisplayText(a2q1)
|
|
|
|
expect(display.title).toBe("Radament's Hideout")
|
|
expect(display.body).toBe('Search the Sewers beneath Lut Gholein.')
|
|
})
|
|
|
|
it('renders the quest log parchment without overlapping bottom bezels', () => {
|
|
const hud = new WorldPanelsHud()
|
|
const font = new D2FontRenderer()
|
|
|
|
const drawCalls: { text: string; x: number; y: number }[] = []
|
|
const originalDrawText = font.drawText.bind(font)
|
|
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
|
|
drawCalls.push({ text: String(text), x, y })
|
|
return originalDrawText(ctx, text, x, y, opts)
|
|
})
|
|
|
|
const mockCtx = {
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
drawImage: vi.fn(),
|
|
fillRect: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
stroke: vi.fn(),
|
|
fill: vi.fn(),
|
|
fillText: vi.fn(),
|
|
measureText: vi.fn(() => ({ width: 100 })),
|
|
fillStyle: '#000',
|
|
strokeStyle: '#000',
|
|
lineWidth: 1,
|
|
globalAlpha: 1,
|
|
} as unknown as CanvasRenderingContext2D
|
|
|
|
hud.selectedActTab = 1
|
|
hud.selectedQuestIdx = 0
|
|
|
|
// Render Quest Log Panel
|
|
hud.drawLeftDockPanel(
|
|
mockCtx,
|
|
'quest',
|
|
{
|
|
borderLeftImg: null,
|
|
questBgImg: null,
|
|
waypointBgImg: null,
|
|
stashBgImg: null,
|
|
cubeBgImg: null,
|
|
vendorBgImg: null,
|
|
buySellBtnImg: null,
|
|
},
|
|
font,
|
|
)
|
|
|
|
// Parchment title should be drawn around oy + 282 (60 + 282 = 342)
|
|
const titleCall = drawCalls.find(c => c.text.includes('(Den of Evil)'))
|
|
expect(titleCall).toBeDefined()
|
|
expect(titleCall!.y).toBe(60 + 282)
|
|
|
|
// Body should start around oy + 300 (60 + 300 = 360)
|
|
const bodyCall = drawCalls.find(c => c.y >= 60 + 300 && c.y <= 60 + 380)
|
|
expect(bodyCall).toBeDefined()
|
|
|
|
// No text should be drawn below oy + 380 into the close button zone
|
|
const outOfBoundsCall = drawCalls.find(c => c.y > 60 + 380 && c.text.includes('洞穴'))
|
|
expect(outOfBoundsCall).toBeUndefined()
|
|
})
|
|
})
|