From 3c599553fc03fe37d61966970f4f77b5f0e2994e Mon Sep 17 00:00:00 2001 From: itsamejms Date: Mon, 7 Sep 2026 10:25:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20session=20auto-capture=20+=20NPC?= =?UTF-8?q?=E2=86=92Initiative=20push=20+=20lore=20grounding=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/components/CalendarWidget.tsx | 16 ++++++++------ src/components/DiceRoller.tsx | 7 +++++++ src/components/EncounterBuilder.tsx | 6 ++++++ src/components/InitiativeTracker.tsx | 5 ++++- src/components/NpcGenerator.tsx | 20 ++++++++++++++++++ src/components/QuestDesigner.tsx | 1 + src/components/SessionLogger.tsx | 31 ++++++++++++++++++++++++++++ src/lib/bus.ts | 10 +++++++++ 8 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/components/CalendarWidget.tsx b/src/components/CalendarWidget.tsx index 076c6c6..d98d5ac 100644 --- a/src/components/CalendarWidget.tsx +++ b/src/components/CalendarWidget.tsx @@ -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; - day += 1; - if (day > daysInMonth(config, month)) { day = 1; month += 1; } - if (month >= config.months.length) { month = 0; year += 1; } - return { day, month, year }; + 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; } + 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)}`, }); } diff --git a/src/components/DiceRoller.tsx b/src/components/DiceRoller.tsx index 6370dbb..50ba809 100644 --- a/src/components/DiceRoller.tsx +++ b/src/components/DiceRoller.tsx @@ -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], diff --git a/src/components/EncounterBuilder.tsx b/src/components/EncounterBuilder.tsx index 74c087c..d1638c8 100644 --- a/src/components/EncounterBuilder.tsx +++ b/src/components/EncounterBuilder.tsx @@ -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(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"); } diff --git a/src/components/InitiativeTracker.tsx b/src/components/InitiativeTracker.tsx index d6dbf75..92b42b9 100644 --- a/src/components/InitiativeTracker.tsx +++ b/src/components/InitiativeTracker.tsx @@ -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 diff --git a/src/components/NpcGenerator.tsx b/src/components/NpcGenerator.tsx index 111a33a..2a49853 100644 --- a/src/components/NpcGenerator.tsx +++ b/src/components/NpcGenerator.tsx @@ -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 +

diff --git a/src/components/QuestDesigner.tsx b/src/components/QuestDesigner.tsx index f429188..30965ee 100644 --- a/src/components/QuestDesigner.tsx +++ b/src/components/QuestDesigner.tsx @@ -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(result); diff --git a/src/components/SessionLogger.tsx b/src/components/SessionLogger.tsx index 5ff127a..30a942c 100644 --- a/src/components/SessionLogger.tsx +++ b/src/components/SessionLogger.tsx @@ -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(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("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(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 +