Add a world-state edit mode for overriding live RPG values

Players/authors can now directly correct the live values of stats the
schema already defines (health, trust, flags, milestones, etc.) without
waiting for the AI to emit a delta. New apply_override() sets values
absolutely rather than adding deltas, and — unlike the AI-facing
apply_delta() — bypasses cooldown/max_delta_per_turn and lets
milestones be un-set, since this is a deliberate correction rather
than a turn to police. Exposed via PUT /adventures/{id}/world-state
and an edit toggle in the World State drawer. Also fixes the
schema-editor stat-kind dropdown and free-text initial-value input
to size consistently with the numeric fields next to them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 18:19:31 +05:30
co-authored by Claude Sonnet 5
parent 8c02fe30d7
commit 141eec7c64
9 changed files with 351 additions and 17 deletions
+22
View File
@@ -190,6 +190,28 @@ def get_world_state(
}
@router.put("/{adventure_id}/world-state")
def override_world_state(
adventure_id: int,
overrides: dict = Body(...),
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
"""Directly edit the live RPG values (a manual correction, not a turn).
`overrides` maps paths (e.g. "player.hp", "npc.gwen.trust", "flags.x",
"milestones.y") to their new absolute value. Unknown paths/wrong types are
rejected individually; the rest still apply."""
adventure = get_adventure_or_404(adventure_id, db, user)
schema = adventure.scenario.stat_schema if adventure.scenario else None
if not worldstate.has_schema(schema):
raise HTTPException(400, "This adventure has no RPG world-state layer")
state = adventure.world_state if isinstance(adventure.world_state, dict) else {}
new_state, report = worldstate.apply_override(state, schema, overrides)
adventure.world_state = new_state
db.commit()
return {"state": new_state, "report": report}
def snapshot_state(adventure: models.Adventure) -> dict:
"""Deep copy of the shared script_state, to staple onto an action so undo/
retry can restore it. Independent of later hook mutations."""
+2
View File
@@ -4,6 +4,7 @@ module validates and clamps them against a scenario's stat_schema."""
from .engine import (
EMIT_RULE,
apply_delta,
apply_override,
band_label,
extract_delta,
has_schema,
@@ -17,6 +18,7 @@ from .engine import (
__all__ = [
"EMIT_RULE",
"apply_delta",
"apply_override",
"band_label",
"extract_delta",
"has_schema",
+114
View File
@@ -255,6 +255,120 @@ def _apply_text_stat(container: dict, key: str, stat_def: dict, change,
report["applied"].append({"path": path, "old": old, "new": new})
def apply_override(world_state: dict, stat_schema: dict, overrides: dict) -> tuple[dict, dict]:
"""Directly set live values — a manual author/admin edit, not an AI turn.
Unlike `apply_delta`: numeric stats are SET rather than added to, and
`cooldown`/`max_delta_per_turn`/the counter-can't-decrease rule are all
ignored (a deliberate correction, not an AI move to police). Milestones
can be toggled either way, not only marked reached. Values are still
validated against the schema (unknown path/type is rejected) and numeric
values still clamp to min/max."""
ws = copy.deepcopy(world_state) if isinstance(world_state, dict) else {}
if not ws:
ws = instantiate(stat_schema)
report: dict = {"applied": [], "rejected": []}
if not isinstance(overrides, dict):
return ws, report
milestones = stat_schema.get("milestones") or {}
flag_defs = stat_schema.get("flags") or {}
npcs = stat_schema.get("npcs") or {}
def set_stat(container: dict, key: str, stat_def: dict, value, path: str) -> None:
if stat_def.get("type") == "text":
if not isinstance(value, str):
report["rejected"].append({"path": path, "reason": "not a string"})
return
new = value.strip()
max_len = stat_def.get("max_length")
if isinstance(max_len, int) and max_len > 0 and len(new) > max_len:
new = new[:max_len]
old = container.get(key, stat_def.get("initial", ""))
container[key] = new
report["applied"].append({"path": path, "old": old, "new": new})
return
num = _coerce_number(value)
if num is None:
report["rejected"].append({"path": path, "reason": "not a number"})
return
old = container.get(key, stat_def.get("initial", 0))
lo, hi = stat_def.get("min"), stat_def.get("max")
if lo is not None and num < lo:
num = lo
if hi is not None and num > hi:
num = hi
if isinstance(old, int) and float(num).is_integer():
num = int(num)
container[key] = num
report["applied"].append({"path": path, "old": old, "new": num})
for raw_path, value in overrides.items():
path = str(raw_path)
parts = path.split(".")
if parts[0] == "flags" and len(parts) == 2:
fid = parts[1]
if fid not in flag_defs:
report["rejected"].append({"path": path, "reason": "unknown flag"})
continue
if not isinstance(value, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
continue
flags = ws.setdefault("flags", {})
old = bool(flags.get(fid, False))
flags[fid] = value
report["applied"].append({"path": path, "old": old, "new": value})
continue
if parts[0] == "milestones" and len(parts) == 2:
mid = parts[1]
if mid not in milestones:
report["rejected"].append({"path": path, "reason": "unknown milestone"})
continue
if not isinstance(value, bool):
report["rejected"].append({"path": path, "reason": "not a boolean"})
continue
reached = ws.setdefault("milestones", {})
old = bool(reached.get(mid, {}).get("reached"))
if value:
reached[mid] = {"reached": True}
else:
reached.pop(mid, None)
report["applied"].append({"path": path, "old": old, "new": value})
continue
if parts[0] in STAT_SECTIONS and len(parts) == 2:
stat_def = (stat_schema.get(parts[0]) or {}).get(parts[1])
if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown stat"})
continue
container = ws.setdefault(parts[0], {})
set_stat(container, parts[1], stat_def, value, path)
continue
if parts[0] == "npc" and len(parts) == 3:
ndef = npcs.get(parts[1])
if not isinstance(ndef, dict):
report["rejected"].append({"path": path, "reason": "unknown npc"})
continue
stat_defs = ndef.get("stats") or {}
stat_def = stat_defs.get(parts[2])
if not isinstance(stat_def, dict):
report["rejected"].append({"path": path, "reason": "unknown npc stat"})
continue
npc_state = ws.setdefault("npc", {})
container = npc_state.setdefault(parts[1], _initials(stat_defs))
set_stat(container, parts[2], stat_def, value, path)
continue
report["rejected"].append({"path": path, "reason": "unknown path"})
return ws, report
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
action_index: int) -> tuple[dict, dict]:
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
+51
View File
@@ -149,6 +149,57 @@ def test_flag_rejects_non_bool_and_unknown():
assert ws["flags"]["has_key"] is False
def test_override_sets_absolute_value_bypassing_cap():
# A manual override isn't policed by max_delta_per_turn like a turn is —
# it sets the value directly (still clamped to min/max).
ws, report = w.apply_override(fresh(), SCHEMA, {"player.hp": 10})
assert ws["player"]["hp"] == 10
assert report["applied"] == [{"path": "player.hp", "old": 100, "new": 10}]
ws, report = w.apply_override(ws, SCHEMA, {"player.hp": 999})
assert ws["player"]["hp"] == 100 # still clamps to max
def test_override_bypasses_cooldown_and_counter_rule():
ws, _ = w.apply_override(fresh(), SCHEMA, {"npc.gwen.trust": 10})
# a second override immediately after would be blocked by cooldown under
# apply_delta, but override ignores cooldown entirely.
ws, report = w.apply_override(ws, SCHEMA, {"npc.gwen.trust": -5})
assert ws["npc"]["gwen"]["trust"] == -5
assert report["applied"]
# counters can be set down directly too (a correction, not a turn).
ws, report = w.apply_override(ws, SCHEMA, {"world.day": 1})
assert ws["world"]["day"] == 1
assert report["applied"]
def test_override_text_stat_replaces():
ws, report = w.apply_override(fresh(), SCHEMA, {"player.outfit": "knight's plate"})
assert ws["player"]["outfit"] == "knight's plate"
assert report["applied"]
def test_override_milestone_toggles_both_ways():
ws, _ = w.apply_override(fresh(), SCHEMA, {"milestones.rescue_gwen": True})
assert ws["milestones"]["rescue_gwen"]["reached"] is True
# unlike apply_delta, override can un-set a milestone.
ws, report = w.apply_override(ws, SCHEMA, {"milestones.rescue_gwen": False})
assert "rescue_gwen" not in ws["milestones"]
assert report["applied"]
def test_override_rejects_unknown_and_bad_type():
ws, report = w.apply_override(fresh(), SCHEMA, {
"player.hp": "not a number",
"npc.bogus.trust": 5,
"flags.nope": True,
})
reasons = {r["path"]: r["reason"] for r in report["rejected"]}
assert reasons["player.hp"] == "not a number"
assert reasons["npc.bogus.trust"] == "unknown npc"
assert reasons["flags.nope"] == "unknown flag"
assert ws["player"]["hp"] == 100 # untouched
def test_reference_includes_desc_and_bands_independently():
guide = w.render_reference(SCHEMA)
# hp has both a description and a band ladder.
@@ -163,6 +163,22 @@ def test_world_state_endpoint(client):
assert body["state"]["player"]["hp"] == 70
def test_override_world_state_endpoint(client):
r = client.put(f"/api/adventures/{client.adv_id}/world-state",
json={"player.hp": 5, "flags.alarm": True, "npc.bogus.trust": 1})
assert r.status_code == 200, r.text
body = r.json()
assert body["state"]["player"]["hp"] == 5
assert body["state"]["flags"]["alarm"] is True
assert body["report"]["rejected"][0]["reason"] == "unknown npc"
# persisted to the DB, not just the response.
assert _world(client.adv_id)["player"]["hp"] == 5
# bypasses max_delta_per_turn (30) — a direct correction, not a turn.
r = client.put(f"/api/adventures/{client.adv_id}/world-state", json={"player.hp": 100})
assert r.json()["state"]["player"]["hp"] == 100
def test_undo_reverts_world_state(client):
_play(client)
assert _world(client.adv_id)["player"]["hp"] == 70
+1 -1
View File
@@ -72,7 +72,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
</div>
<div className="se-fields">
{isText ? (
<label className="se-num">
<label className="se-num se-initial-text">
<span>initial</span>
<input type="text" value={def.initial ?? ''}
onChange={(e) => set('initial', e.target.value)} />
+2
View File
@@ -81,6 +81,8 @@ export const api = {
getAdventure: (id) => request(`/adventures/${id}`),
getScriptState: (id) => request(`/adventures/${id}/script-state`),
getWorldState: (id) => request(`/adventures/${id}/world-state`),
overrideWorldState: (id, overrides) =>
request(`/adventures/${id}/world-state`, { method: 'PUT', body: JSON.stringify(overrides) }),
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' }),
+11
View File
@@ -645,6 +645,16 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
.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; }
.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; }
.ws-edit-actions { display: flex; gap: 6px; }
.ws-edit-input {
width: 100%;
margin-top: 3px;
padding: 4px 7px;
font-size: 0.82rem;
border-radius: 6px;
}
.schema-editor {
width: 100%;
@@ -783,6 +793,7 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
.se-num span { font-size: 0.7rem; color: var(--text-dim); }
.se-num input, .se-num select { width: 72px; padding: 3px 6px; font-size: 0.82rem; border-radius: 6px; }
.se-kind select { width: 118px; }
.se-initial-text input { width: 220px; }
.se-check {
display: flex; align-items: center; gap: 5px;
font-size: 0.8rem; color: var(--text-dim);
+132 -16
View File
@@ -496,8 +496,27 @@ function bandLabel(def, value) {
return null
}
function StatRow({ name, def, value }) {
if (def?.type === 'text') {
function StatRow({ name, def, value, editing, onChange }) {
const isText = def?.type === 'text'
if (editing) {
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
</div>
{isText ? (
<input className="ws-edit-input ws-edit-text" type="text" value={value ?? ''}
onChange={(e) => onChange(e.target.value)} />
) : (
<input className="ws-edit-input ws-edit-num" type="number" value={value ?? 0}
min={typeof def?.min === 'number' ? def.min : undefined}
max={typeof def?.max === 'number' ? def.max : undefined}
onChange={(e) => onChange(e.target.value === '' ? 0 : Number(e.target.value))} />
)}
</div>
)
}
if (isText) {
const text = typeof value === 'string' ? value : (def?.initial ?? '')
return (
<div className="ws-stat">
@@ -529,26 +548,74 @@ function StatRow({ name, def, value }) {
)
}
function StatGroup({ title, defs, values, desc }) {
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
// is a slice of the drawer's flat path->value map for this group's prefix.
function StatGroup({ title, defs, values, desc, editing, draft, onEdit }) {
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={desc || undefined}>{title}</h3>}
{entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def} value={values?.[name]} />
<StatRow key={name} name={name} def={def}
value={editing ? draft?.[name] : values?.[name]}
editing={editing}
onChange={editing ? (v) => onEdit(name, v) : undefined} />
))}
</div>
)
}
// Flatten a schema+state into path->value pairs ("player.hp", "npc.gwen.trust",
// "flags.has_key", "milestones.escaped") to seed an edit-mode draft.
function buildWorldStateDraft(schema, state) {
const draft = {}
const addStats = (prefix, defs, values) => {
for (const [name, def] of Object.entries(defs || {})) {
if (!def || typeof def !== 'object') continue
const fallback = def.type === 'text' ? '' : 0
draft[`${prefix}.${name}`] = values?.[name] ?? def.initial ?? fallback
}
}
addStats('world', schema.world, state.world)
addStats('player', schema.player, state.player)
const npcState = state.npc || {}
for (const [id, ndef] of Object.entries(schema.npcs || {})) {
addStats(`npc.${id}`, ndef?.stats, npcState[id])
}
const flagState = state.flags || {}
for (const [name, def] of Object.entries(schema.flags || {})) {
draft[`flags.${name}`] = flagState[name] ?? !!def?.initial
}
const reached = state.milestones || {}
for (const mid of Object.keys(schema.milestones || {})) {
draft[`milestones.${mid}`] = !!reached[mid]?.reached
}
return draft
}
// Slice a flat path->value draft down to one group's {statName: value} map.
function sliceDraft(draft, prefix) {
const out = {}
const p = `${prefix}.`
for (const [k, v] of Object.entries(draft || {})) {
if (k.startsWith(p)) out[k.slice(p.length)] = v
}
return out
}
// 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.
// the adventure's scenario defines a stat_schema. An edit mode lets the
// player/author directly override the live values (a manual correction, not
// a turn) — it never adds new stats, only edits ones the schema already defines.
function WorldStateDrawer({ advId, refreshKey }) {
const [open, setOpen] = useState(false)
const [data, setData] = useState(null) // { state, schema }
const [failed, setFailed] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(null) // flat path->value, only while editing
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
api.getWorldState(advId)
@@ -570,6 +637,17 @@ function WorldStateDrawer({ advId, refreshKey }) {
const milestones = Object.entries(schema.milestones || {})
const reached = state.milestones || {}
const startEdit = () => { setDraft(buildWorldStateDraft(schema, state)); setEditing(true) }
const cancelEdit = () => { setEditing(false); setDraft(null) }
const setPath = (path, value) => setDraft((d) => ({ ...d, [path]: value }))
const saveEdit = () => {
setSaving(true)
api.overrideWorldState(advId, draft)
.then(() => { setEditing(false); setDraft(null); load() })
.catch(() => setFailed(true))
.finally(() => setSaving(false))
}
return (
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
@@ -580,29 +658,57 @@ function WorldStateDrawer({ advId, refreshKey }) {
<div className="status-body">
<div className="side-panel-header">
<h2>World State</h2>
<button onClick={load} title="Refresh">↻</button>
{editing ? (
<div className="ws-edit-actions">
<button onClick={cancelEdit} disabled={saving} title="Discard changes">Cancel</button>
<button onClick={saveEdit} disabled={saving} title="Save overrides">
{saving ? 'Saving…' : 'Save'}
</button>
</div>
) : (
<div className="ws-edit-actions">
<button onClick={startEdit} title="Edit current values">✎</button>
<button onClick={load} title="Refresh">↻</button>
</div>
)}
</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} />
<StatGroup title={null} defs={schema.world} values={state.world}
editing={editing} draft={sliceDraft(draft, 'world')}
onEdit={(name, v) => setPath(`world.${name}`, v)} />
<StatGroup title="You" defs={schema.player} values={state.player}
editing={editing} draft={sliceDraft(draft, 'player')}
onEdit={(name, v) => setPath(`player.${name}`, v)} />
{npcs.map(([id, def]) => (
<StatGroup key={id} title={def.name || id} desc={def.desc}
defs={def.stats} values={npcState[id]} />
defs={def.stats} values={npcState[id]}
editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)} />
))}
{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
const on = editing ? !!draft?.[`flags.${fid}`] : (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>
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={on}
onChange={(e) => setPath(`flags.${fid}`, e.target.checked)} />
<span className="ws-flag-name">{fid}</span>
</label>
) : (
<>
<span className={`ws-flag-dot ${on ? 'on' : ''}`} />
<span className="ws-flag-name">{fid}</span>
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
</>
)}
</li>
)
})}
@@ -614,11 +720,21 @@ function WorldStateDrawer({ advId, refreshKey }) {
<h3 className="ws-group-title">Milestones</h3>
<ul className="ws-milestones">
{milestones.map(([mid, def]) => {
const done = reached[mid]?.reached
const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached
return (
<li key={mid} className={done ? 'done' : ''}>
<span className="ws-check">{done ? '☑' : '☐'}</span>
{def.desc || mid}
{editing ? (
<label className="ws-flag-edit">
<input type="checkbox" checked={done}
onChange={(e) => setPath(`milestones.${mid}`, e.target.checked)} />
<span>{def.desc || mid}</span>
</label>
) : (
<>
<span className="ws-check">{done ? '☑' : '☐'}</span>
{def.desc || mid}
</>
)}
</li>
)
})}