156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
/**
|
|
* Diablo II data tables (`data/global/excel/*.txt`).
|
|
*
|
|
* Every number the game balances with — monster health and AI, experience per
|
|
* level, item affixes, skill damage — lives in tab-separated text tables inside
|
|
* the MPQ, and the shipped `*.bin` files are just a compiled form of the same
|
|
* rows. Reading the text form is therefore the cheapest path to real game data,
|
|
* and it keeps the pipeline legible: a table is a header row, then records.
|
|
*
|
|
* Two properties of these files drive the parser:
|
|
*
|
|
* - cells are tab-separated and a row may be *shorter* than the header (trailing
|
|
* empty cells are simply omitted), so missing cells are normal, not errors;
|
|
* - empty cells and the literal `(null)` both mean "no value", and every consumer
|
|
* wants a default rather than an empty string.
|
|
*
|
|
* Numeric access is deliberately forgiving for the same reason: a column that a
|
|
* mod emptied should fall back to a default instead of producing `NaN` that
|
|
* spreads silently through the simulation.
|
|
*/
|
|
|
|
/** A parsed table: column names plus one record per row. */
|
|
export interface DataTable {
|
|
/** Column names, in file order. */
|
|
readonly columns: readonly string[]
|
|
/** Records keyed by column name. */
|
|
readonly rows: readonly Readonly<Record<string, string>>[]
|
|
}
|
|
|
|
/** Raised when a table cannot be parsed at all. */
|
|
export class TableError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'TableError'
|
|
}
|
|
}
|
|
|
|
/** Cell text that means "no value". */
|
|
const NULL_CELL = '(null)'
|
|
|
|
/**
|
|
* Parse a tab-separated data table.
|
|
*
|
|
* @param text - the file's text.
|
|
* @returns the parsed table.
|
|
*/
|
|
export function parseTable(text: string): DataTable {
|
|
const lines = text.split(/\r?\n/).filter(line => line.trim() !== '')
|
|
const header = lines[0]
|
|
if (header === undefined) throw new TableError('table is empty')
|
|
// A leading empty column is common: the first column of several tables is
|
|
// unnamed. It is kept as an empty name so indices still line up.
|
|
const columns = header.split('\t').map(name => name.trim())
|
|
if (columns.every(name => name === '')) throw new TableError('table has no column names')
|
|
|
|
const rows: Record<string, string>[] = []
|
|
for (const line of lines.slice(1)) {
|
|
const cells = line.split('\t')
|
|
const row: Record<string, string> = {}
|
|
columns.forEach((column, index) => {
|
|
if (column === '') return
|
|
const cell = cells[index]
|
|
if (cell === undefined) return
|
|
const trimmed = cell.trim()
|
|
if (trimmed === '' || trimmed === NULL_CELL) return
|
|
row[column] = trimmed
|
|
})
|
|
rows.push(row)
|
|
}
|
|
return { columns, rows }
|
|
}
|
|
|
|
/**
|
|
* Read a numeric cell.
|
|
*
|
|
* @param row - the record.
|
|
* @param column - column name.
|
|
* @param fallback - value to use when the cell is missing or unparsable.
|
|
* @returns the number.
|
|
*/
|
|
export function numberCell(row: Readonly<Record<string, string>>, column: string, fallback: number): number {
|
|
const raw = row[column]
|
|
if (raw === undefined || raw.trim() === '') return fallback
|
|
const value = Number(raw)
|
|
return Number.isFinite(value) ? value : fallback
|
|
}
|
|
|
|
/**
|
|
* Read a text cell.
|
|
*
|
|
* @param row - the record.
|
|
* @param column - column name.
|
|
* @param fallback - value to use when the cell is missing.
|
|
* @returns the text.
|
|
*/
|
|
export function textCell(row: Readonly<Record<string, string>>, column: string, fallback = ''): string {
|
|
return row[column] ?? fallback
|
|
}
|
|
|
|
/**
|
|
* Look up a record by one column's value.
|
|
*
|
|
* @param table - the table.
|
|
* @param column - the column to match.
|
|
* @param value - the value to match (case-insensitive).
|
|
* @returns the record, or undefined.
|
|
*/
|
|
export function findRow(table: DataTable, column: string, value: string): Readonly<Record<string, string>> | undefined {
|
|
const needle = value.toLowerCase()
|
|
return table.rows.find(row => (row[column] ?? '').toLowerCase() === needle)
|
|
}
|
|
|
|
/**
|
|
* A source of strings addressed by index — a decoded `.tbl`.
|
|
*/
|
|
export interface TextSource {
|
|
/**
|
|
* Resolve one index.
|
|
*
|
|
* @param index - the string index.
|
|
* @returns the text, or undefined when the index is unused.
|
|
*/
|
|
get: (index: number) => string | undefined
|
|
}
|
|
|
|
/**
|
|
* Wrap decoded table entries as a text source.
|
|
*
|
|
* @param entries - a decoded `.tbl`, or null when the archive has none.
|
|
* @returns the source (empty when there is no table).
|
|
*/
|
|
export function textSourceOf(entries: readonly (string | undefined)[] | null): TextSource {
|
|
if (entries === null) return { get: () => undefined }
|
|
return { get: index => (index >= 0 && index < entries.length ? entries[index] : undefined) }
|
|
}
|
|
|
|
/**
|
|
* Resolve a table cell that holds either a literal or a string index.
|
|
*
|
|
* Diablo II tables mix the two: `Skills.txt` names skills by index into
|
|
* `string.tbl`, while mods and many tools write the words directly. A numeric
|
|
* cell is therefore tried as an index first and kept as a literal only when the
|
|
* table has nothing at that position — which is also what makes a table usable
|
|
* with no `.tbl` loaded at all.
|
|
*
|
|
* @param cell - the raw cell text.
|
|
* @param source - the text source, when a table is loaded.
|
|
* @returns the resolved text.
|
|
*/
|
|
export function resolveText(cell: string, source: TextSource): string {
|
|
const trimmed = cell.trim()
|
|
if (trimmed === '') return ''
|
|
if (!/^\d+$/.test(trimmed)) return trimmed
|
|
return source.get(Number(trimmed)) ?? trimmed
|
|
}
|