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 ( + + ) +} + +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('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)} /> + +
+ ))} + +
+
+ ) +} + +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} + +
+ {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 + +
+ {entries.length === 0 &&
No NPCs.
} + {entries.map(([k, npc]) => ( +
+
+ rename(k, nk)} placeholder="npc_id" /> + npc.{k} + +
+ setNpc(k, { ...npc, name: e.target.value })} /> + setNpc(k, { ...npc, keys: e.target.value })} /> + setNpc(k, { ...npc, desc: e.target.value })} /> + setNpc(k, { ...npc, stats: nd })} /> +
+ ))} +
+ ) +} + +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) + +
+ {entries.length === 0 &&
No flags.
} + {entries.map(([k, f]) => ( +
+
+ rename(k, nk)} placeholder="flag_name" /> + + +
+ setFlag(k, { ...f, desc: e.target.value })} /> +
+ ))} +
+ ) +} + +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) + +
+ {entries.length === 0 &&
No milestones.
} + {entries.map(([k, m]) => ( +
+
+ rename(k, nk)} placeholder="milestone_id" /> + +
+ 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)

+
+ + +

- 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.

-