feat(quest): authentic Diablo II 1.13c quest log tbl strings (Fixes #381)

This commit is contained in:
troytt 2026-09-22 11:24:02 +00:00
parent 090de33935
commit 775f4113c9
4 changed files with 1536 additions and 57 deletions

File diff suppressed because it is too large Load Diff

View File

@ -94,8 +94,8 @@ export function getAvailableSkillsForSide(
return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable))
}
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48, w: 48, h: 48 } as const
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48, w: 48, h: 48 } as const
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },

View File

@ -23,14 +23,41 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 }
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
import {
CANONICAL_QUESTS_BY_ACT,
type CanonicalQuestItem,
type CanonicalQuestStage,
} from '../data/canonical-quest-data.ts'
export interface QuestEntry {
readonly id: string
readonly act: number
readonly index: number
readonly titleEn: string
readonly titleZh: string
readonly titleKey: string
readonly status: 'completed' | 'active' | 'locked'
readonly description: string
readonly objectiveKey: string
readonly objectiveEn: string
readonly objectiveZh: string
readonly completedKey: string
readonly completedEn: string
readonly completedZh: string
readonly rewardSummaryEn: string
readonly rewardSummaryZh: string
readonly stages: readonly CanonicalQuestStage[]
}
export type QuestTblLookup = (key: string) => string | undefined
export interface QuestDisplayText {
readonly title: string
readonly titleKey: string
readonly body: string
readonly status: 'completed' | 'active' | 'locked'
readonly statusTag: string
readonly rewardText: string
}
export interface WaypointEntry {
@ -44,44 +71,25 @@ export interface WaypointEntry {
readonly unlocked: boolean
}
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
return {
...item,
status: isCompleted ? 'completed' : 'active',
description: isCompleted
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
}
}
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: [
{ id: 'a1q1', act: 1, index: 0, titleEn: 'Den of Evil', titleZh: '邪恶洞窟', status: 'completed', description: '阿卡拉委托你肃清鲜血荒地地下的邪恶洞窟。任务已完成,奖励 +1 技能点。' },
{ id: 'a1q2', act: 1, index: 1, titleEn: "Sisters' Burial Grounds", titleZh: '修女埋骨之地', status: 'completed', description: '在冰冷之原的埋骨之地击败堕落的血鸟 (Blood Raven),卡夏为你配备了罗格佣兵。' },
{ id: 'a1q3', act: 1, index: 2, titleEn: 'The Search for Cain', titleZh: '凯恩的搜寻', status: 'completed', description: '从黑暗森林取得艾尼弗斯卷轴,激活乱石旷野的五根石柱,前往崔斯特瑞姆救出迪卡·凯恩。' },
{ id: 'a1q4', act: 1, index: 3, titleEn: 'The Forgotten Tower', titleZh: '遗忘之塔', status: 'active', description: '探索黑色荒地中的五层遗忘之塔,击败女伯爵 (The Countess) 夺回财宝与符文。' },
{ id: 'a1q5', act: 1, index: 4, titleEn: 'Tools of the Trade', titleZh: '交易的工具', status: 'active', description: '深入修道院兵营从铁匠手中夺回赫拉迪姆之锤,并交还给恰西进行灌魔。' },
{ id: 'a1q6', act: 1, index: 5, titleEn: 'Sisters to the Slaughter', titleZh: '屠杀修女', status: 'active', description: '进入地下墓穴第四层,消灭痛苦之王安达利尔 (Andariel),打通前往鲁高因的道路。' },
],
2: [
{ id: 'a2q1', act: 2, index: 0, titleEn: "Radament's Lair", titleZh: '罗达门特的巢穴', status: 'active', description: '在鲁·高因下水道第三层消灭罗达门特,夺回复仇之书(+1 技能点)与赫拉迪姆卷轴。' },
{ id: 'a2q2', act: 2, index: 1, titleEn: 'The Horadric Staff', titleZh: '赫拉迪克之杖', status: 'active', description: '在死亡之殿取得赫拉迪克方块,在遥远绿洲取得国王之杖,在利爪蝮蛇神殿取得蝮蛇护符,将其在方块中合成。' },
{ id: 'a2q3', act: 2, index: 2, titleEn: 'Tainted Sun', titleZh: '堕落的太阳', status: 'active', description: '利爪蝮蛇的邪恶仪式遮蔽了太阳,深入利爪蝮蛇神殿摧毁太阳祭坛,恢复鲁·高因的光明。' },
{ id: 'a2q4', act: 2, index: 3, titleEn: 'Arcane Sanctuary', titleZh: '神秘避难所', status: 'active', description: '穿过杰海因王宫底层的传送门,进入大法师赫拉森的神秘避难所,搜寻真正的塔·拉夏古墓印记。' },
{ id: 'a2q5', act: 2, index: 4, titleEn: 'The Summoner', titleZh: '召唤者', status: 'active', description: '在神秘避难所尽头击败堕落的召唤者,开启通往术士峡谷的传送门。' },
{ id: 'a2q6', act: 2, index: 5, titleEn: 'The Seven Tombs', titleZh: '七座古墓', status: 'active', description: '在术士峡谷搜寻带有真正印记的塔·拉夏古墓,用赫拉迪克之杖开启密室,击败督瑞尔 (Duriel)。' },
],
3: [
{ id: 'a3q1', act: 3, index: 0, titleEn: 'The Golden Bird', titleZh: '黄金鸟', status: 'active', description: '在库拉斯特丛林击败独特怪物取得玉制小人,交还给马席夫换取黄金鸟雕像,阿尔卡特为你配制永久增加 20 点生命上限的药剂。' },
{ id: 'a3q2', act: 3, index: 1, titleEn: 'Blade of the Old Religion', titleZh: '古代宗教之刃', status: 'active', description: '在剥皮丛林吉得宾祭坛夺回附魔匕首吉得宾,将其带给奥玛斯以加固海港魔法屏障。' },
{ id: 'a3q3', act: 3, index: 2, titleEn: "Khalim's Will", titleZh: '克林姆的意志', status: 'active', description: '搜集克林姆的眼球(蜘蛛洞穴)、脑(剥皮地窖)、心脏(库拉斯特下水道)与连枷,合成克林姆的意志击碎强制之球。' },
{ id: 'a3q4', act: 3, index: 3, titleEn: "Lam Esen's Tome", titleZh: '蓝·森的古书', status: 'active', description: '在库拉斯特商场的废弃神庙中搜寻蓝·森的黑书并交给艾席拉,获得 +5 属性点奖励。' },
{ id: 'a3q5', act: 3, index: 4, titleEn: 'The Blackened Temple', titleZh: '黑暗神殿', status: 'active', description: '在崔凡克消灭最高评议会的堕落狂热信徒,用克林姆的意志击碎强制之球,打开通往憎恨囚牢的大门。' },
{ id: 'a3q6', act: 3, index: 5, titleEn: 'The Guardian', titleZh: '守卫者', status: 'active', description: '深入憎恨囚牢第三层,击败憎恨之王墨菲斯托 (Mephisto),夺取他的灵魂石并穿越地狱之门。' },
],
4: [
{ id: 'a4q1', act: 4, index: 0, titleEn: 'The Fallen Angel', titleZh: '堕落天使', status: 'active', description: '泰瑞尔委托你进入绝望平原,击败被恶魔囚禁扭曲的堕落大天使衣卒尔 (Izual),释放其灵魂并获得 +2 技能点。' },
{ id: 'a4q2', act: 4, index: 1, titleEn: "Hell's Forge", titleZh: '地狱熔炉', status: 'active', description: '护送墨菲斯托的灵魂石前往火焰之河的地狱熔炉,击败铁匠海法斯特夺取地狱熔炉之锤,粉碎邪恶灵魂石。' },
{ id: 'a4q3', act: 4, index: 2, titleEn: "Terror's End", titleZh: '恐怖的终结', status: 'active', description: '深入混沌避难所激活五座封印,击败三位庇护所守卫,最终消灭恐惧之王迪亚波罗 (Diablo)。' },
],
5: [
{ id: 'a5q1', act: 5, index: 0, titleEn: 'Siege on Harrogath', titleZh: '围攻哈洛加斯', status: 'active', description: '在血腥丘陵击败督军山克 (Shenk the Overseer),解除恶魔投石机对哈洛加斯的围攻,拉苏克将为你的一件装备打孔。' },
{ id: 'a5q2', act: 5, index: 1, titleEn: 'Rescue on Mount Arreat', titleZh: '亚瑞特山救援', status: 'active', description: '在冰冻高地寻找并释放被恶魔囚禁的 15 名野蛮人斥候,夸尔凯克将奖励你符文组合与招募野蛮人佣兵的特权。' },
{ id: 'a5q3', act: 5, index: 2, titleEn: 'Prison of Ice', titleZh: '冰之囚', status: 'active', description: '在冰河深处寻找被尼拉塞克封冻的安亚 (Anya),使用马拉配制的解冻药水解救她,安亚将赠予全职业抗性卷轴(+10% 全抗)。' },
{ id: 'a5q4', act: 5, index: 3, titleEn: 'Betrayal of Harrogath', titleZh: '哈洛加斯的背叛', status: 'active', description: '穿过安亚开启的红门进入瓦特之厅,击败背叛者尼拉塞克 (Nihlathak),阻止他将圣山遗物交给巴尔。' },
{ id: 'a5q5', act: 5, index: 4, titleEn: 'Rite of Passage', titleZh: '通过祭典', status: 'active', description: '登上亚瑞特山顶触碰古代人祭坛,在不返回城镇的情况下同时击败三位远古野蛮人守卫(科力克、马道克、塔力克),获得海量经验晋级。' },
{ id: 'a5q6', act: 5, index: 5, titleEn: 'Eve of Destruction', titleZh: '毁灭前夕', status: 'active', description: '深入世界之石要塞与毁灭王座,清除巴尔的五波恶魔仆从,追击毁灭之王巴尔进入世界之石大厅并将其消灭。' },
],
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry),
4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry),
5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry),
}
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
@ -180,6 +188,57 @@ export class WorldPanelsHud {
this.questStatuses.set(questId, status)
}
private questTblLookup?: QuestTblLookup | undefined
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
setQuestTblLookup(lookup: QuestTblLookup | null | undefined): void {
this.questTblLookup = lookup ?? undefined
}
setQuestStringTableChain(chain: { get(key: string): string | undefined } | null | undefined): void {
this.questStringTableChain = chain ?? undefined
}
lookupQuestString(key: string): string | undefined {
if (this.questTblLookup) {
const val = this.questTblLookup(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
if (this.questStringTableChain) {
const val = this.questStringTableChain.get(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
return undefined
}
getQuestDisplayText(quest: QuestEntry): QuestDisplayText {
const status = this.getQuestStatus(quest)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
let body = ''
const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn
if (status === 'completed') {
const tblCompleted = this.lookupQuestString(quest.completedKey)
body = tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
} else {
body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
}
return {
title,
titleKey: quest.titleKey,
body,
status,
statusTag,
rewardText,
}
}
private getQuestsAtlasImage(): HTMLImageElement | null {
if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg
if (typeof Image !== 'undefined') {
@ -562,19 +621,21 @@ export class WorldPanelsHud {
const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
if (activeQ) {
const status = this.getQuestStatus(activeQ)
const qInfo = this.getQuestDisplayText(activeQ)
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
ctx.fillRect(ox + 18, oy + 292, 284, 86)
ctx.fillRect(ox + 18, oy + 264, 284, 116)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 292.5, 283, 85)
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
font.drawText(ctx, `${activeQ.titleZh} (${activeQ.titleEn}) ${statusTag}`, ox + 160, oy + 314, {
font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: status === 'completed' ? 'gold' : 'white',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
this.drawWrappedText(ctx, font, activeQ.description, ox + 28, oy + 336, 264, 18)
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '')
: (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
@ -782,20 +843,36 @@ export class WorldPanelsHud {
y: number,
maxW: number,
lineH: number,
maxLines = 10,
): void {
let line = ''
const paragraphs = text.split(/\r?\n/)
let curY = y
for (const ch of text) {
if (font.measureText(line + ch, 'font8') > maxW) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
line = ch
curY += lineH
} else {
line += ch
let linesDrawn = 0
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!
if (font.measureText(line + ch, 'font8') > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
line = ch
curY += lineH
} else {
line += ch
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
curY += lineH
}
}
if (line) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
}
}

229
tests/quest-tbl.test.ts Normal file
View File

@ -0,0 +1,229 @@
/**
* 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()
})
})