Add Phase 12: native RPG world state
Structured world/player/NPC stats, two-way flags, and sticky milestones per scenario (stat_schema). The AI proposes a per-turn delta; a Python engine referees it (clamp to min/max, per-turn cap, cooldown, counters). Band word-labels plus a fixed stat guide (descriptions + full ranges) keep the model grounded. World State drawer + Insights delta report; undo/retry roll it back via the Phase 11 snapshot pattern. - migrations 26-28 (scenarios.stat_schema, adventures.world_state, actions.world_state_before); all nullable, additive, safe on existing rows - migration 29 raises the default context budget 4096 -> 16384 (custom values preserved) - seeded demo scenario 04-rpg-world-state.json (Bandit Camp) - 19 new tests (33 total pass) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
64356414b1
commit
4dac445f90
@@ -80,6 +80,7 @@ export const api = {
|
||||
listAdventures: () => request('/adventures'),
|
||||
getAdventure: (id) => request(`/adventures/${id}`),
|
||||
getScriptState: (id) => request(`/adventures/${id}/script-state`),
|
||||
getWorldState: (id) => request(`/adventures/${id}/world-state`),
|
||||
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
||||
|
||||
@@ -576,6 +576,96 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---------- World State drawer (Phase 12 RPG) ---------- */
|
||||
.ws-group { margin-bottom: 18px; }
|
||||
.ws-group-title {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.ws-stat { padding: 5px 0; }
|
||||
.ws-stat-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ws-stat-name { color: var(--text); text-transform: capitalize; }
|
||||
.ws-stat-val {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8rem;
|
||||
text-align: right;
|
||||
}
|
||||
.ws-band { color: var(--accent-bright); }
|
||||
.ws-bar {
|
||||
margin-top: 4px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ws-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
.ws-milestones { list-style: none; margin: 0; padding: 0; }
|
||||
.ws-milestones li {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: flex-start;
|
||||
padding: 4px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.ws-milestones li.done { color: var(--text-dim); }
|
||||
.ws-check { flex-shrink: 0; color: var(--accent-bright); }
|
||||
.ws-milestones li.done .ws-check { color: var(--player); }
|
||||
.ws-flags { list-style: none; margin: 0; padding: 0; }
|
||||
.ws-flags li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.ws-flag-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||
.ws-flag-name { flex: 1; }
|
||||
.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
|
||||
|
||||
.schema-editor {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.schema-error {
|
||||
margin-top: 6px;
|
||||
color: var(--danger, #e5484d);
|
||||
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); }
|
||||
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
|
||||
.ws-flag { color: var(--text-dim); font-style: italic; }
|
||||
.ws-rejected { color: var(--text-dim); }
|
||||
.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.side-panel-header h2 {
|
||||
margin: 0;
|
||||
|
||||
@@ -42,6 +42,9 @@ const SECTION_LABELS = {
|
||||
plot_essentials: 'Plot Essentials',
|
||||
story_summary: 'Story Summary',
|
||||
used_memories: 'Used Memories (memory bank)',
|
||||
world_state_guide: 'World State (stat guide)',
|
||||
world_state: 'World State (RPG)',
|
||||
world_state_rule: 'World State (reporting rule)',
|
||||
world_lore: 'World Lore (story cards)',
|
||||
history: 'Story history',
|
||||
authors_note: "Author's Note",
|
||||
@@ -481,6 +484,181 @@ function StatusDrawer({ advId, refreshKey }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Word label for a value from a stat def's bands (mirrors worldstate.band_label).
|
||||
function bandLabel(def, value) {
|
||||
const bands = def?.bands
|
||||
if (!Array.isArray(bands) || typeof value !== 'number') return null
|
||||
for (const b of bands) {
|
||||
if (Array.isArray(b) && b.length === 3 && value >= b[0] && value < b[1]) return b[2]
|
||||
}
|
||||
const last = bands[bands.length - 1]
|
||||
if (last && value === last[1]) return last[2]
|
||||
return null
|
||||
}
|
||||
|
||||
function StatRow({ name, def, value }) {
|
||||
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
|
||||
const { min, max } = def || {}
|
||||
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
|
||||
const pct = hasRange ? Math.max(0, Math.min(100, ((val - min) / (max - min)) * 100)) : null
|
||||
const label = bandLabel(def, val)
|
||||
return (
|
||||
<div className="ws-stat">
|
||||
<div className="ws-stat-head">
|
||||
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
|
||||
<span className="ws-stat-val">
|
||||
{val}{typeof max === 'number' ? `/${max}` : ''}
|
||||
{label ? <span className="ws-band"> · {label}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{pct != null && (
|
||||
<div className="ws-bar"><div className="ws-bar-fill" style={{ width: `${pct}%` }} /></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatGroup({ title, defs, values }) {
|
||||
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div className="ws-group">
|
||||
{title && <h3 className="ws-group-title">{title}</h3>}
|
||||
{entries.map(([name, def]) => (
|
||||
<StatRow key={name} name={name} def={def} value={values?.[name]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
|
||||
// stats with bands + bars, and a milestones checklist. Renders nothing unless
|
||||
// the adventure's scenario defines a stat_schema.
|
||||
function WorldStateDrawer({ advId, refreshKey, cards }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [data, setData] = useState(null) // { state, schema }
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.getWorldState(advId)
|
||||
.then((r) => { setData(r); setFailed(false) })
|
||||
.catch(() => setFailed(true))
|
||||
}, [advId])
|
||||
|
||||
// Load once to learn whether there's an RPG layer, then refresh after turns.
|
||||
useEffect(() => { load() }, [load, refreshKey])
|
||||
|
||||
const schema = data?.schema
|
||||
if (!schema) return null // no RPG layer for this adventure
|
||||
|
||||
const state = data?.state || {}
|
||||
const cardName = (id) =>
|
||||
cards?.find((c) => String(c.id) === String(id))?.name || `NPC ${id}`
|
||||
const npcState = state.npc || {}
|
||||
const npcIds = Object.keys(npcState)
|
||||
const flags = Object.entries(schema.flags || {})
|
||||
const flagState = state.flags || {}
|
||||
const milestones = Object.entries(schema.milestones || {})
|
||||
const reached = state.milestones || {}
|
||||
|
||||
return (
|
||||
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
|
||||
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
|
||||
title="RPG world state">
|
||||
{open ? '‹' : '›'}<span className="status-toggle-label">World</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="status-body">
|
||||
<div className="side-panel-header">
|
||||
<h2>World State</h2>
|
||||
<button onClick={load} title="Refresh">↻</button>
|
||||
</div>
|
||||
{failed ? (
|
||||
<div className="empty">Couldn’t load world state.</div>
|
||||
) : (
|
||||
<>
|
||||
<StatGroup title={null} defs={schema.world} values={state.world} />
|
||||
<StatGroup title="You" defs={schema.player} values={state.player} />
|
||||
{npcIds.map((id) => (
|
||||
<StatGroup key={id} title={cardName(id)} defs={schema.npc} values={npcState[id]} />
|
||||
))}
|
||||
{flags.length > 0 && (
|
||||
<div className="ws-group">
|
||||
<h3 className="ws-group-title">Flags</h3>
|
||||
<ul className="ws-flags">
|
||||
{flags.map(([fid, def]) => {
|
||||
const on = flagState[fid] ?? !!def.initial
|
||||
return (
|
||||
<li key={fid} title={def.desc || undefined}>
|
||||
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
|
||||
<span className="ws-flag-name">{fid}</span>
|
||||
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{milestones.length > 0 && (
|
||||
<div className="ws-group">
|
||||
<h3 className="ws-group-title">Milestones</h3>
|
||||
<ul className="ws-milestones">
|
||||
{milestones.map(([mid, def]) => {
|
||||
const done = reached[mid]?.reached
|
||||
return (
|
||||
<li key={mid} className={done ? 'done' : ''}>
|
||||
<span className="ws-check">{done ? '☑' : '☐'}</span>
|
||||
{def.desc || mid}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Per-turn RPG state change, shown when inspecting a past turn's snapshot.
|
||||
function WorldStateReport({ worldState }) {
|
||||
if (!worldState) return null
|
||||
const delta = worldState.delta || {}
|
||||
const report = worldState.report || {}
|
||||
const paths = Object.keys(delta)
|
||||
const rejected = report.rejected || []
|
||||
const clamped = new Set((report.clamped || []).map((c) => c.path))
|
||||
if (paths.length === 0 && rejected.length === 0) {
|
||||
return (
|
||||
<div className="script-report">
|
||||
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State</span></div>
|
||||
<div className="dim" style={{ fontSize: '0.82rem' }}>No changes this turn.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="script-report">
|
||||
<div className="ctx-header" style={{ padding: '4px 0 2px' }}><span>World State changes</span></div>
|
||||
<ul className="ws-report">
|
||||
{paths.map((p) => (
|
||||
<li key={p}>
|
||||
<code>{p}</code> <span className="ws-delta">{String(delta[p])}</span>
|
||||
{clamped.has(p) && <span className="ws-flag"> clamped</span>}
|
||||
</li>
|
||||
))}
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`r${i}`} className="ws-rejected">
|
||||
<code>{r.path}</code> rejected — {r.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScriptReport({ script }) {
|
||||
if (!script || (!script.logs?.length && !script.errors?.length && !script.context_changed)) {
|
||||
return null
|
||||
@@ -591,6 +769,7 @@ function InsightsPanel({ advId, inspectActionId, onClearInspect, refreshKey }) {
|
||||
</div>
|
||||
))}
|
||||
<ScriptReport script={report.script} />
|
||||
<WorldStateReport worldState={report.world_state} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -794,6 +973,7 @@ export default function Play() {
|
||||
|
||||
return (
|
||||
<div className={`play-layout ${panel ? 'with-panel' : ''}`}>
|
||||
<WorldStateDrawer advId={id} refreshKey={actions.length} cards={adventure.story_cards} />
|
||||
<StatusDrawer advId={id} refreshKey={actions.length} />
|
||||
<div className="page play-page">
|
||||
<div className="page-header">
|
||||
|
||||
@@ -9,6 +9,9 @@ export default function ScenarioEditor() {
|
||||
const [scenario, setScenario] = useState(null)
|
||||
const [allScripts, setAllScripts] = useState([])
|
||||
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('')
|
||||
// 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())
|
||||
@@ -18,7 +21,10 @@ export default function ScenarioEditor() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getScenario(id).then(setScenario).catch(() => navigate('/scenarios'))
|
||||
api.getScenario(id).then((s) => {
|
||||
setScenario(s)
|
||||
setSchemaText(s.stat_schema ? JSON.stringify(s.stat_schema, null, 2) : '')
|
||||
}).catch(() => navigate('/scenarios'))
|
||||
api.listScripts().then(setAllScripts).catch(() => {})
|
||||
}, [id, navigate])
|
||||
|
||||
@@ -32,6 +38,39 @@ export default function ScenarioEditor() {
|
||||
})
|
||||
}
|
||||
|
||||
// RPG world-state schema: edited as raw JSON, only saved when it parses to an
|
||||
// object (empty text clears the RPG layer). Invalid JSON shows an inline error
|
||||
// and holds off saving.
|
||||
const setSchema = (text) => {
|
||||
setSchemaText(text)
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
setSchemaError('')
|
||||
debounceSave('stat_schema', () => 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.')
|
||||
return
|
||||
}
|
||||
setSchemaError('')
|
||||
debounceSave('stat_schema', () => saveSchema(parsed))
|
||||
}
|
||||
|
||||
const saveSchema = async (parsed) => {
|
||||
await api.updateScenario(id, { stat_schema: parsed })
|
||||
setScenario((s) => ({ ...s, stat_schema: parsed }))
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
}
|
||||
|
||||
const addCard = async () => {
|
||||
const card = await api.createStoryCard({ scenario_id: Number(id) })
|
||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
|
||||
@@ -152,6 +191,25 @@ export default function ScenarioEditor() {
|
||||
onChange={updateCard} onDelete={() => deleteCard(card.id)} />
|
||||
))}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>World State (RPG)</h2>
|
||||
</div>
|
||||
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
|
||||
Optional. Define stats (with bands and rules) and milestones as a JSON object,
|
||||
and the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
|
||||
Leave blank for a plain narrative scenario. NPC stats apply to story cards of the
|
||||
configured <code>npc_card_types</code>.
|
||||
</p>
|
||||
<textarea
|
||||
className="schema-editor"
|
||||
value={schemaText}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
placeholder={'{\n "player": { "hp": { "min": 0, "max": 100, "initial": 100 } },\n "milestones": { "goal": { "desc": "..." } }\n}'}
|
||||
/>
|
||||
{schemaError && <div className="schema-error">⚠ {schemaError}</div>}
|
||||
|
||||
<div className="page-header" style={{ marginTop: 28 }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user