Add form-based editor for the RPG world-state schema
Scenario editor now has a "World State (RPG)" section with an Editor/JSON toggle. The Editor is a form UI to add/edit/remove world & player stats (min/max/initial/±per-turn/cooldown/counter/desc/bands), NPCs (name, keys, description, and their own stats), flags, and milestones — no JSON required. Both views edit the same parsed schema and stay in sync; empty clears the RPG layer. Keeps the read-only structured preview under the JSON view. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cf464a48b0
commit
67fbe17cee
@@ -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 (
|
||||||
|
<input className="se-key" value={v} placeholder={placeholder}
|
||||||
|
onChange={(e) => setV(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Num({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<label className="se-num">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input type="number" value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="se-stat">
|
||||||
|
<div className="se-row-top">
|
||||||
|
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
|
||||||
|
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="se-fields">
|
||||||
|
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
|
||||||
|
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
|
||||||
|
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
|
||||||
|
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
|
||||||
|
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
|
||||||
|
<label className="se-check">
|
||||||
|
<input type="checkbox" checked={def.type === 'counter'}
|
||||||
|
onChange={(e) => set('type', e.target.checked ? 'counter' : undefined)} />
|
||||||
|
<span>counts up only</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)"
|
||||||
|
onChange={(e) => set('desc', e.target.value || undefined)} />
|
||||||
|
<div className="se-bands">
|
||||||
|
<div className="se-sub-head">Bands — low, high, label</div>
|
||||||
|
{bands.map((b, i) => (
|
||||||
|
<div key={i} className="se-band">
|
||||||
|
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
|
||||||
|
onChange={(e) => updBand(i, 0, e.target.value)} />
|
||||||
|
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
|
||||||
|
onChange={(e) => updBand(i, 1, e.target.value)} />
|
||||||
|
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
|
||||||
|
onChange={(e) => updBand(i, 2, e.target.value)} />
|
||||||
|
<button type="button" className="se-remove"
|
||||||
|
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="se-add-sm"
|
||||||
|
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={nested ? 'se-section se-section-nested' : 'se-section'}>
|
||||||
|
<div className="se-section-head">
|
||||||
|
<span className="se-section-title">{title}</span>
|
||||||
|
<button type="button" className="se-add" onClick={add}>{addLabel}</button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 && <div className="se-empty">None yet.</div>}
|
||||||
|
{entries.map(([k, d]) => (
|
||||||
|
<StatEditor key={k} statKey={k} def={d || {}}
|
||||||
|
onRenameKey={(nk) => rename(k, nk)}
|
||||||
|
onChange={(nd) => setDef(k, nd)}
|
||||||
|
onRemove={() => remove(k)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="se-section">
|
||||||
|
<div className="se-section-head">
|
||||||
|
<span className="se-section-title">NPCs</span>
|
||||||
|
<button type="button" className="se-add" onClick={add}>+ NPC</button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 && <div className="se-empty">No NPCs.</div>}
|
||||||
|
{entries.map(([k, npc]) => (
|
||||||
|
<div key={k} className="se-npc">
|
||||||
|
<div className="se-row-top">
|
||||||
|
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="npc_id" />
|
||||||
|
<span className="schema-npc-id">npc.{k}</span>
|
||||||
|
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove NPC">✕</button>
|
||||||
|
</div>
|
||||||
|
<input className="se-text" value={npc.name || ''} placeholder="Display name"
|
||||||
|
onChange={(e) => setNpc(k, { ...npc, name: e.target.value })} />
|
||||||
|
<input className="se-text" value={npc.keys || ''} placeholder="trigger words, comma-separated (e.g. Gwen, ranger)"
|
||||||
|
onChange={(e) => setNpc(k, { ...npc, keys: e.target.value })} />
|
||||||
|
<input className="se-text" value={npc.desc || ''} placeholder="description (lore + shown to the AI)"
|
||||||
|
onChange={(e) => setNpc(k, { ...npc, desc: e.target.value })} />
|
||||||
|
<StatSection title="Stats" defs={npc.stats || {}} nested
|
||||||
|
onChange={(nd) => setNpc(k, { ...npc, stats: nd })} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="se-section">
|
||||||
|
<div className="se-section-head">
|
||||||
|
<span className="se-section-title">Flags (on/off)</span>
|
||||||
|
<button type="button" className="se-add" onClick={add}>+ flag</button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 && <div className="se-empty">No flags.</div>}
|
||||||
|
{entries.map(([k, f]) => (
|
||||||
|
<div key={k} className="se-stat">
|
||||||
|
<div className="se-row-top">
|
||||||
|
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
|
||||||
|
<label className="se-check">
|
||||||
|
<input type="checkbox" checked={!!f.initial}
|
||||||
|
onChange={(e) => setFlag(k, { ...f, initial: e.target.checked })} />
|
||||||
|
<span>on by default</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
|
||||||
|
</div>
|
||||||
|
<input className="se-text" value={f.desc || ''} placeholder="description"
|
||||||
|
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="se-section">
|
||||||
|
<div className="se-section-head">
|
||||||
|
<span className="se-section-title">Milestones (sticky objectives)</span>
|
||||||
|
<button type="button" className="se-add" onClick={add}>+ milestone</button>
|
||||||
|
</div>
|
||||||
|
{entries.length === 0 && <div className="se-empty">No milestones.</div>}
|
||||||
|
{entries.map(([k, m]) => (
|
||||||
|
<div key={k} className="se-stat">
|
||||||
|
<div className="se-row-top">
|
||||||
|
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
|
||||||
|
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
|
||||||
|
</div>
|
||||||
|
<input className="se-text" value={m?.desc || ''} placeholder="objective description"
|
||||||
|
onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="se">
|
||||||
|
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)} />
|
||||||
|
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)} />
|
||||||
|
<NpcSection npcs={s.npcs || {}} onChange={(v) => setSection('npcs', v)} />
|
||||||
|
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
|
||||||
|
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -660,6 +660,136 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
color: var(--danger, #e5484d);
|
color: var(--danger, #e5484d);
|
||||||
font-size: 0.82rem;
|
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 { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
|
||||||
.ws-report li { padding: 2px 0; color: var(--text); }
|
.ws-report li { padding: 2px 0; color: var(--text); }
|
||||||
.ws-report code { color: var(--accent-bright); }
|
.ws-report code { color: var(--accent-bright); }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
|
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
|
||||||
|
import SchemaEditor from '../SchemaEditor'
|
||||||
|
|
||||||
export default function ScenarioEditor() {
|
export default function ScenarioEditor() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -12,6 +13,9 @@ export default function ScenarioEditor() {
|
|||||||
// Raw text buffer for the stat_schema JSON editor + a live parse error.
|
// Raw text buffer for the stat_schema JSON editor + a live parse error.
|
||||||
const [schemaText, setSchemaText] = useState('')
|
const [schemaText, setSchemaText] = useState('')
|
||||||
const [schemaError, setSchemaError] = 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
|
// One timer per field/card: a single shared timer would cancel the pending
|
||||||
// save of whatever was edited previously within the debounce window.
|
// save of whatever was edited previously within the debounce window.
|
||||||
const saveTimers = useRef(new Map())
|
const saveTimers = useRef(new Map())
|
||||||
@@ -24,6 +28,7 @@ export default function ScenarioEditor() {
|
|||||||
api.getScenario(id).then((s) => {
|
api.getScenario(id).then((s) => {
|
||||||
setScenario(s)
|
setScenario(s)
|
||||||
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
|
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
|
||||||
|
setParsedSchema(s.stat_schema || null)
|
||||||
}).catch(() => navigate('/scenarios'))
|
}).catch(() => navigate('/scenarios'))
|
||||||
api.listScripts().then(setAllScripts).catch(() => {})
|
api.listScripts().then(setAllScripts).catch(() => {})
|
||||||
}, [id, navigate])
|
}, [id, navigate])
|
||||||
@@ -46,6 +51,7 @@ export default function ScenarioEditor() {
|
|||||||
const trimmed = text.trim()
|
const trimmed = text.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setSchemaError('')
|
setSchemaError('')
|
||||||
|
setParsedSchema(null)
|
||||||
debounceSave('stat_schema', () => saveSchema(null))
|
debounceSave('stat_schema', () => saveSchema(null))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -53,7 +59,7 @@ export default function ScenarioEditor() {
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(trimmed)
|
parsed = JSON.parse(trimmed)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSchemaError(`Invalid JSON: ${err.message}`)
|
setSchemaError(`Invalid JSON: ${err.message}`) // keep last-good preview
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
@@ -61,6 +67,7 @@ export default function ScenarioEditor() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSchemaError('')
|
setSchemaError('')
|
||||||
|
setParsedSchema(parsed)
|
||||||
debounceSave('stat_schema', () => saveSchema(parsed))
|
debounceSave('stat_schema', () => saveSchema(parsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +78,16 @@ export default function ScenarioEditor() {
|
|||||||
setTimeout(() => setStatus(''), 1500)
|
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 addCard = async () => {
|
||||||
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
||||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
||||||
@@ -193,23 +210,35 @@ export default function ScenarioEditor() {
|
|||||||
|
|
||||||
<div className="page-header" style={{ marginTop: 28 }}>
|
<div className="page-header" style={{ marginTop: 28 }}>
|
||||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
|
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
|
||||||
|
<div className="panel-toggles">
|
||||||
|
<button type="button" className={schemaView === 'form' ? 'active' : ''}
|
||||||
|
onClick={() => setSchemaView('form')}>Editor</button>
|
||||||
|
<button type="button" className={schemaView === 'json' ? 'active' : ''}
|
||||||
|
onClick={() => setSchemaView('json')}>JSON</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||||
Optional. Define stats (with bands and rules), NPCs, flags, and milestones as a
|
Optional. Define stats (with bands and rules), NPCs, flags, and milestones, and
|
||||||
JSON object, and the AI will track them each turn — HP, mana, an NPC’s trust,
|
the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
|
||||||
quest objectives. Each NPC in <code>npcs</code> has its own <code>name</code>,
|
Each NPC has its own name, trigger keys, description, and its own stats; a story
|
||||||
<code>desc</code>, trigger <code>keys</code>, and <code>stats</code>; a story card
|
card is created for it automatically. Leave blank for a plain narrative scenario.
|
||||||
is created for it automatically. Leave blank for a plain narrative scenario.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
{schemaView === 'form' ? (
|
||||||
className="schema-editor"
|
<SchemaEditor schema={parsedSchema} onChange={applySchema} />
|
||||||
value={schemaText}
|
) : (
|
||||||
onChange={(e) => setSchema(e.target.value)}
|
<>
|
||||||
rows={12}
|
<textarea
|
||||||
spellCheck={false}
|
className="schema-editor"
|
||||||
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
|
value={schemaText}
|
||||||
/>
|
onChange={(e) => setSchema(e.target.value)}
|
||||||
{schemaError && <div className="schema-error">⚠ {schemaError}</div>}
|
rows={14}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "npcs": {\n "gwen": { "name": "Gwen", "keys": "Gwen, ranger", "desc": "...",\n "stats": { "trust": { "min": -100, "max": 100, "initial": 20 } } }\n },\n "flags": { "has_key": { "desc": "...", "initial": false } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
|
||||||
|
/>
|
||||||
|
{schemaError && <div className="schema-error">⚠ {schemaError}</div>}
|
||||||
|
<SchemaPreview schema={parsedSchema} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="page-header" style={{ marginTop: 28 }}>
|
<div className="page-header" style={{ marginTop: 28 }}>
|
||||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
||||||
@@ -235,3 +264,105 @@ export default function ScenarioEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Structured preview of a parsed stat_schema (read-only) ----
|
||||||
|
|
||||||
|
function statMeta(def) {
|
||||||
|
const parts = []
|
||||||
|
if (def.initial !== undefined) parts.push(`start ${def.initial}`)
|
||||||
|
if (typeof def.min === 'number' && typeof def.max === 'number') parts.push(`${def.min}–${def.max}`)
|
||||||
|
if (def.type === 'counter') parts.push('counter')
|
||||||
|
if (typeof def.max_delta_per_turn === 'number') parts.push(`±${def.max_delta_per_turn}/turn`)
|
||||||
|
if (def.cooldown) parts.push(`cooldown ${def.cooldown}`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatDefRow({ name, def }) {
|
||||||
|
if (!def || typeof def !== 'object') return null
|
||||||
|
const bands = Array.isArray(def.bands)
|
||||||
|
? def.bands.filter((b) => Array.isArray(b) && b.length === 3)
|
||||||
|
.map((b) => `${b[0]}–${b[1]} ${b[2]}`).join(', ')
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div className="schema-stat">
|
||||||
|
<div className="schema-stat-head">
|
||||||
|
<span className="schema-stat-name">{name}</span>
|
||||||
|
<span className="schema-stat-meta">{statMeta(def)}</span>
|
||||||
|
</div>
|
||||||
|
{def.desc && <div className="schema-desc">{def.desc}</div>}
|
||||||
|
{bands && <div className="schema-bands">bands: {bands}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatDefGroup({ title, defs }) {
|
||||||
|
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="schema-group">
|
||||||
|
<div className="schema-group-title">{title}</div>
|
||||||
|
{entries.map(([n, d]) => <StatDefRow key={n} name={n} def={d} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaPreview({ schema }) {
|
||||||
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return null
|
||||||
|
const world = schema.world || {}
|
||||||
|
const player = schema.player || {}
|
||||||
|
const npcs = schema.npcs || {}
|
||||||
|
const flags = schema.flags || {}
|
||||||
|
const milestones = schema.milestones || {}
|
||||||
|
const count = [world, player, npcs, flags, milestones]
|
||||||
|
.reduce((n, o) => n + Object.keys(o || {}).length, 0)
|
||||||
|
if (count === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="schema-preview">
|
||||||
|
<div className="schema-preview-title">Parsed structure</div>
|
||||||
|
<StatDefGroup title="World" defs={world} />
|
||||||
|
<StatDefGroup title="Player" defs={player} />
|
||||||
|
{Object.entries(npcs).map(([npcId, npc]) => (
|
||||||
|
npc && typeof npc === 'object' ? (
|
||||||
|
<div key={npcId} className="schema-group">
|
||||||
|
<div className="schema-group-title">
|
||||||
|
{npc.name || npcId} <span className="schema-npc-id">npc.{npcId}</span>
|
||||||
|
</div>
|
||||||
|
{npc.keys && <div className="schema-desc">triggers: {npc.keys}</div>}
|
||||||
|
{npc.desc && <div className="schema-desc">{npc.desc}</div>}
|
||||||
|
{Object.entries(npc.stats || {}).map(([n, d]) => (
|
||||||
|
<StatDefRow key={n} name={n} def={d} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
))}
|
||||||
|
{Object.keys(flags).length > 0 && (
|
||||||
|
<div className="schema-group">
|
||||||
|
<div className="schema-group-title">Flags</div>
|
||||||
|
{Object.entries(flags).map(([fid, f]) => (
|
||||||
|
<div key={fid} className="schema-stat">
|
||||||
|
<div className="schema-stat-head">
|
||||||
|
<span className="schema-stat-name">{fid}</span>
|
||||||
|
<span className="schema-stat-meta">{f?.initial ? 'on by default' : 'off by default'}</span>
|
||||||
|
</div>
|
||||||
|
{f?.desc && <div className="schema-desc">{f.desc}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Object.keys(milestones).length > 0 && (
|
||||||
|
<div className="schema-group">
|
||||||
|
<div className="schema-group-title">Milestones</div>
|
||||||
|
{Object.entries(milestones).map(([mid, m]) => (
|
||||||
|
<div key={mid} className="schema-stat">
|
||||||
|
<div className="schema-stat-head">
|
||||||
|
<span className="schema-stat-name">{mid}</span>
|
||||||
|
</div>
|
||||||
|
{m?.desc && <div className="schema-desc">{m.desc}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user