working through the plan + UI/ UX

This commit is contained in:
itsamejms
2026-07-12 22:13:43 +01:00
parent 5b256242be
commit a24f3615e0
38 changed files with 5295 additions and 328 deletions
+306 -8
View File
@@ -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,
};