/** * 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[]; } // --------------------------------------------------------------------------- // 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[], }; } // --------------------------------------------------------------------------- // 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 | 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: "", }; }