diff --git a/frontend/src/SchemaEditor.jsx b/frontend/src/SchemaEditor.jsx
new file mode 100644
index 0000000..77bce74
--- /dev/null
+++ b/frontend/src/SchemaEditor.jsx
@@ -0,0 +1,247 @@
+import { useEffect, useState } from 'react'
+
+// Rebuild an object with one key renamed, preserving order. Returns null on a
+// no-op or a collision (so the caller keeps the old object).
+function withRenamedKey(obj, oldKey, newKey) {
+ if (!newKey || oldKey === newKey || obj[newKey] !== undefined) return null
+ const next = {}
+ for (const [k, v] of Object.entries(obj)) next[k === oldKey ? newKey : k] = v
+ return next
+}
+
+// A key/id input that commits on blur (renaming a key mid-keystroke would
+// rebuild the parent object and steal focus).
+function KeyInput({ value, onCommit, placeholder }) {
+ const [v, setV] = useState(value)
+ useEffect(() => { setV(value) }, [value])
+ const commit = () => {
+ const trimmed = v.trim()
+ if (trimmed && trimmed !== value) onCommit(trimmed)
+ else setV(value)
+ }
+ return (
+ setV(e.target.value)}
+ onBlur={commit}
+ onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} />
+ )
+}
+
+function Num({ label, value, onChange }) {
+ return (
+
+ {label}
+ onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
+
+ )
+}
+
+function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
+ const set = (field, val) => {
+ const next = { ...def }
+ if (val === undefined || val === '') delete next[field]
+ else next[field] = val
+ onChange(next)
+ }
+ const bands = Array.isArray(def.bands) ? def.bands : []
+ const setBands = (nb) => set('bands', nb.length ? nb : undefined)
+ const updBand = (i, j, raw) => {
+ const nb = bands.map((b) => (Array.isArray(b) ? [...b] : [0, 0, '']))
+ while (nb[i].length < 3) nb[i].push(j < 2 ? 0 : '')
+ nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw
+ setBands(nb)
+ }
+ return (
+
+
+
+ ✕
+
+
+ set('min', v)} />
+ set('max', v)} />
+ set('initial', v)} />
+ set('max_delta_per_turn', v)} />
+ set('cooldown', v)} />
+
+ set('type', e.target.checked ? 'counter' : undefined)} />
+ counts up only
+
+
+
set('desc', e.target.value || undefined)} />
+
+
Bands — low, high, label
+ {bands.map((b, i) => (
+
+ updBand(i, 0, e.target.value)} />
+ updBand(i, 1, e.target.value)} />
+ updBand(i, 2, e.target.value)} />
+ setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕
+
+ ))}
+
setBands([...bands, [0, 0, '']])}>+ band
+
+
+ )
+}
+
+function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
+ const entries = Object.entries(defs || {})
+ const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
+ const setDef = (k, val) => onChange({ ...defs, [k]: val })
+ const remove = (k) => { const x = { ...defs }; delete x[k]; onChange(x) }
+ const add = () => {
+ let i = 1, key = 'stat'
+ while (defs[key]) key = `stat${i++}`
+ onChange({ ...defs, [key]: { min: 0, max: 100, initial: 0 } })
+ }
+ return (
+
+
+ {title}
+ {addLabel}
+
+ {entries.length === 0 &&
None yet.
}
+ {entries.map(([k, d]) => (
+
rename(k, nk)}
+ onChange={(nd) => setDef(k, nd)}
+ onRemove={() => remove(k)} />
+ ))}
+
+ )
+}
+
+function NpcSection({ npcs, onChange }) {
+ const entries = Object.entries(npcs || {})
+ const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
+ const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
+ const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
+ const add = () => {
+ let i = 1, key = 'npc'
+ while (npcs[key]) key = `npc${i++}`
+ onChange({ ...npcs, [key]: { name: '', keys: '', desc: '', stats: {} } })
+ }
+ return (
+
+
+ NPCs
+ + NPC
+
+ {entries.length === 0 &&
No NPCs.
}
+ {entries.map(([k, npc]) => (
+
+ ))}
+
+ )
+}
+
+function FlagSection({ flags, onChange }) {
+ const entries = Object.entries(flags || {})
+ const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
+ const setFlag = (k, val) => onChange({ ...flags, [k]: val })
+ const remove = (k) => { const x = { ...flags }; delete x[k]; onChange(x) }
+ const add = () => {
+ let i = 1, key = 'flag'
+ while (flags[key]) key = `flag${i++}`
+ onChange({ ...flags, [key]: { initial: false, desc: '' } })
+ }
+ return (
+
+
+ Flags (on/off)
+ + flag
+
+ {entries.length === 0 &&
No flags.
}
+ {entries.map(([k, f]) => (
+
+ ))}
+
+ )
+}
+
+function MilestoneSection({ milestones, onChange }) {
+ const entries = Object.entries(milestones || {})
+ const rename = (o, n) => { const x = withRenamedKey(milestones, o, n); if (x) onChange(x) }
+ const setM = (k, val) => onChange({ ...milestones, [k]: val })
+ const remove = (k) => { const x = { ...milestones }; delete x[k]; onChange(x) }
+ const add = () => {
+ let i = 1, key = 'goal'
+ while (milestones[key]) key = `goal${i++}`
+ onChange({ ...milestones, [key]: { desc: '' } })
+ }
+ return (
+
+
+ Milestones (sticky objectives)
+ + milestone
+
+ {entries.length === 0 &&
No milestones.
}
+ {entries.map(([k, m]) => (
+
+
+ rename(k, nk)} placeholder="milestone_id" />
+ remove(k)} title="Remove milestone">✕
+
+
setM(k, { ...m, desc: e.target.value })} />
+
+ ))}
+
+ )
+}
+
+// Form-based editor for a stat_schema. `schema` is the parsed object (or null);
+// `onChange(nextSchema)` fires on every edit. Empty sections are dropped.
+export default function SchemaEditor({ schema, onChange }) {
+ const s = schema && typeof schema === 'object' ? schema : {}
+ const setSection = (key, val) => {
+ const next = { ...s }
+ if (val && Object.keys(val).length) next[key] = val
+ else delete next[key]
+ onChange(next)
+ }
+ return (
+
+ setSection('world', v)} />
+ setSection('player', v)} />
+ setSection('npcs', v)} />
+ setSection('flags', v)} />
+ setSection('milestones', v)} />
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 5c75edc..b5544f9 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -660,6 +660,136 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
color: var(--danger, #e5484d);
font-size: 0.82rem;
}
+.schema-preview {
+ margin-top: 14px;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-panel);
+}
+.schema-preview-title {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--text-dim);
+ margin-bottom: 10px;
+}
+.schema-group { margin-bottom: 16px; }
+.schema-group:last-child { margin-bottom: 0; }
+.schema-group-title {
+ font-family: Georgia, serif;
+ font-size: 0.98rem;
+ color: var(--accent-bright);
+ margin-bottom: 6px;
+}
+.schema-npc-id {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ margin-left: 6px;
+}
+.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); }
+.schema-stat:last-child { border-bottom: none; }
+.schema-stat-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+}
+.schema-stat-name { color: var(--text); text-transform: capitalize; }
+.schema-stat-meta {
+ color: var(--text-dim);
+ font-size: 0.8rem;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
+.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
+
+/* ---- Form-based schema editor ---- */
+.se-section {
+ margin-bottom: 16px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-panel);
+}
+.se-section-nested {
+ margin: 8px 0 0;
+ background: transparent;
+ border-style: dashed;
+}
+.se-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 8px;
+}
+.se-section-title {
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-dim);
+}
+.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
+.se-add, .se-add-sm {
+ font-size: 0.78rem;
+ padding: 3px 10px;
+}
+.se-add-sm { margin-top: 6px; }
+.se-remove {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 2px 6px;
+ font-size: 0.9rem;
+ line-height: 1;
+}
+.se-remove:hover { color: var(--danger, #e5484d); }
+.se-stat, .se-npc {
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+}
+.se-stat:last-child, .se-npc:last-child { border-bottom: none; }
+.se-npc {
+ padding: 10px 12px;
+ margin-bottom: 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+}
+.se-row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
+.se-key {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.85rem;
+ padding: 4px 8px;
+ max-width: 220px;
+}
+.se-fields {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ align-items: flex-end;
+ margin-bottom: 6px;
+}
+.se-num { display: flex; flex-direction: column; gap: 2px; }
+.se-num span { font-size: 0.7rem; color: var(--text-dim); }
+.se-num input { width: 72px; padding: 3px 6px; font-size: 0.82rem; }
+.se-check {
+ display: flex; align-items: center; gap: 5px;
+ font-size: 0.8rem; color: var(--text-dim);
+}
+.se-check input { width: auto; }
+.se-text {
+ width: 100%;
+ padding: 4px 8px;
+ font-size: 0.82rem;
+ margin-bottom: 4px;
+}
+.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
+.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
+.se-band-n { width: 64px; padding: 3px 6px; font-size: 0.82rem; }
+.se-band-l { flex: 1; padding: 3px 6px; font-size: 0.82rem; }
.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
.ws-report li { padding: 2px 0; color: var(--text); }
.ws-report code { color: var(--accent-bright); }
diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx
index bce9c0f..9476b5d 100644
--- a/frontend/src/pages/ScenarioEditor.jsx
+++ b/frontend/src/pages/ScenarioEditor.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
+import SchemaEditor from '../SchemaEditor'
export default function ScenarioEditor() {
const { id } = useParams()
@@ -12,6 +13,9 @@ export default function ScenarioEditor() {
// 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'
// One timer per field/card: a single shared timer would cancel the pending
// save of whatever was edited previously within the debounce window.
const saveTimers = useRef(new Map())
@@ -24,6 +28,7 @@ export default function ScenarioEditor() {
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'))
api.listScripts().then(setAllScripts).catch(() => {})
}, [id, navigate])
@@ -46,6 +51,7 @@ export default function ScenarioEditor() {
const trimmed = text.trim()
if (!trimmed) {
setSchemaError('')
+ setParsedSchema(null)
debounceSave('stat_schema', () => saveSchema(null))
return
}
@@ -53,7 +59,7 @@ export default function ScenarioEditor() {
try {
parsed = JSON.parse(trimmed)
} catch (err) {
- setSchemaError(`Invalid JSON: ${err.message}`)
+ setSchemaError(`Invalid JSON: ${err.message}`) // keep last-good preview
return
}
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -61,6 +67,7 @@ export default function ScenarioEditor() {
return
}
setSchemaError('')
+ setParsedSchema(parsed)
debounceSave('stat_schema', () => saveSchema(parsed))
}
@@ -71,6 +78,16 @@ export default function ScenarioEditor() {
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))
+ }
+
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
@@ -193,23 +210,35 @@ export default function ScenarioEditor() {
World State (RPG)
+
+ setSchemaView('form')}>Editor
+ setSchemaView('json')}>JSON
+
- Optional. Define stats (with bands and rules), NPCs, flags, and milestones as a
- JSON object, and the AI will track them each turn — HP, mana, an NPC’s trust,
- quest objectives. Each NPC in npcs has its own name,
- desc, trigger keys, and stats; a story card
- is created for it automatically. Leave blank for a plain narrative scenario.
+ Optional. Define stats (with bands and rules), NPCs, flags, and milestones, and
+ the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
+ Each NPC has its own name, trigger keys, description, and its own stats; a story
+ card is created for it automatically. Leave blank for a plain narrative scenario.
-