437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
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,
|
||
};
|