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:
co-authored by
Claude Sonnet 5
parent
8c02fe30d7
commit
141eec7c64
@@ -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:
|
def snapshot_state(adventure: models.Adventure) -> dict:
|
||||||
"""Deep copy of the shared script_state, to staple onto an action so undo/
|
"""Deep copy of the shared script_state, to staple onto an action so undo/
|
||||||
retry can restore it. Independent of later hook mutations."""
|
retry can restore it. Independent of later hook mutations."""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ module validates and clamps them against a scenario's stat_schema."""
|
|||||||
from .engine import (
|
from .engine import (
|
||||||
EMIT_RULE,
|
EMIT_RULE,
|
||||||
apply_delta,
|
apply_delta,
|
||||||
|
apply_override,
|
||||||
band_label,
|
band_label,
|
||||||
extract_delta,
|
extract_delta,
|
||||||
has_schema,
|
has_schema,
|
||||||
@@ -17,6 +18,7 @@ from .engine import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"EMIT_RULE",
|
"EMIT_RULE",
|
||||||
"apply_delta",
|
"apply_delta",
|
||||||
|
"apply_override",
|
||||||
"band_label",
|
"band_label",
|
||||||
"extract_delta",
|
"extract_delta",
|
||||||
"has_schema",
|
"has_schema",
|
||||||
|
|||||||
@@ -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})
|
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,
|
def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
||||||
action_index: int) -> tuple[dict, dict]:
|
action_index: int) -> tuple[dict, dict]:
|
||||||
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
|
"""Validate/clamp `delta` against `stat_schema` and apply to a copy of
|
||||||
|
|||||||
@@ -149,6 +149,57 @@ def test_flag_rejects_non_bool_and_unknown():
|
|||||||
assert ws["flags"]["has_key"] is False
|
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():
|
def test_reference_includes_desc_and_bands_independently():
|
||||||
guide = w.render_reference(SCHEMA)
|
guide = w.render_reference(SCHEMA)
|
||||||
# hp has both a description and a band ladder.
|
# 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
|
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):
|
def test_undo_reverts_world_state(client):
|
||||||
_play(client)
|
_play(client)
|
||||||
assert _world(client.adv_id)["player"]["hp"] == 70
|
assert _world(client.adv_id)["player"]["hp"] == 70
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="se-fields">
|
<div className="se-fields">
|
||||||
{isText ? (
|
{isText ? (
|
||||||
<label className="se-num">
|
<label className="se-num se-initial-text">
|
||||||
<span>initial</span>
|
<span>initial</span>
|
||||||
<input type="text" value={def.initial ?? ''}
|
<input type="text" value={def.initial ?? ''}
|
||||||
onChange={(e) => set('initial', e.target.value)} />
|
onChange={(e) => set('initial', e.target.value)} />
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export const api = {
|
|||||||
getAdventure: (id) => request(`/adventures/${id}`),
|
getAdventure: (id) => request(`/adventures/${id}`),
|
||||||
getScriptState: (id) => request(`/adventures/${id}/script-state`),
|
getScriptState: (id) => request(`/adventures/${id}/script-state`),
|
||||||
getWorldState: (id) => request(`/adventures/${id}/world-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) }),
|
createAdventure: (data) => request('/adventures', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
updateAdventure: (id, data) => request(`/adventures/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||||
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
deleteAdventure: (id) => request(`/adventures/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -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-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||||
.ws-flag-name { flex: 1; }
|
.ws-flag-name { flex: 1; }
|
||||||
.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
|
.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 {
|
.schema-editor {
|
||||||
width: 100%;
|
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 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-num input, .se-num select { width: 72px; padding: 3px 6px; font-size: 0.82rem; border-radius: 6px; }
|
||||||
.se-kind select { width: 118px; }
|
.se-kind select { width: 118px; }
|
||||||
|
.se-initial-text input { width: 220px; }
|
||||||
.se-check {
|
.se-check {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex; align-items: center; gap: 5px;
|
||||||
font-size: 0.8rem; color: var(--text-dim);
|
font-size: 0.8rem; color: var(--text-dim);
|
||||||
|
|||||||
+126
-10
@@ -496,8 +496,27 @@ function bandLabel(def, value) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatRow({ name, def, value }) {
|
function StatRow({ name, def, value, editing, onChange }) {
|
||||||
if (def?.type === 'text') {
|
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 ?? '')
|
const text = typeof value === 'string' ? value : (def?.initial ?? '')
|
||||||
return (
|
return (
|
||||||
<div className="ws-stat">
|
<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')
|
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
|
||||||
if (entries.length === 0) return null
|
if (entries.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="ws-group">
|
<div className="ws-group">
|
||||||
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
|
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
|
||||||
{entries.map(([name, def]) => (
|
{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>
|
</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
|
// Collapsible left rail showing the RPG world state (Phase 12): world/player/NPC
|
||||||
// stats with bands + bars, and a milestones checklist. Renders nothing unless
|
// 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 }) {
|
function WorldStateDrawer({ advId, refreshKey }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [data, setData] = useState(null) // { state, schema }
|
const [data, setData] = useState(null) // { state, schema }
|
||||||
const [failed, setFailed] = useState(false)
|
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(() => {
|
const load = useCallback(() => {
|
||||||
api.getWorldState(advId)
|
api.getWorldState(advId)
|
||||||
@@ -570,6 +637,17 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
|||||||
const milestones = Object.entries(schema.milestones || {})
|
const milestones = Object.entries(schema.milestones || {})
|
||||||
const reached = state.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 (
|
return (
|
||||||
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
|
<div className={`status-drawer ws-drawer ${open ? 'open' : ''}`}>
|
||||||
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
|
<button className="status-toggle" onClick={() => setOpen((o) => !o)}
|
||||||
@@ -580,29 +658,57 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
|||||||
<div className="status-body">
|
<div className="status-body">
|
||||||
<div className="side-panel-header">
|
<div className="side-panel-header">
|
||||||
<h2>World State</h2>
|
<h2>World State</h2>
|
||||||
|
{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>
|
<button onClick={load} title="Refresh">↻</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="empty">Couldn’t load world state.</div>
|
<div className="empty">Couldn’t load world state.</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<StatGroup title={null} defs={schema.world} values={state.world} />
|
<StatGroup title={null} defs={schema.world} values={state.world}
|
||||||
<StatGroup title="You" defs={schema.player} values={state.player} />
|
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]) => (
|
{npcs.map(([id, def]) => (
|
||||||
<StatGroup key={id} title={def.name || id} desc={def.desc}
|
<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 && (
|
{flags.length > 0 && (
|
||||||
<div className="ws-group">
|
<div className="ws-group">
|
||||||
<h3 className="ws-group-title">Flags</h3>
|
<h3 className="ws-group-title">Flags</h3>
|
||||||
<ul className="ws-flags">
|
<ul className="ws-flags">
|
||||||
{flags.map(([fid, def]) => {
|
{flags.map(([fid, def]) => {
|
||||||
const on = flagState[fid] ?? !!def.initial
|
const on = editing ? !!draft?.[`flags.${fid}`] : (flagState[fid] ?? !!def.initial)
|
||||||
return (
|
return (
|
||||||
<li key={fid} title={def.desc || undefined}>
|
<li key={fid} title={def.desc || undefined}>
|
||||||
|
{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-dot ${on ? 'on' : ''}`} />
|
||||||
<span className="ws-flag-name">{fid}</span>
|
<span className="ws-flag-name">{fid}</span>
|
||||||
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
|
<span className="ws-flag-val">{on ? 'yes' : 'no'}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -614,11 +720,21 @@ function WorldStateDrawer({ advId, refreshKey }) {
|
|||||||
<h3 className="ws-group-title">Milestones</h3>
|
<h3 className="ws-group-title">Milestones</h3>
|
||||||
<ul className="ws-milestones">
|
<ul className="ws-milestones">
|
||||||
{milestones.map(([mid, def]) => {
|
{milestones.map(([mid, def]) => {
|
||||||
const done = reached[mid]?.reached
|
const done = editing ? !!draft?.[`milestones.${mid}`] : !!reached[mid]?.reached
|
||||||
return (
|
return (
|
||||||
<li key={mid} className={done ? 'done' : ''}>
|
<li key={mid} className={done ? 'done' : ''}>
|
||||||
|
{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>
|
<span className="ws-check">{done ? '☑' : '☐'}</span>
|
||||||
{def.desc || mid}
|
{def.desc || mid}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user