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
+52
View File
@@ -0,0 +1,52 @@
// ponytail: tiny event bus so tools can push data to each other without
// a shared store. Survives component unmounts via a module-level emitter.
// Use sparingly: only for cross-tool flows (Encounter → Initiative, Item →
// Lore, etc.). The History view stays the single source of truth for
// rehydrating past results.
type Handler<T> = (payload: T) => void;
interface CombatantInput {
name: string;
initiative?: number;
hp: number;
}
class EventBus {
private map = new Map<string, Set<Handler<unknown>>>();
on<T>(event: string, handler: Handler<T>): () => void {
const set = this.map.get(event) ?? new Set();
set.add(handler as Handler<unknown>);
this.map.set(event, set);
return () => {
set.delete(handler as Handler<unknown>);
};
}
emit<T>(event: string, payload: T): void {
const set = this.map.get(event);
if (!set) return;
for (const h of set) {
try {
h(payload);
} catch (e) {
console.error(`[EventBus] handler for ${event} threw:`, e);
}
}
}
}
export const bus = new EventBus();
// ─── Event names + payload types ────────────────────────────
export const Events = {
AddCombatants: "add-combatants",
} as const;
export type AddCombatantsPayload = {
combatants: CombatantInput[];
/** Source label, e.g. "Goblin Ambush (Easy)" — shown in the toast. */
source: string;
};
+64
View File
@@ -0,0 +1,64 @@
// ponytail: 5e DMG p.82 XP thresholds per character. Single source of truth
// for the encounter builder's budget math. The "Easy/Medium/Hard/Deadly"
// thresholds are *per character*; the table is indexed by character level.
export type Difficulty = "Easy" | "Medium" | "Hard" | "Deadly";
const THRESHOLDS: Record<number, Record<Difficulty, number>> = {
1: { Easy: 25, Medium: 50, Hard: 75, Deadly: 100 },
2: { Easy: 50, Medium: 100, Hard: 150, Deadly: 200 },
3: { Easy: 75, Medium: 150, Hard: 225, Deadly: 400 },
4: { Easy: 125, Medium: 250, Hard: 375, Deadly: 500 },
5: { Easy: 250, Medium: 500, Hard: 750, Deadly: 1100 },
6: { Easy: 300, Medium: 600, Hard: 900, Deadly: 1400 },
7: { Easy: 350, Medium: 750, Hard: 1100, Deadly: 1700 },
8: { Easy: 450, Medium: 900, Hard: 1400, Deadly: 2100 },
9: { Easy: 550, Medium: 1100, Hard: 1600, Deadly: 2400 },
10: { Easy: 600, Medium: 1200, Hard: 1900, Deadly: 2800 },
11: { Easy: 800, Medium: 1600, Hard: 2400, Deadly: 3600 },
12: { Easy: 1000, Medium: 2000, Hard: 3000, Deadly: 4500 },
13: { Easy: 1100, Medium: 2200, Hard: 3400, Deadly: 5100 },
14: { Easy: 1250, Medium: 2500, Hard: 3800, Deadly: 5700 },
15: { Easy: 1400, Medium: 2800, Hard: 4300, Deadly: 6400 },
16: { Easy: 1600, Medium: 3200, Hard: 4800, Deadly: 7200 },
17: { Easy: 2000, Medium: 3900, Hard: 5900, Deadly: 8800 },
18: { Easy: 2100, Medium: 4200, Hard: 6300, Deadly: 9500 },
19: { Easy: 2400, Medium: 4900, Hard: 7300, Deadly: 10900 },
20: { Easy: 2800, Medium: 5700, Hard: 8500, Deadly: 12700 },
};
export interface Budget {
easy: number;
medium: number;
hard: number;
deadly: number;
}
/** Compute per-difficulty XP budgets for a party. Clamps level to 1..20. */
export function encounterBudget(partyLevel: number, partySize: number): Budget {
const lvl = Math.max(1, Math.min(20, Math.round(partyLevel)));
const size = Math.max(1, Math.round(partySize));
const t = THRESHOLDS[lvl];
return {
easy: t.Easy * size,
medium: t.Medium * size,
hard: t.Hard * size,
deadly: t.Deadly * size,
};
}
/** Map an XP total to a difficulty bucket. */
export function difficultyForXp(xp: number, budget: Budget): Difficulty {
if (xp >= budget.deadly) return "Deadly";
if (xp >= budget.hard) return "Hard";
if (xp >= budget.medium) return "Medium";
if (xp >= budget.easy) return "Easy";
return "Easy";
}
export const DIFFICULTY_COLOR: Record<Difficulty, string> = {
Easy: "var(--color-success)",
Medium: "var(--color-info)",
Hard: "var(--color-gold-bright)",
Deadly: "var(--color-danger)",
};
+140
View File
@@ -0,0 +1,140 @@
import { invoke } from "@tauri-apps/api/core";
export type GenerationKind =
| "npc"
| "encounter"
| "world"
| "item"
| "quest"
| "session"
| "image";
export interface GenerationSummary {
id: number;
kind: GenerationKind;
title: string;
/** Epoch ms */
createdAt: number;
}
export interface Generation {
id: number;
kind: GenerationKind;
title: string;
/** Raw JSON string from the LLM (or data URL for image kind). */
data: string;
source: string | null;
createdAt: number;
}
export interface AddGenerationRequest {
kind: GenerationKind;
title: string;
data: string;
source?: string;
}
// ponytail: thin wrapper. Every generator that succeeds calls addGeneration;
// the History view lists/gets/deletes via these helpers.
export async function addGeneration(req: AddGenerationRequest): Promise<number> {
return invoke<number>("generation_add", { req });
}
export async function listGenerations(
kind?: GenerationKind,
): Promise<GenerationSummary[]> {
return invoke<GenerationSummary[]>("generation_list", { kind: kind ?? null });
}
export async function getGeneration(id: number): Promise<Generation | null> {
return invoke<Generation | null>("generation_get", { id });
}
export async function deleteGeneration(id: number): Promise<number> {
return invoke<number>("generation_delete", { id });
}
export async function clearAllGenerations(): Promise<number> {
return invoke<number>("generation_delete", { id: null });
}
export async function generationCounts(): Promise<Record<string, number>> {
const pairs = await invoke<[string, number][]>("generation_counts");
return Object.fromEntries(pairs);
}
// ─── Title extraction ────────────────────────────────────────
// ponytail: each LLM response shape has a slightly different "title" field.
// Centralize so the history list can label rows without re-parsing in the UI.
const TITLE_KEYS: Record<GenerationKind, string[]> = {
npc: ["name"],
encounter: ["title"],
world: ["name"],
item: ["name"],
quest: ["title"],
session: ["title"],
image: [],
};
/**
* Pull a human-readable title out of an LLM JSON string. Falls back to the
* caller's `fallback` if the JSON is malformed or has no recognized field.
*/
export function extractTitle(
kind: GenerationKind,
data: string,
fallback: string,
): string {
try {
const obj = JSON.parse(data);
for (const k of TITLE_KEYS[kind]) {
const v = obj?.[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
} catch {
// Not JSON — for image kind the data is a base64 PNG data URL.
}
return fallback;
}
// ─── Time formatting ─────────────────────────────────────────
/** Compact relative time for the history list: "just now", "3m", "2h", "5d". */
export function relativeTime(epochMs: number, now: number = Date.now()): string {
const diff = Math.max(0, now - epochMs);
const s = Math.floor(diff / 1000);
if (s < 45) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
const date = new Date(epochMs);
return date.toLocaleDateString();
}
// ─── Defaults (one source of truth for kind label/icon) ──────
export const KIND_LABELS: Record<GenerationKind, string> = {
npc: "NPC",
encounter: "Encounter",
world: "World",
item: "Item",
quest: "Quest",
session: "Session",
image: "Image",
};
export const KIND_ORDER: GenerationKind[] = [
"npc",
"encounter",
"world",
"item",
"quest",
"session",
"image",
];
+16
View File
@@ -0,0 +1,16 @@
import { invoke } from "@tauri-apps/api/core";
/**
* Best-effort lore ingestion. Every generated result gets indexed into the
* local RAG store so future generations stay consistent with everything the
* DM has already created. Errors are swallowed — lore is augmentation, never
* a blocker on the generation UX.
*/
export async function addToLore(source: string, text: string): Promise<void> {
if (!source.trim() || !text.trim()) return;
try {
await invoke("rag_add", { req: { source, text } });
} catch (e) {
console.warn("addToLore failed:", e);
}
}
+95
View File
@@ -0,0 +1,95 @@
// ponytail: 5e SRD races (9) and PHB-style backgrounds (16). The original
// generator only had 8 races and a free-text "Class" that conflated classes
// and backgrounds. These lists disambiguate.
export const RACES = [
"Human",
"Elf",
"Dwarf",
"Halfling",
"Gnome",
"Dragonborn",
"Tiefling",
"Half-Orc",
"Goliath",
] as const;
// ponytail: PHB backgrounds. 5e calls these "backgrounds" and they cover
// occupation, social standing, and starting equipment. They're the right
// concept for an NPC generator — most named NPCs have a background, not
// necessarily a class.
export const BACKGROUNDS = [
"Acolyte",
"Charlatan",
"Criminal",
"Entertainer",
"Folk Hero",
"Guild Artisan",
"Hermit",
"Noble",
"Outlander",
"Sage",
"Sailor",
"Soldier",
"Urchin",
] as const;
export const ALIGNMENTS = [
"Lawful Good",
"Neutral Good",
"Chaotic Good",
"Lawful Neutral",
"True Neutral",
"Chaotic Neutral",
"Lawful Evil",
"Neutral Evil",
"Chaotic Evil",
] as const;
// ponytail: per-race name banks. Tiny but functional; full banks live in
// the SRD. Sufficient for a "random" button on the form.
const NAMES_BY_RACE: Record<string, { first: string[]; last: string[] }> = {
Human: {
first: ["Aldric", "Branwen", "Cassia", "Doran", "Elara", "Fendrel", "Gwynne", "Hadrian", "Isobel", "Joren"],
last: ["Ashford", "Brightwood", "Carrow", "Dunharrow", "Emberlain", "Frostvale", "Greycastle", "Holloway"],
},
Elf: {
first: ["Aelar", "Beiro", "Carric", "Drannor", "Enna", "Faelar", "Galinndan", "Hadarai", "Immeral", "Ivellios"],
last: ["Amakir", "Galanodel", "Holimion", "Liadon", "Meliamne", "Nailo", "Siannodel"],
},
Dwarf: {
first: ["Adrik", "Baern", "Brottor", "Dain", "Eberk", "Gardain", "Harbek", "Kildrak", "Morgran", "Orsik"],
last: ["Balderk", "Battlehammer", "Brawnanvil", "Dankil", "Fireforge", "Frostbeard", "Gorunn", "Holderhek", "Ironfist"],
},
Halfling: {
first: ["Alton", "Beau", "Cade", "Eldon", "Garret", "Lyle", "Milo", "Osborn", "Perrin", "Reed"],
last: ["Brushgather", "Goodbarrel", "Greenbottle", "High-hill", "Hilltopple", "Leagallow", "Tealeaf", "Thorngage"],
},
Gnome: {
first: ["Alston", "Boddynock", "Brocc", "Burgell", "Dimble", "Eldon", "Erky", "Fonkin", "Frug", "Gerbo"],
last: ["Beren", "Daergel", "Folkor", "Garrick", "Nackle", "Murnig", "Ningel", "Raulnor", "Scheppen", "Timbers"],
},
Dragonborn: {
first: ["Arjhan", "Balasar", "Bharash", "Donaar", "Ghesh", "Heskan", "Kriv", "Medrash", "Mehen", "Nadarr"],
last: ["Clethtinthiallor", "Daardendrian", "Delmirev", "Drachedandion", "Fenkenkabradon", "Kepeshkmolik", "Kerrhylon", "Kimbatuul", "Linxakasendalor", "Myastan"],
},
Tiefling: {
first: ["Akmenos", "Amnon", "Barakas", "Damakos", "Ekemon", "Iados", "Kairon", "Leucis", "Melech", "Mordai"],
last: ["Carrion", "Glory", "Hope", "Ideal", "Music", "Nowhere", "Quest", "Random", "Sorrow", "Temerity"],
},
"Half-Orc": {
first: ["Dench", "Feng", "Gell", "Henk", "Holg", "Imsh", "Keth", "Krusk", "Mhurren", "Ront"],
last: ["Bloodfang", "Cleavehowl", "Grimaxe", "Ironjaw", "Skullbelly", "Stoneheart", "Wartooth"],
},
Goliath: {
first: ["Aukan", "Eglath", "Gae-Al", "Gauthak", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith"],
last: ["Anakir", "Dukan", "Gatholos", "Ilikan", "Keothi", "Kuori", "Lo-Kag", "Manneo", "Maveith", "Nalla"],
},
};
export function randomName(race: string): string {
const bank = NAMES_BY_RACE[race] ?? NAMES_BY_RACE.Human;
const first = bank.first[Math.floor(Math.random() * bank.first.length)];
const last = bank.last[Math.floor(Math.random() * bank.last.length)];
return `${first} ${last}`;
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from "react";
import type { Generation, GenerationKind } from "../lib/generations";
/**
* The shape each generator's prefill callback expects. Tools accept `prefill`
* (a Generation) and call this hook to consume it once. The hook calls
* `onConsumed` to tell the parent to clear the pending prefill, so re-renders
* don't re-trigger the effect.
*/
export function usePrefillEffect<T extends Generation>(
prefill: T | null,
kind: GenerationKind,
onConsumed: () => void,
apply: (data: T) => void,
) {
useEffect(() => {
if (prefill && prefill.kind === kind) {
apply(prefill as T);
onConsumed();
}
// Intentional: run only when prefill id changes (mount or rehydrate).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefill?.id]);
}