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 = { "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 = { 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const { addToast } = useToast(); usePrefillEffect(prefill ?? null, "encounter", () => onPrefillConsumed?.(), (g) => { const parsed = parseLlmJson(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("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(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 (
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" />
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" />
{/* Budget readout β€” shows the DM what XP thresholds they're working with. */}
Budget (party of {partySize} lvl {partyLevel})
{error && (
{error}
)} {encounter && (
{difficulty ?? encounter.difficulty} Encounter ~{computedXp} XP Β· L{partyLevel}Γ—{partySize}
Monsters
    {ensureArray(encounter.monsters).map((m, i) =>
  • {flattenValue(m)}
  • )}
Terrain Features

{encounter.terrain}

Loot

{encounter.loot}

)}
); } function BudgetCell({ label, value, color, active, }: { label: string; value: number; color: string; active: boolean; }) { return (
{label}
{value}
); } // 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 = { "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, };