import { useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { parseLlmJson, flattenValue } from "../lib/llm-parse"; import { GeneratedImage } from "./GeneratedImage"; import { addToLore } from "../lib/lore"; import { useToast } from "./Toast"; import { addGeneration, extractTitle, type Generation } from "../lib/generations"; import { usePrefillEffect } from "../lib/usePrefill"; interface ItemMechanics { attunement: string; // "none", "yes", or a condition string charges: string; // "0" or "N (regen 1d6 per dawn)" value: string; // "500 gp" weight: string; // "2 lbs" effect: string; // one-line effect description } interface ItemResponse { name: string; rarity: string; type: string; description: string; mechanical: string | Record | ItemMechanics; lore: string; } const RARITIES = ["Common", "Uncommon", "Rare", "Very Rare", "Legendary", "Artifact"]; const TYPES = ["Weapon", "Armor", "Potion", "Scroll", "Wondrous Item", "Ring", "Wand", "Staff"]; interface Props { prefill?: Generation | null; onPrefillConsumed?: () => void; } export function ItemForge({ prefill, onPrefillConsumed }: Props = {}) { const [rarity, setRarity] = useState("Rare"); const [itemType, setItemType] = useState("Wondrous Item"); const [prompt, setPrompt] = useState(""); const [item, setItem] = useState(null); const [rawResponse, setRawResponse] = useState(null); const [loading, setLoading] = useState(false); const [artNonce, setArtNonce] = useState(0); const { addToast } = useToast(); usePrefillEffect(prefill ?? null, "item", () => onPrefillConsumed?.(), (g) => { const parsed = parseLlmJson(g.data); if (parsed) { setItem(parsed); setRarity(parsed.rarity || "Rare"); setItemType(parsed.type || "Wondrous Item"); addToast(`Loaded "${g.title}" from history`, "info"); } }); // ponytail: FLUX.2 renders readable text, so the item name is painted in-image. const artPrompt = item ? `fantasy product shot of a ${item.rarity || rarity} ${item.type || itemType} named "${item.name || "magic item"}", ${flattenValue(item.description)}, dramatic side lighting, dark velvet background, 1024x1024` : null; async function generate() { setLoading(true); setItem(null); setRawResponse(null); try { const result = await invoke("generate", { req: { prompt: `Create a D&D 5e magic item with the following specifications: Rarity: ${rarity} Type: ${itemType} ${prompt ? `Additional details: ${prompt}` : ""} IMPORTANT: Return ONLY a raw JSON object, with NO markdown fences, NO code blocks, NO extra text. The response must start with { and end with }. The JSON must have exactly these keys: - "name": the item's evocative name (string) - "rarity": "${rarity}" (string) - "type": "${itemType}" (string) - "description": a vivid 2-3 sentence physical description (string) - "mechanical": an object with keys attunement (string: "none", "yes", or a condition), charges (string), value (string), weight (string), and effect (a one-line string describing the item's mechanical effect) - "lore": a 1-2 sentence piece of lore or history (string)`, system: "You are a creative D&D item designer. You MUST respond with ONLY valid JSON. No markdown fences, no code blocks, no explanation. Just the JSON object.", temperature: 0.85, max_tokens: 600, ragQuery: `${rarity} ${itemType} ${prompt}`, }, }); setRawResponse(result); const parsed = parseLlmJson(result); if (parsed) { setItem(parsed); addToast("Item forged", "success"); const title = extractTitle("item", result, `${parsed.rarity ?? rarity} ${parsed.type ?? itemType}`); const source = `${parsed.rarity || rarity} ${parsed.type || itemType}${prompt ? ` — ${prompt}` : ""}`; void addGeneration({ kind: "item", title, data: result, source }); void addToLore( `Item: ${parsed.name || itemType}`, `${parsed.name || "Magic item"} — ${parsed.rarity || rarity} ${parsed.type || itemType}. ${flattenValue(parsed.description)}\nMechanics: ${flattenValue(parsed.mechanical)}\nLore: ${flattenValue(parsed.lore)}`, ); } else { addToast("LLM returned an unparseable response", "error"); } } catch (e) { addToast(`Item generation failed: ${e}`, "error"); } setLoading(false); } const rarityColor: Record = { Common: "var(--color-text-secondary)", Uncommon: "#1eff00", Rare: "#0070dd", "Very Rare": "#a335ee", Legendary: "#ff8000", Artifact: "#e6cc80", }; const mechanicalText = item ? flattenValue(item.mechanical) : ""; // ponytail: structured mechanics if the LLM returned the object form; fall // back to a paragraph string for older generations. const mech = item?.mechanical; const isStructured = typeof mech === "object" && mech !== null && "attunement" in mech && "effect" in mech; const structured = isStructured ? (mech as ItemMechanics) : null; const hasParseError = !item && rawResponse; // ponytail: one-click "surprise me" — random rarity + type, no typed prompt. function randomItem() { setRarity(RARITIES[Math.floor(Math.random() * RARITIES.length)]); setItemType(TYPES[Math.floor(Math.random() * TYPES.length)]); setPrompt(""); setTimeout(generate, 0); } return (
setPrompt(e.target.value)} placeholder="e.g. a flame dagger that grants fire resistance..." onKeyDown={(e) => e.key === "Enter" && generate()} />
{!item && !loading && !hasParseError && (
No item yet. Pick rarity + type, then Forge.
)} {item && (

{item.name || "Unknown Item"}

{item.rarity || rarity} {item.type || itemType}
Description

{flattenValue(item.description)}

Mechanics {structured ? (
{/* ponytail: structured fields — attunement/charges/value/weight + one-line effect */}
Attunement: {structured.attunement === "none" || !structured.attunement ? "No" : structured.attunement} Charges: {structured.charges || "—"} Value: {structured.value || "—"} Weight: {structured.weight || "—"}

{structured.effect}

) : (

{mechanicalText}

)}
Lore

{flattenValue(item.lore)}

)} {/* Show raw response if JSON parsing failed */} {hasParseError && (
Raw LLM response (click to expand)
            {rawResponse}
          
)}
); }