- 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
206 lines
6.1 KiB
TypeScript
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: "",
|
|
};
|
|
}
|