feat: add spark table completion and rolling support

Implement "spark tables" functionality, which allows users to roll
dice against markdown tables to retrieve specific values.

- Add `sparkTablesSource` to scan markdown files for tables starting
  with a dice notation (e.g., d6, d20).
- Implement `/spark` command in the journal to resolve and roll
  spark tables.
- Add a new message type `spark` with a dedicated UI component to
  render the results.
- Update completions API to include spark table metadata.
This commit is contained in:
2026-07-07 19:02:17 +08:00
parent 3690d13407
commit 2f29f8774d
10 changed files with 587 additions and 10 deletions
+63 -4
View File
@@ -24,6 +24,7 @@ import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
// ---- Helpers ----
@@ -34,7 +35,7 @@ interface CompletionItem {
}
interface ParsedInput {
type: "chat" | "roll" | "link";
type: "chat" | "roll" | "spark" | "link";
payload: Record<string, unknown>;
error?: string;
}
@@ -47,6 +48,13 @@ function parseInput(raw: string): ParsedInput {
return { type: "roll", payload: { notation, label: notation } };
}
if (raw.startsWith("/spark ")) {
const key = raw.slice("/spark ".length).trim();
if (!key)
return { type: "spark", payload: {}, error: "Spark table key required" };
return { type: "spark", payload: { key } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" };
@@ -57,8 +65,8 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
// /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" };
}
@@ -101,7 +109,7 @@ export const JournalInput: Component = () => {
// ---- Send ----
function handleSend() {
async function handleSend() {
const raw = text().trim();
if (!raw) return;
@@ -142,6 +150,30 @@ export const JournalInput: Component = () => {
return;
}
// GM spark: resolve the spark table roll locally
if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
// Look up filePath from completions data
const match = comp.data.sparkTables.find((s) => s.slug === key);
const filePath = match?.filePath ?? "";
const p = await resolveSparkPayload({ key, filePath });
const result = sendMessage("spark", p);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to roll spark table");
setSending(false);
return;
}
}
const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) {
setError(result.error);
@@ -172,6 +204,7 @@ export const JournalInput: Component = () => {
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
];
@@ -206,6 +239,32 @@ export const JournalInput: Component = () => {
}));
}
// After /spark — show spark table suggestions
if (raw.startsWith("/spark ")) {
const prefix = raw.slice("/spark ".length).toLowerCase();
const matches = data.sparkTables
.filter(
(s) =>
s.slug.toLowerCase().includes(prefix) ||
s.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [
{
label: "No spark tables found",
kind: "no-results",
insertText: "",
},
];
}
return matches.map((s) => ({
label: `${s.filePath} § ${s.slug} (${s.notation})`,
kind: "value" as const,
insertText: `/spark ${s.slug}`,
}));
}
// After /link — show article and heading suggestions
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();