working through the plan + UI/ UX
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse";
|
||||
import { useState } from "react";
|
||||
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[];
|
||||
@@ -9,13 +15,178 @@ interface Encounter {
|
||||
loot: string;
|
||||
}
|
||||
|
||||
export function EncounterBuilder() {
|
||||
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);
|
||||
@@ -41,15 +212,49 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
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">
|
||||
@@ -84,12 +289,27 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
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"
|
||||
>
|
||||
{["Forest", "Dungeon", "Urban", "Mountain", "Desert", "Swamp", "Coastal", "Underdark"].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
{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}
|
||||
@@ -107,11 +327,18 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
{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-[var(--color-gold-bright)] text-xs font-semibold">
|
||||
{encounter.difficulty} Encounter
|
||||
<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]">
|
||||
Level {partyLevel} × {partySize}
|
||||
~{computedXp} XP · L{partyLevel}×{partySize}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -131,8 +358,79 @@ IMPORTANT: Return ONLY a raw JSON object. NO markdown fences, NO code blocks, NO
|
||||
<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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user