/** * Quests and NPCs. * * Diablo II's quests are a small state machine per act: talk to an NPC to accept, * do the thing, talk again to be rewarded. `Quest.txt` holds the names and * descriptions (through the string table) and the objective; the dialogue itself * lives in code with its text in the TBL. This module models the part that is * structural — the states, the objective counter, the reward, and the dialogue * *selection* — and leaves the actual lines to the data. * * The dialogue is chosen by state rather than listed blindly, because that is the * behaviour that matters: an NPC who offers a quest already in progress, or who * forgets a completed one, is the classic quest bug. */ import { numberCell, resolveText, textCell } from './tables.ts' import type { TextSource } from './tables.ts' /** One NPC. */ export interface NpcDef { /** Table id. */ readonly id: string /** Display name. */ readonly name: string /** The quest this NPC offers, when any. */ readonly questId: string | null /** Lines offered while the quest is available. */ readonly offerLines: readonly string[] /** Lines offered while the quest is in progress. */ readonly progressLines: readonly string[] /** Lines offered once the quest is done. */ readonly doneLines: readonly string[] } /** One quest. */ export interface QuestDef { /** Table id. */ readonly id: string /** Display name. */ readonly name: string /** Description, for the quest log. */ readonly description: string /** Monster id to kill, or `*` for any. */ readonly monsterId: string /** How many kills complete it. */ readonly killCount: number /** Experience awarded on completion. */ readonly rewardXp: number /** Gold awarded on completion. */ readonly rewardGold: number } /** A quest's live state. */ export interface QuestProgress { /** The definition. */ readonly def: QuestDef /** Where it stands. */ status: 'inactive' | 'active' | 'complete' /** Kills recorded so far. */ kills: number } /** Raised when a quest id is used that the log does not know. */ export class QuestError extends Error { constructor(message: string) { super(message) this.name = 'QuestError' } } /** * Read one NPC row. * * @param row - the record. * @param rowIndex - position, for a fallback id. * @param text - text source for name and dialogue indices. * @returns the NPC. */ export function npcFromRow( row: Readonly>, rowIndex: number, text: TextSource, ): NpcDef { const id = textCell(row, 'Id', textCell(row, 'npc', `npc${String(rowIndex)}`)) // Split first (a cell may list several indices or literals), resolve each part, // then split again: a string table entry can itself hold several lines, and // resolving after splitting would leave those pipes in the text. const lines = (column: string): string[] => textCell(row, column, '') .split('|') .map(part => resolveText(part.trim(), text)) .flatMap(resolved => resolved.split('|')) .map(line => line.trim()) .filter(line => line !== '') return { id, name: resolveText(textCell(row, 'Name', id), text) || id, questId: textCell(row, 'Quest', '') || null, offerLines: lines('Offer'), progressLines: lines('Progress'), doneLines: lines('Done'), } } /** * Read every NPC in a table. * * @param table - an NPC-shaped table. * @param text - text source. * @returns the NPCs. */ export function npcsFromTable( table: { rows: readonly Readonly>[] }, text: TextSource, ): NpcDef[] { return table.rows.map((row, index) => npcFromRow(row, index, text)) } /** * Read one quest row. * * @param row - the record. * @param rowIndex - position, for a fallback id. * @param text - text source for name and description indices. * @returns the quest. */ export function questFromRow( row: Readonly>, rowIndex: number, text: TextSource, ): QuestDef { const id = textCell(row, 'Id', textCell(row, 'quest', `quest${String(rowIndex)}`)) return { id, name: resolveText(textCell(row, 'Name', id), text) || id, description: resolveText(textCell(row, 'Description', ''), text), monsterId: textCell(row, 'MonsterId', textCell(row, 'monster', '*')) || '*', killCount: Math.max(1, numberCell(row, 'KillCount', numberCell(row, 'count', 1))), rewardXp: Math.max(0, numberCell(row, 'RewardXP', numberCell(row, 'xp', 0))), rewardGold: Math.max(0, numberCell(row, 'RewardGold', numberCell(row, 'gold', 0))), } } /** * Read every quest in a table. * * @param table - a `Quest.txt`-shaped table. * @param text - text source. * @returns the quests. */ export function questsFromTable( table: { rows: readonly Readonly>[] }, text: TextSource, ): QuestDef[] { return table.rows.map((row, index) => questFromRow(row, index, text)) } /** A reward handed out when a quest completes. */ export interface QuestReward { /** Quest that completed. */ readonly questId: string /** Experience awarded. */ readonly xp: number /** Gold awarded. */ readonly gold: number } /** * The player's quest log. */ export class QuestLog { private readonly progress = new Map() /** * @param quests - the quests the log tracks. */ constructor(quests: readonly QuestDef[]) { for (const quest of quests) { this.progress.set(quest.id, { def: quest, status: 'inactive', kills: 0 }) } } /** * Rebuild a log from saved progress. * * @param quests - the quest definitions. * @param states - saved status and counters, matched by id. * @returns the log. */ static restore( quests: readonly QuestDef[], states: readonly { readonly id: string; readonly status: QuestProgress['status']; readonly kills: number }[], ): QuestLog { const log = new QuestLog(quests) for (const saved of states) { const entry = log.progress.get(saved.id) if (entry === undefined) continue entry.status = saved.status entry.kills = Math.max(0, saved.kills) } return log } /** Every quest's progress, in table order. */ get all(): readonly QuestProgress[] { return [...this.progress.values()] } /** The quests currently in progress. */ get active(): readonly QuestProgress[] { return this.all.filter(entry => entry.status === 'active') } /** * Read one quest's progress. * * @param id - the quest id. * @returns the progress, or undefined when unknown. */ get(id: string): QuestProgress | undefined { return this.progress.get(id) } /** * Accept a quest. * * @param id - the quest id. * @returns true when it moved from inactive to active. */ accept(id: string): boolean { const entry = this.progress.get(id) if (entry === undefined) throw new QuestError(`unknown quest "${id}"`) if (entry.status !== 'inactive') return false entry.status = 'active' return true } /** * Record a kill, advancing every active quest that wants that monster. * * @param monsterId - the monster's table id. * @returns the quests this kill completed. */ recordKill(monsterId: string): QuestReward[] { const rewards: QuestReward[] = [] for (const entry of this.progress.values()) { if (entry.status !== 'active') continue const wanted = entry.def.monsterId // `*` means any monster, which is how a "kill N of anything" quest reads. if (wanted !== '*' && wanted !== monsterId) continue entry.kills += 1 if (entry.kills >= entry.def.killCount) { entry.status = 'complete' rewards.push({ questId: entry.def.id, xp: entry.def.rewardXp, gold: entry.def.rewardGold }) } } return rewards } } /** * The lines an NPC says right now. * * @param npc - the NPC. * @param log - the quest log. * @returns the lines, empty when there is nothing to say. */ export function npcDialog(npc: NpcDef, log: QuestLog): string[] { if (npc.questId === null) return [...npc.offerLines] const entry = log.get(npc.questId) if (entry === undefined) return [...npc.offerLines] switch (entry.status) { case 'inactive': return [...npc.offerLines] case 'active': return [...npc.progressLines] case 'complete': return [...npc.doneLines] /* v8 ignore next -- the status union is closed above. */ default: return [] } }