import { parseLlmJson, ensureArray, flattenValue } from "../lib/llm-parse"; import { useState, useEffect } 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 { usePersistentState } from "../lib/usePersistentState"; import { WorldMap } from "./WorldMap"; interface WorldResponse { name: string; description: string; regions: string[]; landmarks: string[]; conflicts: string[]; cultures: string[]; // ponytail: DM-placed pin positions (normalized 0..1). UI-managed, not // returned by the LLM. Persisted with the world. pins?: Record; } interface Props { prefill?: Generation | null; onPrefillConsumed?: () => void; } export function WorldBuilder({ prefill, onPrefillConsumed }: Props = {}) { // ponytail: persist theme + last world so a reload keeps the DM's work. const [theme, setTheme] = usePersistentState("world.theme", "high fantasy"); const [world, setWorld] = usePersistentState("world.last", null); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); const { addToast } = useToast(); // ponytail: record a dragged pin's position into the world so it persists. function onPinMove(name: string, x: number, y: number) { setWorld((w) => w ? { ...w, pins: { ...(w.pins ?? {}), [name]: { x, y } } } : w); } // ponytail: drop a selection that no longer exists on the map (e.g. // after a regenerate or loading a different world from history). useEffect(() => { if (!world || !selected) return; const names = [...ensureArray(world.regions), ...ensureArray(world.landmarks)].map(flattenValue); if (!names.includes(selected)) setSelected(null); }, [world, selected]); // ponytail: genre presets as a quick-pick row. const THEME_PRESETS = [ "high fantasy", "dark fantasy", "sword & sorcery", "steampunk", "post-apocalyptic", "horror", "sci-fantasy", ]; usePrefillEffect(prefill ?? null, "world", () => onPrefillConsumed?.(), (g) => { const parsed = parseLlmJson(g.data); if (parsed) { setWorld(parsed); addToast(`Loaded "${g.title}" from history`, "info"); } }); async function generate() { setLoading(true); setWorld(null); try { const result = await invoke("generate", { req: { prompt: `Create a detailed fantasy world with the theme: "${theme}". Provide the response as a JSON object with exactly these keys: - "name": the world's name - "description": a 2-3 sentence overview - "regions": an array of 3-4 region names with brief descriptions (each must be a string) - "landmarks": an array of 2-3 notable landmarks (each must be a string) - "conflicts": an array of 2-3 major conflicts or tensions (each must be a string) - "cultures": an array of 2-3 distinct cultures or peoples (each must be a string) 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 creative world-building DM. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.9, max_tokens: 600, ragQuery: `fantasy world ${theme} regions landmarks cultures`, }, }); const parsed = parseLlmJson(result); if (parsed) { setWorld(parsed); addToast("World generated", "success"); const title = extractTitle("world", result, theme); const source = `Theme: ${theme}`; void addGeneration({ kind: "world", title, data: result, source }); const regions = ensureArray(parsed.regions).map(flattenValue).join("; "); const landmarks = ensureArray(parsed.landmarks).map(flattenValue).join("; "); const conflicts = ensureArray(parsed.conflicts).map(flattenValue).join("; "); const cultures = ensureArray(parsed.cultures).map(flattenValue).join("; "); void addToLore( `World: ${parsed.name || theme}`, `${parsed.name || "World"} (${theme}). ${parsed.description}\nRegions: ${regions}\nLandmarks: ${landmarks}\nConflicts: ${conflicts}\nCultures: ${cultures}`, ); } else { addToast("LLM returned an unparseable response", "error"); } } catch (e) { addToast(`World generation failed: ${e}`, "error"); } setLoading(false); } return (
{THEME_PRESETS.map((t) => ( ))}
setTheme(e.target.value)} placeholder="e.g. high fantasy, dark post-apocalyptic, steampunk..." onKeyDown={(e) => e.key === "Enter" && generate()} />
{world && (
{/* World name and description */}

{world.name}

{world.description}

{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}

Drag pins to place regions (●) and landmarks (✦). Click a pin to select it.

{/* Selected pin detail, or a hint to pick one. */}
{selected ? ( <> {ensureArray(world.regions).map(flattenValue).includes(selected) ? "Region" : "Landmark"}

{selected}

) : (

Click a pin on the map to inspect it here.

)}
{/* Regions (clickable → select on map) */} {world.regions?.length > 0 && (

🏔 Regions

{ensureArray(world.regions).map((r, i) => { const name = flattenValue(r); return ( ); })}
)} {/* Conflicts */} {world.conflicts?.length > 0 && (

⚔ Conflicts

{ensureArray(world.conflicts).map((c, i) => (
{flattenValue(c)}
))}
)} {/* Cultures */} {world.cultures?.length > 0 && (

👥 Cultures

{ensureArray(world.cultures).map((c, i) => ( {flattenValue(c)} ))}
)}
)}
); }