Files
ttrpg-tools/src/components/utils/spark-table.ts
T
hypercross b7a804f1cf refactor: remove as= override and render data-spark at runtime
- Drop resolveBlockAs and the as= attribute; scanDoc now switches
  directly on role and warns on unknown roles instead of silently
  stripping
- md-table derives data-spark from its loaded CSV via
  parseSparkTableCsv; delete injectSparkDirectives and the second
  registry pass so pathIndex is exactly scanDoc output
- Align spark-table dice header regex with isSparkTableHeader
2026-09-08 22:18:12 +08:00

206 lines
6.1 KiB
TypeScript

/**
* Spark Table — runtime rolling and CSV parsing.
*
* A spark table is a CSV whose first column header is a dice formula
* (d6, d20, d100, etc.). Rolling a spark table means:
*
* 1. Roll the dice formula once
* 2. Look up the row whose dice-column value matches the roll
* 3. Return all column values from that row
*
* When `remix` is true, each data column gets its own independent roll
* and may come from different rows.
*/
import Slugger from "github-slugger";
import { parse } from "csv-parse/browser/esm/sync";
import { parseCSVString } from "../utils/csv-loader";
import { rollFormula } from "../md-commander/hooks";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SparkTableColumn {
header: string;
slug: string;
value: string;
}
export interface SparkTableResult {
/** The dice notation (e.g. "d6", "d20") */
notation: string;
/** The roll results keyed by column slug */
columns: SparkTableColumn[];
/** Source file path */
source: string;
}
export interface SparkTableMeta {
/** Dice notation from the first column header */
notation: string;
/** Concatenated slug of data columns */
slug: string;
/** The header name of the dice column (first column) */
diceHeader: string;
/** Data column headers (excluding dice column) */
dataHeaders: string[];
/** Full list of rows as objects keyed by header */
rows: Record<string, string>[];
}
// ---------------------------------------------------------------------------
// CSV parsing
// ---------------------------------------------------------------------------
// Matches the scanner's `isSparkTableHeader` (content-registry): plain dice,
// counts, and modifiers all count as spark-table first columns.
const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i;
/**
* Parse a CSV string into a SparkTableMeta.
* The CSV must have a dice formula as its first column header.
* Returns null if the CSV is not a valid spark table.
*/
export function parseSparkTableCsv(csv: string): SparkTableMeta | null {
// Parse raw headers first — before parseCSVString injects frontmatter keys
// into rows. We use csv-parse directly to get the header order reliably.
const rawParsed = parse(csv, {
columns: false,
comment: "#",
trim: true,
skipEmptyLines: true,
bom: true,
}) as string[][];
if (rawParsed.length < 2) return null;
const headers = rawParsed[0];
if (headers.length < 2) return null;
if (!DICE_HEADER_RE.test(headers[0])) return null;
// Now parse with csv-loader for full frontmatter + quoting support
const parsed = parseCSVString(csv);
const slugger = new Slugger();
const diceHeader = headers[0];
const dataHeaders = headers.slice(1);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
return {
notation: diceHeader,
slug,
diceHeader,
dataHeaders,
rows: parsed as Record<string, string>[],
};
}
// ---------------------------------------------------------------------------
// Range parsing
// ---------------------------------------------------------------------------
/**
* Parse a cell value like "1-3", "4-6", or "10-20" into { min, max }.
* Returns null if the cell doesn't represent a range.
*/
function parseRange(cell: string): { min: number; max: number } | null {
const trimmed = cell.trim();
const m = /^(\d+)\s*-\s*(\d+)$/.exec(trimmed);
if (!m) return null;
const min = parseInt(m[1], 10);
const max = parseInt(m[2], 10);
if (isNaN(min) || isNaN(max)) return null;
return { min, max };
}
/** Test whether a rolled total matches a cell value */
function matchesCell(diceCell: string, rolledValue: number): boolean {
const trimmed = diceCell.trim();
// Exact integer match
const cellNum = parseInt(trimmed, 10);
if (!isNaN(cellNum) && rolledValue === cellNum) return true;
// Exact string match (for non-numeric labels)
if (String(rolledValue) === trimmed) return true;
// Range match (e.g. "1-3")
const range = parseRange(trimmed);
if (range && rolledValue >= range.min && rolledValue <= range.max)
return true;
return false;
}
// ---------------------------------------------------------------------------
// Rolling
// ---------------------------------------------------------------------------
export interface RollSparkTableOptions {
/** When true, each data column gets its own independent roll. Default false. */
remix?: boolean;
}
/**
* Roll a spark table.
*
* By default, rolls the dice once and reads all columns from the matched row.
* When `remix` is true, each data column gets its own independent roll and
* may come from different rows.
*/
export function rollSparkTable(
meta: SparkTableMeta,
options: RollSparkTableOptions = {},
): SparkTableResult {
const slugger = new Slugger();
const columns: SparkTableColumn[] = [];
if (options.remix) {
// Independent roll per column
for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) {
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
value = row[header] ?? "";
break;
}
}
columns.push({ header, slug, value });
}
} else {
// Single roll — all columns from the same row
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
let matchedRow: Record<string, string> | null = null;
for (const row of meta.rows) {
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
matchedRow = row;
break;
}
}
for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
const value = matchedRow
? (matchedRow[header] ?? "")
: `(no row for ${rolledValue})`;
columns.push({ header, slug, value });
}
}
return {
notation: meta.notation,
columns,
source: "",
};
}