Files
dm-pal/src/components/EncounterBuilder.tsx
T
itsamejms 3c599553fc
CI / frontend (push) Successful in 31s
CI / rust (push) Successful in 6m3s
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.
2026-09-07 10:25:38 +01:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
import { useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { addToLore } from "../lib/lore";
import { useToast } from "./Toast";
import { addGeneration, extractTitle, type Generation } from "../lib/generations";
import { usePrefillEffect } from "../lib/usePrefill";
import { bus, Events, type AddCombatantsPayload, type PlayAmbientPayload } from "../lib/bus";
import { encounterBudget, difficultyForXp, DIFFICULTY_COLOR, type Difficulty } from "../lib/encounter-budget";
interface Encounter {
monsters: string[];
terrain: string;
difficulty: string;
loot: string;
}
interface Props {
prefill?: Generation | null;
onPrefillConsumed?: () => void;
}
// ponytail: 5e terrains with icons. The plan called for 12; we now have
// 12 (added planar, haunted, ship, siege to the original 8).
const TERRAINS: { value: string; icon: string }[] = [
{ value: "Forest", icon: "🌲" },
{ value: "Dungeon", icon: "🏰" },
{ value: "Urban", icon: "🏛" },
{ value: "Mountain", icon: "⛰" },
{ value: "Desert", icon: "🏜" },
{ value: "Swamp", icon: "🌫" },
{ value: "Coastal", icon: "🌊" },
{ value: "Underdark", icon: "🕳" },
{ value: "Planar", icon: "✈" },
{ value: "Haunted", icon: "🏚" },
{ value: "Ship", icon: "🚢" },
{ value: "Siege", icon: "🏰" },
];
// ponytail: parse "3x Goblin Scouts" or "2 bandits" out of the LLM's monster
// string. Returns the count + the cleaned name. If no count is found, defaults
// to 1. Used when sending to the Initiative Tracker.
function parseMonsterLine(raw: string): { name: string; count: number } {
const m = raw.match(/^\s*(\d+)\s*[xX*]?\s+(.+)$/);
if (m) {
return { count: Math.max(1, parseInt(m[1], 10)), name: m[2].trim() };
}
return { count: 1, name: raw.trim() };
}
// ponytail: rough HP estimate by CR. Not strictly DMG-correct, but it gives
// the DM a starting point that they can edit on the Initiative Tracker. The
// table is from a synthesis of DMG averages; the DM is expected to override
// per monster. "—" for unknown CR.
const CR_TO_HP: Record<string, number> = {
"0": 4,
"0.125": 9,
"0.25": 17,
"0.5": 28,
"1": 39,
"2": 49,
"3": 64,
"4": 84,
"5": 109,
"6": 133,
"7": 156,
"8": 178,
"9": 200,
"10": 222,
"11": 244,
"12": 267,
"13": 289,
"14": 311,
"15": 333,
"16": 356,
"17": 378,
"18": 400,
"19": 422,
"20": 444,
"21": 467,
"22": 489,
"23": 511,
"24": 533,
"25": 556,
"26": 578,
"27": 600,
"28": 622,
"29": 644,
"30": 667,
};
function hpForCr(cr: string | null | undefined): number {
if (!cr) return 30; // ponytail: default to ~CR 1 HP if no CR known.
const hit = CR_TO_HP[cr.trim()];
return hit ?? 30;
}
// ponytail: very lightweight CR detection from a monster name. Looks for
// common ordinals ("CR 5"), trailing " (CR 1/4)" annotations, or specific
// monsters. The DM can edit the HP on the Initiative Tracker once imported.
const KNOWN_CR: Record<string, string> = {
goblin: "0.25",
"goblin scout": "0.25",
hobgoblin: "0.5",
orc: "0.5",
"orc warrior": "1",
kobold: "0.125",
bandit: "0.125",
"bandit captain": "2",
"bugbear chief": "3",
bugbear: "1",
gnoll: "0.5",
"dire wolf": "1",
wolf: "0.25",
skeleton: "0.25",
zombie: "0.25",
ghoul: "1",
ghast: "2",
wight: "3",
ogre: "2",
troll: "5",
"young red dragon": "10",
"adult red dragon": "17",
"ancient red dragon": "24",
manticore: "3",
griffon: "2",
wyvern: "6",
"ogre chief": "3",
shaman: "2",
};
function crForName(name: string): string | null {
const lower = name.toLowerCase();
if (KNOWN_CR[lower]) return KNOWN_CR[lower];
for (const [key, cr] of Object.entries(KNOWN_CR)) {
if (lower.includes(key)) return cr;
}
// Look for trailing "(CR X)" or "CR 1/2" annotations.
const m = name.match(/\(?CR\s*(\d+\/\d+|\d+)\)?/i);
if (m) return m[1];
return null;
}
export function EncounterBuilder({ prefill, onPrefillConsumed }: Props = {}) {
const [partyLevel, setPartyLevel] = useState(5);
const [partySize, setPartySize] = useState(4);
const [terrain, setTerrain] = useState("Forest");
const [encounter, setEncounter] = useState<Encounter | null>(null);
const [loading, setLoading] = useState(false);
const { addToast } = useToast();
usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => {
const parsed = parseLlmJson<Encounter>(g.data);
if (parsed) {
setEncounter(parsed);
addToast(`Loaded "${g.title}" from history`, "info");
}
});
const budget = useMemo(
() => encounterBudget(partyLevel, partySize),
[partyLevel, partySize],
);
// ponytail: extract a 1-20 XP estimate from the LLM's "3x CR-1" or similar
// wording. The LLM doesn't return CR directly, so we sniff the monster
// names against our small table. If we can't, we fall back to a rough
// estimate by difficulty and surface the uncertainty in the UI.
const computedXp = useMemo(() => {
if (!encounter) return 0;
let total = 0;
for (const raw of ensureArray(encounter.monsters)) {
const { count, name } = parseMonsterLine(flattenValue(raw));
const cr = crForName(name);
if (cr) {
// ponytail: CR to XP per DMG. 1/4 = 50, 1/2 = 100, 1 = 200, etc.
const xp = CR_TO_XP[cr] ?? 0;
total += xp * count;
} else {
total += 50 * count; // fallback: assume ~CR 1/4 average
}
}
return total;
}, [encounter]);
const difficulty: Difficulty | null = encounter
? difficultyForXp(computedXp, budget)
: null;
async function generate() {
setLoading(true);
setEncounter(null);
try {
const result = await invoke<string>("generate", {
req: {
prompt: `Generate a D&D 5e encounter for a party of ${partySize} level-${partyLevel} characters in a ${terrain.toLowerCase()} setting.
Provide the response as a JSON object with exactly these keys:
- "monsters": an array of 2-4 monster descriptions with quantities (e.g. "3x Goblin Scouts") — each must be a string, not an object
- "terrain": a brief terrain description with environmental features
- "difficulty": one of Easy/Medium/Hard/Deadly
- "loot": a brief treasure description
IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO extra text. Start with { and end with }.`,
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);
if (parsed) {
setEncounter(parsed);
addToast("Encounter generated", "success");
const title = extractTitle("encounter", result, `${parsed.difficulty ?? "?"} in ${terrain}`);
const source = `Party of ${partySize} lvl ${partyLevel} in ${terrain}`;
void addGeneration({ kind: "encounter", title, data: result, source });
const monsters = ensureArray(parsed.monsters).map(flattenValue).join(", ");
void addToLore(
`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");
}
} catch (e) {
addToast(`Encounter generation failed: ${e}`, "error");
}
setLoading(false);
}
function sendToInitiative() {
if (!encounter) return;
const combatants = ensureArray(encounter.monsters).flatMap((raw) => {
const { name, count } = parseMonsterLine(flattenValue(raw));
const cr = crForName(name);
const hp = hpForCr(cr);
return Array.from({ length: count }, () => ({ name, hp }));
});
if (combatants.length === 0) {
addToast("No monsters to send", "warning");
return;
}
const payload: AddCombatantsPayload = {
combatants,
source: `${encounter.difficulty ?? "?"} in ${terrain}`,
};
bus.emit(Events.AddCombatants, payload);
addToast(
`Sent ${combatants.length} combatant${combatants.length === 1 ? "" : "s"} to Initiative Tracker — switch to Initiative to roll`,
"success",
);
}
// ponytail: map the encounter's terrain to the closest synthesized ambient.
// Best-effort — the DM can tweak on the Soundboard. Covers the 12 terrains.
const TERRAIN_AMBIENT: Record<string, string> = {
Forest: "forest", Swamp: "rain", Coastal: "ocean", Ocean: "ocean",
Mountain: "wind", Desert: "wind", Underdark: "cave", Dungeon: "cave",
Urban: "tavern", Haunted: "thunder", Planar: "thunder", Ship: "ocean",
Siege: "wind",
};
function sendToSoundboard() {
const id = TERRAIN_AMBIENT[terrain] ?? "wind";
const payload: PlayAmbientPayload = { id, source: `${terrain} ambient` };
bus.emit(Events.PlayAmbient, payload);
addToast(`Sent ${terrain} ambient to Soundboard`, "success");
}
return (
<div className="flex flex-col gap-2 h-full overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Level</label>
<input
type="number"
min={1}
max={20}
value={partyLevel}
onChange={(e) => setPartyLevel(parseInt(e.target.value) || 1)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Party Size</label>
<input
type="number"
min={1}
max={10}
value={partySize}
onChange={(e) => setPartySize(parseInt(e.target.value) || 1)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[var(--color-text-secondary)] text-[10px] font-medium">Terrain</label>
<select
value={terrain}
onChange={(e) => setTerrain(e.target.value)}
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-2 py-1.5 text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-gold-bright)] text-xs cursor-pointer"
>
{TERRAINS.map((t) => (
<option key={t.value} value={t.value}>
{t.icon} {t.value}
</option>
))}
</select>
</div>
{/* Budget readout — shows the DM what XP thresholds they're working with. */}
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] p-2 text-[10px] font-mono">
<div className="flex justify-between text-[var(--color-text-dim)] mb-1">
<span>Budget (party of {partySize} lvl {partyLevel})</span>
</div>
<div className="grid grid-cols-4 gap-1 text-center">
<BudgetCell label="Easy" value={budget.easy} color={DIFFICULTY_COLOR.Easy} active={difficulty === "Easy"} />
<BudgetCell label="Med" value={budget.medium} color={DIFFICULTY_COLOR.Medium} active={difficulty === "Medium"} />
<BudgetCell label="Hard" value={budget.hard} color={DIFFICULTY_COLOR.Hard} active={difficulty === "Hard"} />
<BudgetCell label="Dead" value={budget.deadly} color={DIFFICULTY_COLOR.Deadly} active={difficulty === "Deadly"} />
</div>
</div>
<button
onClick={generate}
disabled={loading}
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-xs font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
>
{loading ? "⚔ Generating…" : "⚔ Generate Encounter"}
</button>
{!encounter && !loading && (
<div className="flex flex-col items-center justify-center py-8 text-center text-[var(--color-text-dim)] text-xs">
<span>No encounter yet.</span>
<span className="mt-1">Set the party + terrain, then Generate.</span>
</div>
)}
{encounter && (
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span
className="font-heading text-xs font-semibold rounded-full px-2 py-0.5"
style={{
color: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
borderColor: difficulty ? DIFFICULTY_COLOR[difficulty] : "var(--color-gold-bright)",
borderWidth: 1,
}}
>
{difficulty ?? encounter.difficulty} Encounter
</span>
<span className="text-[var(--color-text-dim)] text-[10px]">
~{computedXp} XP · L{partyLevel}×{partySize}
</span>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Monsters</span>
<ul className="list-disc list-inside text-[var(--color-text-primary)] text-xs mt-0.5">
{ensureArray(encounter.monsters).map((m, i) => <li key={i}>{flattenValue(m)}</li>)}
</ul>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Terrain Features</span>
<p className="text-[var(--color-text-primary)] text-xs mt-0.5">{encounter.terrain}</p>
</div>
<div>
<span className="text-[var(--color-text-secondary)] text-[10px] font-medium">Loot</span>
<p className="text-[var(--color-gold-bright)] text-xs mt-0.5">{encounter.loot}</p>
</div>
<button
onClick={sendToInitiative}
className="mt-1 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-1.5 text-xs font-semibold hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center justify-center gap-1.5"
title="Add these monsters to the Initiative Tracker"
>
⚔ Send to Initiative Tracker
</button>
<button
onClick={sendToSoundboard}
className="rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border-glass)] text-[var(--color-gold-bright)] px-3 py-1.5 text-xs font-semibold hover:border-[var(--color-gold-bright)] transition-colors cursor-pointer flex items-center justify-center gap-1.5"
title="Play an ambient matching the terrain"
>
🎧 Send to Soundboard
</button>
</div>
)}
</div>
);
}
function BudgetCell({
label,
value,
color,
active,
}: {
label: string;
value: number;
color: string;
active: boolean;
}) {
return (
<div
className={`rounded px-1 py-0.5 transition-colors ${
active ? "bg-[var(--color-bg-card)]" : ""
}`}
style={{ color: active ? color : "var(--color-text-dim)" }}
>
<div className="text-[9px] uppercase tracking-wider">{label}</div>
<div className="font-bold">{value}</div>
</div>
);
}
// ponytail: CR → XP. From the DMG encounter-building table. Used to estimate
// the XP total of a generated encounter when computing difficulty.
const CR_TO_XP: Record<string, number> = {
"0": 10,
"0.125": 25,
"0.25": 50,
"0.5": 100,
"1": 200,
"2": 450,
"3": 700,
"4": 1100,
"5": 1800,
"6": 2300,
"7": 2900,
"8": 3900,
"9": 5000,
"10": 5900,
"11": 7200,
"12": 8400,
"13": 10000,
"14": 11500,
"15": 13000,
"16": 15000,
"17": 18000,
"18": 20000,
"19": 22000,
"20": 25000,
"21": 33000,
"22": 41000,
"23": 50000,
"24": 62000,
"25": 75000,
"26": 90000,
"27": 105000,
"28": 120000,
"29": 135000,
"30": 155000,
};