working through the plan + UI/ UX
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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)",
|
||||
};
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user