import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { api } from '../api' import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components' import { useDebouncedSave } from '../hooks/useDebouncedSave' import ArtPicker from '../ArtPicker' import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor' export default function ScenarioEditor() { const { id } = useParams() const navigate = useNavigate() const [scenario, setScenario] = useState(null) const [status, setStatus] = useState('') // Raw text buffer for the stat_schema JSON editor + a live parse error. const [schemaText, setSchemaText] = useState('') const [schemaError, setSchemaError] = useState('') // Last successfully-parsed schema (drives the form editor + preview). const [parsedSchema, setParsedSchema] = useState(null) const [schemaView, setSchemaView] = useState('form') // 'form' | 'json' const toast = useToast() const debounceSave = useDebouncedSave() useEffect(() => { api.getScenario(id).then((s) => { setScenario(s) setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '') setParsedSchema(s.stat_schema || null) }).catch(() => navigate('/scenarios')) }, [id, navigate]) const setField = (field, value) => { const next = { ...scenario, [field]: value } setScenario(next) debounceSave(field, async () => { await api.updateScenario(id, { [field]: value }) setStatus('Saved') setTimeout(() => setStatus(''), 1500) }) } // Several fields at once, in one PATCH — the art picker sets `image` and // `icon` together (choosing one clears the other, and they must not race). const setFields = (patch) => { setScenario((prev) => ({ ...prev, ...patch })) debounceSave('art', async () => { await api.updateScenario(id, patch) setStatus('Saved') setTimeout(() => setStatus(''), 1500) }) } // JSON view: typing only updates the text buffer; nothing is parsed or saved // until the user clicks Save (so a half-typed edit doesn't spam errors). const onJsonText = (text) => { setSchemaText(text) if (schemaError) setSchemaError('') } // Parse the JSON box on demand: apply + save if valid, else show the error. const commitJson = () => { const trimmed = schemaText.trim() if (!trimmed) { setSchemaError('') setParsedSchema(null) saveSchema(null) return } let parsed try { parsed = JSON.parse(trimmed) } catch (err) { setSchemaError(`Invalid JSON: ${err.message}`) return } if (typeof parsed !== 'object' || Array.isArray(parsed)) { setSchemaError('The schema must be a JSON object (e.g. { "player": { … } }).') return } setSchemaError('') setParsedSchema(parsed) setSchemaText(JSON.stringify(parsed, null, 2)) // normalize / pretty-print saveSchema(parsed) } const saveSchema = async (parsed) => { await api.updateScenario(id, { stat_schema: parsed }) setScenario((s) => ({ ...s, stat_schema: parsed })) setStatus('Saved') setTimeout(() => setStatus(''), 1500) } // Edits from the form editor: keep the JSON text in sync and save. An empty // schema clears the RPG layer (null). const applySchema = (next) => { const cleaned = next && Object.keys(next).length ? next : null setParsedSchema(cleaned) setSchemaText(cleaned ? JSON.stringify(cleaned, null, 2) : '') setSchemaError('') debounceSave('stat_schema', () => saveSchema(cleaned)) } // NPCs live in the same stat_schema, but are edited in their own page // section — so this is just the `npcs` slice of the same save path. const setNpcs = (npcs) => { const next = { ...(parsedSchema || {}) } if (npcs && Object.keys(npcs).length) next.npcs = npcs else delete next.npcs applySchema(next) } const addCard = async () => { const card = await api.createStoryCard({ scenario_id: Number(id) }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] }) } const updateCard = (card) => { setScenario({ ...scenario, story_cards: scenario.story_cards.map((c) => (c.id === card.id ? card : c)), }) debounceSave(`card-${card.id}`, () => { api.updateStoryCard(card.id, { name: card.name, type: card.type, keys: card.keys, entry: card.entry, notes: card.notes, }) }) } const deleteCard = async (cardId) => { await api.deleteStoryCard(cardId) setScenario({ ...scenario, story_cards: scenario.story_cards.filter((c) => c.id !== cardId) }) } const exportCards = async () => { const cards = await api.exportStoryCards({ scenario_id: id }) downloadJSON(cards, `${scenario.title.replace(/\W+/g, '-')}-cards.json`) } const importCards = async () => { try { const parsed = await pickJSONFile() const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards) if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error') const created = await api.importStoryCards({ scenario_id: Number(id), cards }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] }) } catch (err) { toast(err.message, 'error') } } const exportScenario = async () => { downloadJSON(await api.exportScenario(id), `${scenario.title.replace(/\W+/g, '-')}.json`) } const deleteScenario = async () => { if (!confirm('Delete this scenario permanently?')) return await api.deleteScenario(id) navigate('/scenarios') } const play = async () => { const adv = await api.createAdventure({ scenario_id: Number(id) }) navigate(`/play/${adv.id}`) } if (!scenario) return null // Shared demo scenarios (Phase 8) are visible to everyone but owned by no // one; the backend rejects edits, so present them read-only. const readOnly = !!scenario.is_public return (

{readOnly ? 'Scenario (read-only)' : 'Edit Scenario'}

{status} {!readOnly && }
{readOnly && (
This is a shared demo scenario — it can’t be edited, but you can hit Play to start your own adventure from it, or Export and re-import it as your own copy.
)}
setField('title', v)} /> setField('description', v)} textarea placeholder="Shown in the scenario list; not sent to the AI." /> setField('prompt', v)} textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." /> setField('memory', v)} textarea placeholder="Key facts the AI should always remember." /> setField('authors_note', v)} textarea rows={2} placeholder="Style/theme guidance, injected near the end of context." /> setField('ai_instructions', v)} textarea rows={2} placeholder="Behavioral guidance for the model (always included)." /> setField('tags', v)} placeholder="fantasy, mystery" />

Story Cards

{!readOnly && }
{scenario.story_cards.length === 0 && (
No story cards. Cards inject their Entry into context when a trigger word appears in the story.
)} {scenario.story_cards.map((card) => ( deleteCard(card.id)} /> ))}

World State (RPG)

Optional, and no longer how the story is tracked. Every campaign now keeps genre-neutral narrative state — who exists, where they are, what they hold, what has been established — without any schema at all, and that is what the narrator is told. Numbers defined here are recorded alongside it for scenarios that were built on them. Leave blank unless you have one.

{schemaView === 'form' ? ( ) : ( <>