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:
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user