252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
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<string, { x: number; y: number }>;
|
|
}
|
|
|
|
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<string>("world.theme", "high fantasy");
|
|
const [world, setWorld] = usePersistentState<WorldResponse | null>("world.last", null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [selected, setSelected] = useState<string | null>(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<WorldResponse>(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<string>("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<WorldResponse>(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 (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-[var(--color-text-secondary)] text-xs font-medium">
|
|
World Theme
|
|
</label>
|
|
<div className="flex flex-wrap gap-1.5 mb-1">
|
|
{THEME_PRESETS.map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setTheme(t)}
|
|
className={`rounded-lg px-2.5 py-1 text-xs border transition-colors cursor-pointer ${
|
|
theme === t
|
|
? "bg-[var(--color-gold-glow)] border-[var(--color-gold-bright)] text-[var(--color-gold-bright)]"
|
|
: "bg-[var(--color-bg-surface)] border-[var(--color-border-glass)] text-[var(--color-text-dim)] hover:text-[var(--color-text-secondary)]"
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] px-3 py-2 text-[var(--color-text-primary)] placeholder-[var(--color-text-dim)] focus:outline-none focus:border-[var(--color-gold-bright)] text-sm"
|
|
value={theme}
|
|
onChange={(e) => setTheme(e.target.value)}
|
|
placeholder="e.g. high fantasy, dark post-apocalyptic, steampunk..."
|
|
onKeyDown={(e) => e.key === "Enter" && generate()}
|
|
/>
|
|
<button
|
|
onClick={generate}
|
|
disabled={loading}
|
|
className="rounded-lg bg-[var(--color-gold-bright)] text-[var(--color-bg-deep)] px-4 py-2 text-sm font-semibold hover:bg-[var(--color-gold-muted)] transition-colors cursor-pointer disabled:opacity-50"
|
|
>
|
|
{loading ? "✨ Generating…" : "✨ Generate World"}
|
|
</button>
|
|
</div>
|
|
|
|
|
|
{world && (
|
|
<div className="flex flex-col gap-3">
|
|
{/* World name and description */}
|
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-4">
|
|
<h3 className="font-heading text-[var(--color-gold-bright)] text-lg font-bold mb-1">
|
|
{world.name}
|
|
</h3>
|
|
<p className="text-[var(--color-text-primary)] text-sm leading-relaxed">
|
|
{world.description}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Map + side details. Two-pane on wide screens; stacked on narrow. */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
|
|
<div className="lg:col-span-2">
|
|
<WorldMap
|
|
regions={ensureArray(world.regions).map(flattenValue)}
|
|
landmarks={ensureArray(world.landmarks).map(flattenValue)}
|
|
pins={world.pins ?? {}}
|
|
selected={selected}
|
|
onSelect={setSelected}
|
|
onPinMove={onPinMove}
|
|
/>
|
|
<p className="text-[10px] text-[var(--color-text-dim)] mt-1">
|
|
Drag pins to place regions (●) and landmarks (✦). Click a pin to select it.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{/* Selected pin detail, or a hint to pick one. */}
|
|
<div className="rounded-lg bg-[var(--color-bg-surface)] border border-[var(--color-border-glass)] p-3">
|
|
{selected ? (
|
|
<>
|
|
<span className="text-[10px] uppercase tracking-wider text-[var(--color-gold-muted)]">
|
|
{ensureArray(world.regions).map(flattenValue).includes(selected) ? "Region" : "Landmark"}
|
|
</span>
|
|
<p className="text-[var(--color-text-primary)] text-sm mt-1">{selected}</p>
|
|
</>
|
|
) : (
|
|
<p className="text-[var(--color-text-dim)] text-xs italic">
|
|
Click a pin on the map to inspect it here.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Regions (clickable → select on map) */}
|
|
{world.regions?.length > 0 && (
|
|
<div>
|
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">🏔 Regions</h4>
|
|
<div className="flex flex-col gap-1">
|
|
{ensureArray(world.regions).map((r, i) => {
|
|
const name = flattenValue(r);
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => setSelected(name)}
|
|
className={`text-left rounded-lg px-3 py-1.5 text-xs cursor-pointer transition-colors ${
|
|
selected === name
|
|
? "bg-[var(--color-gold-glow)] text-[var(--color-gold-bright)] border border-[var(--color-gold-bright)]"
|
|
: "bg-[var(--color-bg-surface)] border border-[var(--color-border-subtle)] text-[var(--color-text-primary)] hover:border-[var(--color-gold-muted)]"
|
|
}`}
|
|
>
|
|
{name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflicts */}
|
|
{world.conflicts?.length > 0 && (
|
|
<div>
|
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">⚔ Conflicts</h4>
|
|
<div className="flex flex-col gap-1">
|
|
{ensureArray(world.conflicts).map((c, i) => (
|
|
<div key={i} className="rounded-lg bg-[var(--color-bg-surface)] border-l-2 border-[var(--color-danger)] px-3 py-1.5 text-xs text-[var(--color-text-primary)]">
|
|
{flattenValue(c)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cultures */}
|
|
{world.cultures?.length > 0 && (
|
|
<div>
|
|
<h4 className="text-[var(--color-text-secondary)] text-xs font-medium mb-1.5">👥 Cultures</h4>
|
|
<div className="flex flex-wrap gap-1">
|
|
{ensureArray(world.cultures).map((c, i) => (
|
|
<span key={i} className="rounded-full bg-[var(--color-bg-surface)] border border-[var(--color-info)]/30 text-[var(--color-info)] px-2.5 py-1 text-[11px]">
|
|
{flattenValue(c)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
} |