diff --git a/src/components/journal/types/spark.tsx b/src/components/journal/types/spark.tsx index 71c5c06..505cd0c 100644 --- a/src/components/journal/types/spark.tsx +++ b/src/components/journal/types/spark.tsx @@ -15,10 +15,10 @@ import { z } from "zod"; import { For } from "solid-js"; import { registerMessageType } from "../registry"; -import { rollFormula } from "../../md-commander/hooks"; import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table"; import { getIndexedData } from "../../../data-loader/file-index"; import { getRegistry } from "../../journal/completions"; +import { parseMarkdown } from "../../../markdown"; // --------------------------------------------------------------------------- // Schema @@ -28,12 +28,29 @@ const sparkColumnSchema = z.object({ header: z.string(), slug: z.string(), value: z.string(), + /** Dice total that selected this column's row. */ + roll: z.number().optional(), }); const sparkResultSchema = z.object({ notation: z.string(), columns: z.array(sparkColumnSchema), source: z.string(), + /** True when each column rolled independently. */ + remix: z.boolean().optional(), +}); + +/** Shape of a single dice-engine roll result. */ +const rollDetailSchema = z.object({ + total: z.number(), + detail: z.string(), + plainDetail: z.string(), + pools: z.array( + z.object({ + rolls: z.array(z.number()), + subtotal: z.number(), + }), + ), }); const schema = z.object({ @@ -43,18 +60,11 @@ const schema = z.object({ notation: z.string().min(1), /** Resolved spark table columns */ sparkTable: sparkResultSchema, - /** First column's raw dice roll — informational only */ - rollResult: z.object({ - total: z.number(), - detail: z.string(), - plainDetail: z.string(), - pools: z.array( - z.object({ - rolls: z.array(z.number()), - subtotal: z.number(), - }), - ), - }), + /** + * Dice result for the shared roll — absent in remix mode, where each column + * rolls independently and carries its own `roll` instead. + */ + rollResult: rollDetailSchema.optional(), }); export type SparkPayload = z.infer; @@ -105,13 +115,13 @@ export async function resolveSparkPayload(raw: { } const sparkResult = rollSparkTable(meta, { remix: raw.remix }); - const firstRoll = rollFormula(meta.notation); return { label: raw.key, notation: meta.notation, - sparkTable: sparkResult, - rollResult: firstRoll.result, + // Source path lets markdown in the rolled values resolve relative refs. + sparkTable: { ...sparkResult, source: raw.docPath ?? "" }, + rollResult: sparkResult.roll, }; } @@ -127,35 +137,47 @@ registerMessageType({ defaultPayload: () => ({ label: "", notation: "d6", - sparkTable: { notation: "d6", columns: [], source: "" }, - rollResult: { total: 0, detail: "", plainDetail: "", pools: [] }, + sparkTable: { notation: "d6", columns: [], source: "", remix: false }, }), render: (p) => { const st = p.sparkTable; + const sharedRoll = p.rollResult?.total; return (
-
- ✨ - - {st.notation} · spark - -
- {(col) => ( - - - - - )} + {(col) => { + const roll = col.roll ?? sharedRoll; + return ( + + + + ); + }}
- {col.header} - {col.value}
+ {col.header} + {roll != null && ( + + {" / "} + {st.notation} + {" -> "} + {roll} + + )} +
+ + {(col) => ( +
+ )} +
); }, diff --git a/src/components/utils/spark-table.test.ts b/src/components/utils/spark-table.test.ts new file mode 100644 index 0000000..3a794d3 --- /dev/null +++ b/src/components/utils/spark-table.test.ts @@ -0,0 +1,57 @@ +import { parseSparkTableCsv, rollSparkTable } from "./spark-table"; + +const CSV = `2d6,encounter +2,Tomato +3,Onion +4,Cabbage +5,Carrot +6,Potato +7,Leek +8,Pumpkin +9,Mushroom +10,Radish +11,Eggplant +12,Pepper`; + +describe("rollSparkTable", () => { + const meta = parseSparkTableCsv(CSV)!; + + test("non-remix reports the shared roll and reads the row it selected", () => { + for (let i = 0; i < 25; i++) { + const result = rollSparkTable(meta); + + expect(result.remix).toBe(false); + expect(result.roll).toBeDefined(); + + const rolled = result.roll!.total; + expect(rolled).toBeGreaterThanOrEqual(2); + expect(rolled).toBeLessThanOrEqual(12); + expect(result.roll!.detail).toBeTruthy(); + + // Every column shares the single roll. + for (const col of result.columns) { + expect(col.roll).toBe(rolled); + } + + // The displayed value comes from the row matching the reported roll. + const row = meta.rows.find((r) => Number(r["2d6"]) === rolled); + expect(result.columns[0].value).toBe(row!["encounter"]); + } + }); + + test("remix gives each column its own roll and omits the shared roll", () => { + const result = rollSparkTable(meta, { remix: true }); + + expect(result.remix).toBe(true); + expect(result.roll).toBeUndefined(); + + for (const col of result.columns) { + expect(typeof col.roll).toBe("number"); + expect(col.roll).toBeGreaterThanOrEqual(2); + expect(col.roll).toBeLessThanOrEqual(12); + + const row = meta.rows.find((r) => Number(r["2d6"]) === col.roll); + expect(col.value).toBe(row!["encounter"]); + } + }); +}); diff --git a/src/components/utils/spark-table.ts b/src/components/utils/spark-table.ts index 8580fcb..03ed186 100644 --- a/src/components/utils/spark-table.ts +++ b/src/components/utils/spark-table.ts @@ -25,8 +25,13 @@ export interface SparkTableColumn { header: string; slug: string; value: string; + /** Dice total that selected this column's row. */ + roll?: number; } +/** The dice-engine result for a single roll. */ +export type SparkRollDetail = ReturnType["result"]; + export interface SparkTableResult { /** The dice notation (e.g. "d6", "d20") */ notation: string; @@ -34,6 +39,10 @@ export interface SparkTableResult { columns: SparkTableColumn[]; /** Source file path */ source: string; + /** True when each column rolled independently. */ + remix: boolean; + /** Full dice detail for the shared roll — absent in remix mode. */ + roll?: SparkRollDetail; } export interface SparkTableMeta { @@ -157,8 +166,9 @@ export function rollSparkTable( ): SparkTableResult { const slugger = new Slugger(); const columns: SparkTableColumn[] = []; + const remix = options.remix ?? false; - if (options.remix) { + if (remix) { // Independent roll per column for (const header of meta.dataHeaders) { const slug = slugger.slug(header.toLowerCase()); @@ -173,33 +183,37 @@ export function rollSparkTable( } } - 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; - } + columns.push({ header, slug, value, roll: rolledValue }); } - 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: "", remix }; + } + + // 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, roll: rolledValue }); + } + return { notation: meta.notation, columns, source: "", + remix, + roll: roll.result, }; }