diablo2-web/src/game/quests.ts

279 lines
8.3 KiB
TypeScript

/**
* 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<Record<string, string>>,
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<Record<string, string>>[] },
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<Record<string, string>>,
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<Record<string, string>>[] },
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<string, QuestProgress>()
/**
* @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 []
}
}