feat(journal): show spark roll dice and markdown

The spark message never displayed the dice, and its rollResult came from a
second, independent roll that could disagree with the row actually
selected.

rollSparkTable now reports the roll(s) it used, so the message can show
'header / notation -> roll' in the purple box and render each rolled value
as markdown (its own div) below it.
This commit is contained in:
hyper
2026-09-12 20:43:03 +08:00
parent c7f3b3425b
commit 0ce91afc49
3 changed files with 145 additions and 52 deletions
+49 -27
View File
@@ -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,23 +28,20 @@ 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(),
});
const schema = z.object({
/** User-visible label, e.g. "adventure body-label" */
label: z.string(),
/** The dice notation from the spark table header */
notation: z.string().min(1),
/** Resolved spark table columns */
sparkTable: sparkResultSchema,
/** First column's raw dice roll — informational only */
rollResult: z.object({
/** Shape of a single dice-engine roll result. */
const rollDetailSchema = z.object({
total: z.number(),
detail: z.string(),
plainDetail: z.string(),
@@ -54,7 +51,20 @@ const schema = z.object({
subtotal: z.number(),
}),
),
}),
});
const schema = z.object({
/** User-visible label, e.g. "adventure body-label" */
label: z.string(),
/** The dice notation from the spark table header */
notation: z.string().min(1),
/** Resolved spark table columns */
sparkTable: sparkResultSchema,
/**
* 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<typeof schema>;
@@ -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<SparkPayload>({
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 (
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<span class="text-lg">✨</span>
<span class="font-mono text-xs text-purple-600">
{st.notation} · spark
</span>
</div>
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
<table class="w-full text-xs">
<tbody>
<For each={st.columns}>
{(col) => (
{(col) => {
const roll = col.roll ?? sharedRoll;
return (
<tr class="border-t border-purple-100 first:border-t-0">
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
<td class="px-2 py-1 text-purple-600 font-medium whitespace-nowrap">
{col.header}
</td>
<td class="px-2 py-1 text-gray-800">{col.value}</td>
</tr>
{roll != null && (
<span class="font-mono text-purple-400">
{" / "}
{st.notation}
{" -> "}
{roll}
</span>
)}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
<For each={st.columns}>
{(col) => (
<div
class="px-2 py-1 [&_p]:m-0"
innerHTML={parseMarkdown(col.value, st.source)}
/>
)}
</For>
</div>
);
},
+57
View File
@@ -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"]);
}
});
});
+19 -5
View File
@@ -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<typeof rollFormula>["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,9 +183,12 @@ export function rollSparkTable(
}
}
columns.push({ header, slug, value });
columns.push({ header, slug, value, roll: rolledValue });
}
} else {
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;
@@ -193,13 +206,14 @@ export function rollSparkTable(
const value = matchedRow
? (matchedRow[header] ?? "")
: `(no row for ${rolledValue})`;
columns.push({ header, slug, value });
}
columns.push({ header, slug, value, roll: rolledValue });
}
return {
notation: meta.notation,
columns,
source: "",
remix,
roll: roll.result,
};
}