279 lines
8.3 KiB
TypeScript
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 []
|
|
}
|
|
}
|