feat: session auto-capture + NPC→Initiative push + lore grounding everywhere
- 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:
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
import { bus, Events } from "../lib/bus";
|
||||||
|
|
||||||
// ponytail: the calendar is user-configurable for homebrew campaigns.
|
// ponytail: the calendar is user-configurable for homebrew campaigns.
|
||||||
// `months` carry their own day count so a custom calendar can have variable
|
// `months` carry their own day count so a custom calendar can have variable
|
||||||
@@ -106,12 +107,15 @@ export function CalendarWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nextDay() {
|
function nextDay() {
|
||||||
setToday((t) => {
|
let { day, month, year } = today;
|
||||||
let { day, month, year } = t;
|
|
||||||
day += 1;
|
day += 1;
|
||||||
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
|
if (day > daysInMonth(config, month)) { day = 1; month += 1; }
|
||||||
if (month >= config.months.length) { month = 0; year += 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)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { usePersistentState } from "../lib/usePersistentState";
|
import { usePersistentState } from "../lib/usePersistentState";
|
||||||
|
import { bus, Events } from "../lib/bus";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
import { parseNotation, applyMode, type Mode, type DieResult } from "../lib/dice";
|
||||||
|
|
||||||
@@ -58,6 +59,12 @@ export function DiceRoller() {
|
|||||||
|
|
||||||
setResults((prev) => [result, ...prev].slice(0, 50));
|
setResults((prev) => [result, ...prev].slice(0, 50));
|
||||||
setLastTotal(sum);
|
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);
|
setLastBreakdown(result);
|
||||||
},
|
},
|
||||||
[input, mode],
|
[input, mode],
|
||||||
|
|||||||
@@ -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.",
|
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,
|
temperature: 0.9,
|
||||||
max_tokens: 400,
|
max_tokens: 400,
|
||||||
|
ragQuery: `${terrain} monsters encounter`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const parsed = parseLlmJson<Encounter>(result);
|
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: ${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)}`,
|
`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 {
|
} else {
|
||||||
addToast("LLM returned an unparseable response", "error");
|
addToast("LLM returned an unparseable response", "error");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,17 +161,20 @@ export function InitiativeTracker() {
|
|||||||
if (combatants.length === 0) return;
|
if (combatants.length === 0) return;
|
||||||
if (!activeId) {
|
if (!activeId) {
|
||||||
setActiveId(combatants[0].id);
|
setActiveId(combatants[0].id);
|
||||||
|
bus.emit(Events.LogEntry, { text: `⚔ Combat starts — ${combatants[0].name}`, tag: "#combat" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const idx = combatants.findIndex((c) => c.id === activeId);
|
const idx = combatants.findIndex((c) => c.id === activeId);
|
||||||
if (idx === combatants.length - 1) {
|
if (idx === combatants.length - 1) {
|
||||||
setRound((r) => r + 1);
|
setRound((r) => r + 1);
|
||||||
setActiveId(combatants[0].id);
|
setActiveId(combatants[0].id);
|
||||||
|
bus.emit(Events.LogEntry, { text: `⚔ Round ${round + 1} — ${combatants[0].name}'s turn`, tag: "#combat" });
|
||||||
} else {
|
} else {
|
||||||
setActiveId(combatants[idx + 1].id);
|
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.
|
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
|
// 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
|
// the red zero sit until the DM advances (don't auto-advance — that would
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { GeneratedImage } from "./GeneratedImage";
|
import { GeneratedImage } from "./GeneratedImage";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { bus, Events } from "../lib/bus";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
@@ -71,6 +72,18 @@ export function NpcGenerator({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
if (!npc) return;
|
if (!npc) return;
|
||||||
setNpc({ ...npc, goals: npc.goals.filter((_, j) => j !== i) });
|
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() {
|
function addGoal() {
|
||||||
if (!npc || !newGoal.trim()) return;
|
if (!npc || !newGoal.trim()) return;
|
||||||
setNpc({ ...npc, goals: [...npc.goals, newGoal.trim()] });
|
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
|
✨ new portrait
|
||||||
</button>
|
</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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-sm">
|
||||||
|
|||||||
@@ -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.",
|
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,
|
temperature: 0.85,
|
||||||
max_tokens: 800,
|
max_tokens: 800,
|
||||||
|
ragQuery: `${theme} quest ${level}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const parsed = parseLlmJson<QuestResponse>(result);
|
const parsed = parseLlmJson<QuestResponse>(result);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { invoke, Channel } from "@tauri-apps/api/core";
|
import { invoke, Channel } from "@tauri-apps/api/core";
|
||||||
import { addToLore } from "../lib/lore";
|
import { addToLore } from "../lib/lore";
|
||||||
|
import { bus, Events, type LogEntryPayload } from "../lib/bus";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
import { addGeneration, type Generation } from "../lib/generations";
|
import { addGeneration, type Generation } from "../lib/generations";
|
||||||
import { usePrefillEffect } from "../lib/usePrefill";
|
import { usePrefillEffect } from "../lib/usePrefill";
|
||||||
@@ -45,6 +46,9 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
const [filterTag, setFilterTag] = useState<string | null>(null);
|
const [filterTag, setFilterTag] = useState<string | null>(null);
|
||||||
const [previewSummary, setPreviewSummary] = useState(true);
|
const [previewSummary, setPreviewSummary] = useState(true);
|
||||||
const [renaming, setRenaming] = useState(false);
|
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();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
// ponytail: ensure an active session exists (first run / migration).
|
// 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) }));
|
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() {
|
function addSession() {
|
||||||
const s = newSession(`Session ${sessions.length + 1}`);
|
const s = newSession(`Session ${sessions.length + 1}`);
|
||||||
setSessions((prev) => [...prev, s]);
|
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.",
|
system: "You are a helpful D&D session summarizer. Be concise and creative.",
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
max_tokens: 512,
|
max_tokens: 512,
|
||||||
|
ragQuery: active.title,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
});
|
});
|
||||||
@@ -251,6 +271,17 @@ export function SessionLogger({ prefill, onPrefillConsumed }: Props = {}) {
|
|||||||
>
|
>
|
||||||
Add Note
|
Add Note
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={summarize}
|
onClick={summarize}
|
||||||
disabled={loading || active.entries.length === 0}
|
disabled={loading || active.entries.length === 0}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const bus = new EventBus();
|
|||||||
export const Events = {
|
export const Events = {
|
||||||
AddCombatants: "add-combatants",
|
AddCombatants: "add-combatants",
|
||||||
PlayAmbient: "play-ambient",
|
PlayAmbient: "play-ambient",
|
||||||
|
LogEntry: "log-entry",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type AddCombatantsPayload = {
|
export type AddCombatantsPayload = {
|
||||||
@@ -52,6 +53,15 @@ export type AddCombatantsPayload = {
|
|||||||
source: string;
|
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 = {
|
export type PlayAmbientPayload = {
|
||||||
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
|
/** Ambient sound id from the Soundboard (rain, wind, fire, …). */
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user