feat: session auto-capture + NPC→Initiative push + lore grounding everywhere
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 6m3s

- SessionLogger listens for a new LogEntry bus event; dice rolls,
  initiative turns/rounds, generated encounters and calendar day
  advances land in the active session as timestamped entries — the
  raw material the AI summary actually needs. Mute toggle in the
  toolbar (persisted, default on).
- NPC Generator: '⚔ to initiative' button pushes the generated stat
  block (HP) into the tracker via the existing AddCombatants bus event.
- ragQuery now set on Encounter, Quest and Session summary too
  (NPC/Item/World already had it) — every generator is world-grounded.
This commit is contained in:
itsamejms
2026-09-07 10:25:38 +01:00
parent bbd492fff1
commit 3c599553fc
8 changed files with 89 additions and 7 deletions
+7 -3
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from "react";
import { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
// ponytail: the calendar is user-configurable for homebrew campaigns.
// `months` carry their own day count so a custom calendar can have variable
@@ -106,12 +107,15 @@ export function CalendarWidget() {
}
function nextDay() {
setToday((t) => {
let { day, month, year } = t;
let { day, month, year } = today;
day += 1;
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
if (month >= config.months.length) { month = 0; year += 1; }
return { day, month, year };
setToday({ day, month, year });
// ponytail: the campaign clock ticks — the session log hears about it.
const label = config.months[month]?.name ?? String(month + 1);
bus.emit(Events.LogEntry, {
text: `🗓 Day advanced: ${day} ${label} ${config.yearLabel} ${year} — ${weatherFor(day, month, year)}`,
});
}
+7
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from "react";
import { usePersistentState } from "../lib/usePersistentState";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast";
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
@@ -58,6 +59,12 @@ export function DiceRoller() {
setResults((prev) => [result, ...prev].slice(0, 50));
setLastTotal(sum);
// ponytail: every roll also lands in the active session log —
// SessionLogger owns the mute toggle, we just report what happened.
bus.emit(Events.LogEntry, {
text: `🎲 ${result.notation} → ${sum} [${rolls.join(", ")}]`,
tag: "#misc",
});
setLastBreakdown(result);
},
[input, mode],
+6
View File
@@ -205,6 +205,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
system: "You are a D&D encounter designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.9,
max_tokens: 400,
ragQuery: `${terrain} monsters encounter`,
},
});
const parsed = parseLlmJson<Encounter>(result);
@@ -219,6 +220,11 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
`Encounter: ${parsed.difficulty || "?"} in ${terrain}`,
`Encounter (party of ${partySize} level-${partyLevel}) in ${terrain}. ${monsters}\nTerrain: ${flattenValue(parsed.terrain)}\nDifficulty: ${flattenValue(parsed.difficulty)}\nLoot: ${flattenValue(parsed.loot)}`,
);
// ponytail: the fight starts — tell the session log.
bus.emit(Events.LogEntry, {
text: `⚔ Encounter: ${title} (${flattenValue(parsed.difficulty) || "?"}) — ${monsters}`,
tag: "#combat",
});
} else {
addToast("LLM returned an unparseable response", "error");
}
+4 -1
View File
@@ -161,17 +161,20 @@ export function InitiativeTracker() {
if (combatants.length === 0) return;
if (!activeId) {
setActiveId(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Combat starts — ${combatants[0].name}`, tag: "#combat" });
return;
}
const idx = combatants.findIndex((c) => c.id === activeId);
if (idx === combatants.length - 1) {
setRound((r) => r + 1);
setActiveId(combatants[0].id);
bus.emit(Events.LogEntry, { text: `⚔ Round ${round + 1} — ${combatants[0].name}'s turn`, tag: "#combat" });
} else {
setActiveId(combatants[idx + 1].id);
bus.emit(Events.LogEntry, { text: `⚔ ${combatants[idx + 1].name}'s turn`, tag: "#combat" });
}
setTimerLeft(TURN_SECONDS); // ponytail: reset the per-turn timer.
}, [combatants, activeId]);
}, [combatants, activeId, round]);
// ponytail: tick the timer once per second while armed. Stopping at 0 lets
// the red zero sit until the DM advances (don't auto-advance — that would
+20
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { GeneratedImage } from "./GeneratedImage";
import { addToLore } from "../lib/lore";
import { bus, Events } from "../lib/bus";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
@@ -71,6 +72,18 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
if (!npc) return;
setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) });
}
// ponytail: one click from the NPC sheet to the combat tracker — the
// generated stat block already has HP; Initiative rolls on arrival.
function addToTracker() {
if (!npc) return;
const label = name || `${race} ${background}`;
bus.emit(Events.AddCombatants, {
combatants: [{ name: label, hp: npc.stats?.hp ?? 10 }],
source: "NPC Generator",
});
addToast(`${label} added to the Initiative Tracker`, "success");
}
function addGoal() {
if (!npc || !newGoal.trim()) return;
setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] });
@@ -204,6 +217,13 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
>
✨ new portrait
</button>
<button
onClick={addToTracker}
className="mt-0.5 w-full text-[10px] text-[var(--color-text-dim)] hover:text-[var(--color-gold-bright)] cursor-pointer transition-colors"
title="Send this NPC to the Initiative Tracker"
>
⚔ to initiative
</button>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
+1
View File
@@ -76,6 +76,7 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
system: "You are a creative D&D quest designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.",
temperature: 0.85,
max_tokens: 800,
ragQuery: `${theme} quest ${level}`,
},
});
const parsed = parseLlmJson<QuestResponse>(result);
+31
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from "react";
import { invoke, Channel } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { bus, Events, type LogEntryPayload } from "../lib/bus";
import { useToast } from "./Toast";
import { addGeneration, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
@@ -45,6 +46,9 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
const [filterTag, setFilterTag] = useState<string | null>(null);
const [previewSummary, setPreviewSummary] = useState(true);
const [renaming, setRenaming] = useState(false);
// ponytail: auto-capture dice rolls / initiative turns / encounters into the
// active session — the raw material for a useful AI summary. Muteable.
const [autoLog, setAutoLog] = usePersistentState<boolean>("session.autolog", true);
const { addToast } = useToast();
// ponytail: ensure an active session exists (first run / migration).
@@ -90,6 +94,21 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
updateActive((s) => ({ ...s, entries: s.entries.filter((_, j) => j !== i) }));
}
// ponytail: listener re-registers when the active session or the mute
// toggle changes — no stale closures over activeId.
useEffect(() => {
return bus.on<LogEntryPayload>(Events.LogEntry, ({ text, tag }) => {
if (!autoLog) return;
setSessions((prev) =>
prev.map((s) =>
s.id === activeId
? { ...s, entries: [{ text, time: new Date().toLocaleString(), tag }, ...s.entries] }
: s,
),
);
});
}, [activeId, autoLog, setSessions]);
function addSession() {
const s = newSession(`Session ${sessions.length + 1}`);
setSessions((prev) => [...prev, s]);
@@ -152,6 +171,7 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
system: "You are a helpful D&D session summarizer. Be concise and creative.",
temperature: 0.7,
max_tokens: 512,
ragQuery: active.title,
},
channel,
});
@@ -251,6 +271,17 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
>
Add Note
</button>
<button
onClick={() => setAutoLog((v) => !v)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors cursor-pointer ${
autoLog
? "border-[var(--color-gold-bright)] text-[var(--color-gold-bright)] bg-[var(--color-gold-glow)]"
: "border-[var(--color-border-subtle)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
}`}
title="Auto-capture dice rolls, initiative turns, encounters and calendar days into this session"
>
{autoLog ? "● capturing" : "○ paused"}
</button>
<button
onClick={summarize}
disabled={loading || active.entries.length === 0}
+10
View File
@@ -44,6 +44,7 @@ export const bus = new EventBus();
export const Events = {
AddCombatants: "add-combatants",
PlayAmbient: "play-ambient",
LogEntry: "log-entry",
} as const;
export type AddCombatantsPayload = {
@@ -52,6 +53,15 @@ export type AddCombatantsPayload = {
source: string;
};
/** One auto-captured line for the active session log (dice rolls, initiative
* turns, encounters, calendar days). SessionLogger owns the mute toggle. */
export type LogEntryPayload = {
/** Pre-formatted line, e.g. "🎲 2d6+3 → 11 [4,5]". */
text: string;
/** One of SessionLogger's TAGS; omit for untagged. */
tag?: string;
};
export type PlayAmbientPayload = {
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
id: string;