Files
dm-pal/src/components/EncounterBuilder.tsx
T
2026-07-12 22:13:43 +01:00

437 lines
15 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 } 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 [error, setError] = useState("");
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);
setError("");
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,
},
});
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)}`,
);
} else {
setError("Could not parse LLM response. Try again or check your LLM connection.");
addToast("LLM returned an unparseable response", "error");
}
} catch (e) {
setError(String(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",
);
}
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>
{error && (
<div className="rounded-lg bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/30 p-2 text-[var(--color-danger)] text-xs">
{error}
</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>
</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,
};