feat(quest): authentic Diablo II 1.13c quest log tbl strings (Fixes #381)
This commit is contained in:
parent
090de33935
commit
775f4113c9
File diff suppressed because it is too large
Load Diff
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue