diff --git a/backend/app/seed_data/04-rpg-world-state.json b/backend/app/seed_data/04-rpg-world-state.json index 86d017f..7e69380 100644 --- a/backend/app/seed_data/04-rpg-world-state.json +++ b/backend/app/seed_data/04-rpg-world-state.json @@ -21,6 +21,10 @@ "desc": "Magical energy for spells; spent casting, restored by resting.", "min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25, "bands": [[0, 10, "drained"], [10, 30, "steady"], [30, 51, "brimming"]] + }, + "outfit": { + "type": "text", "initial": "worn leather armor, travel-stained", + "desc": "What the player is currently wearing/carrying visibly; update it when gear is gained, lost, or changed." } }, "flags": { diff --git a/backend/app/worldstate/engine.py b/backend/app/worldstate/engine.py index f2389d3..71e13a4 100644 --- a/backend/app/worldstate/engine.py +++ b/backend/app/worldstate/engine.py @@ -35,10 +35,14 @@ EMIT_RULE = ( '"player.", ' '"world.", "npc.." (use the id in parentheses, e.g. npc.gwen.trust, ' 'not the display name); "flags.": true or false to toggle an on/off state; and ' - '"milestones.": true when an objective is completed. Send only things that actually ' + '"milestones.": true when an objective is completed. Some stats marked (free text) in ' + "the stat guide hold a short string instead of a number — for those, send the new value " + "in full (not a delta), e.g. what the player is now wearing or holding; only send it when " + "it actually changed. Send only things that actually " "changed and never restate unchanged values; if truly nothing changed, omit the block. " "Example:\n" - '```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true}\n```' + '```state\n{"player.hp": -15, "npc.gwen.trust": 5, "milestones.escaped": true, ' + '"player.outfit": "torn traveling cloak"}\n```' ) # ```state { ... } ``` (also tolerates ```json or an unlabelled fence); DOTALL. @@ -71,7 +75,7 @@ def npc_triggers(ndef: dict, key: str) -> list[str]: def _initials(defs: dict) -> dict: return { - name: d.get("initial", 0) + name: d.get("initial", "" if d.get("type") == "text" else 0) for name, d in defs.items() if isinstance(d, dict) } @@ -222,6 +226,35 @@ def _apply_stat(container: dict, key: str, stat_def: dict, change, report["clamped"].append(entry) +def _apply_text_stat(container: dict, key: str, stat_def: dict, change, + path: str, action_index: int, meta: dict, report: dict) -> None: + """Free-text stats replace rather than add: the AI sends the new value in + full, not a delta. No clamping/bands apply — only an optional cooldown and + an optional max_length truncation.""" + if not isinstance(change, str): + report["rejected"].append({"path": path, "reason": "not a string"}) + return + + cooldown = stat_def.get("cooldown") or 0 + last = meta["last_changed"].get(path) + if cooldown and last is not None and action_index - last < cooldown: + report["rejected"].append({"path": path, "reason": "cooldown"}) + return + + new = change.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", "")) + if new == old: + return # no actual change — silent no-op + + container[key] = new + meta["last_changed"][path] = action_index + report["applied"].append({"path": path, "old": old, "new": new}) + + 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 @@ -283,8 +316,12 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict, report["rejected"].append({"path": path, "reason": "unknown stat"}) continue container = ws.setdefault(parts[0], {}) - _apply_stat(container, parts[1], stat_def, change, path, - action_index, meta, report) + if stat_def.get("type") == "text": + _apply_text_stat(container, parts[1], stat_def, change, path, + action_index, meta, report) + else: + _apply_stat(container, parts[1], stat_def, change, path, + action_index, meta, report) continue # npc.. — each NPC has its own stat defs. @@ -300,8 +337,12 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict, continue npc_state = ws.setdefault("npc", {}) container = npc_state.setdefault(parts[1], _initials(stat_defs)) - _apply_stat(container, parts[2], stat_def, change, path, - action_index, meta, report) + if stat_def.get("type") == "text": + _apply_text_stat(container, parts[2], stat_def, change, path, + action_index, meta, report) + else: + _apply_stat(container, parts[2], stat_def, change, path, + action_index, meta, report) continue report["rejected"].append({"path": path, "reason": "unknown path"}) @@ -318,6 +359,10 @@ def _stat_line(defs: dict, values: dict) -> str: for name, d in defs.items(): if not isinstance(d, dict): continue + if d.get("type") == "text": + val = values.get(name, d.get("initial", "")) + parts.append(f'{name} "{val}"' if val else f"{name} (unset)") + continue val = values.get(name, d.get("initial", 0)) hi = d.get("max") shown = f"{val}/{hi}" if hi is not None else f"{val}" @@ -386,6 +431,9 @@ def _describe_stat(name: str, d: dict) -> str | None: # Fragments are joined with "; " and end with a single ".", so drop any # trailing period the author already put on the description. bits.append(desc.strip().rstrip(".")) + if d.get("type") == "text": + bits.append("free text") + return f"{name} — {'; '.join(bits)}." if bits else None lo, hi = d.get("min"), d.get("max") if isinstance(lo, (int, float)) and isinstance(hi, (int, float)): bits.append(f"range {lo}–{hi}") diff --git a/backend/tests/test_worldstate.py b/backend/tests/test_worldstate.py index 70ff9f3..57f3565 100644 --- a/backend/tests/test_worldstate.py +++ b/backend/tests/test_worldstate.py @@ -12,6 +12,7 @@ SCHEMA = { "bands": [[0, 20, "very weak"], [20, 40, "hurt"], [40, 60, "minor damage"], [60, 90, "healthy"], [90, 100, "full health"]]}, + "outfit": {"type": "text", "initial": "traveling clothes", "desc": "What the player is wearing"}, }, "npcs": { "gwen": { @@ -40,12 +41,29 @@ def fresh(): def test_instantiate_uses_initials(): ws = fresh() assert ws["world"] == {"day": 1} - assert ws["player"] == {"hp": 100} + assert ws["player"] == {"hp": 100, "outfit": "traveling clothes"} # Each defined NPC is instantiated up front with its own stats. assert ws["npc"] == {"gwen": {"trust": 0}, "drake": {"ferocity": 50}} assert ws["milestones"] == {} +def test_text_stat_replaces_value(): + ws, report = w.apply_delta(fresh(), SCHEMA, {"player.outfit": "muddy cloak"}, 1) + assert ws["player"]["outfit"] == "muddy cloak" + assert report["applied"][0] == {"path": "player.outfit", "old": "traveling clothes", "new": "muddy cloak"} + + +def test_text_stat_rejects_non_string(): + ws, report = w.apply_delta(fresh(), SCHEMA, {"player.outfit": 5}, 1) + assert ws["player"]["outfit"] == "traveling clothes" + assert report["rejected"][0]["reason"] == "not a string" + + +def test_text_stat_noop_when_unchanged(): + ws, report = w.apply_delta(fresh(), SCHEMA, {"player.outfit": "traveling clothes"}, 1) + assert report["applied"] == [] + + def test_per_npc_distinct_stats(): ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.drake.ferocity": 20}, 1) assert ws["npc"]["drake"]["ferocity"] == 70 diff --git a/frontend/src/SchemaEditor.jsx b/frontend/src/SchemaEditor.jsx index 77bce74..71c1a13 100644 --- a/frontend/src/SchemaEditor.jsx +++ b/frontend/src/SchemaEditor.jsx @@ -44,6 +44,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) { else next[field] = val onChange(next) } + const isText = def.type === 'text' const bands = Array.isArray(def.bands) ? def.bands : [] const setBands = (nb) => set('bands', nb.length ? nb : undefined) const updBand = (i, j, raw) => { @@ -52,6 +53,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) { nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw setBands(nb) } + const setType = (val) => { + const next = { ...def, type: val || undefined } + if (val === 'text') { + // Numeric-only fields don't apply to free text. + delete next.min; delete next.max; delete next.max_delta_per_turn; delete next.bands + if (typeof next.initial !== 'string') next.initial = '' + } else if (typeof next.initial === 'string') { + delete next.initial + } + onChange(next) + } return (
@@ -59,36 +71,52 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
- set('min', v)} /> - set('max', v)} /> - set('initial', v)} /> - set('max_delta_per_turn', v)} /> + {isText ? ( + + ) : ( + <> + set('min', v)} /> + set('max', v)} /> + set('initial', v)} /> + set('max_delta_per_turn', v)} /> + + )} set('cooldown', v)} /> -
set('desc', e.target.value || undefined)} /> -
-
Bands — low, high, label
- {bands.map((b, i) => ( -
- updBand(i, 0, e.target.value)} /> - updBand(i, 1, e.target.value)} /> - updBand(i, 2, e.target.value)} /> - -
- ))} - -
+ {!isText && ( +
+
Bands — low, high, label
+ {bands.map((b, i) => ( +
+ updBand(i, 0, e.target.value)} /> + updBand(i, 1, e.target.value)} /> + updBand(i, 2, e.target.value)} /> + +
+ ))} + +
+ )}
) } diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 440909d..66360a2 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -497,6 +497,17 @@ function bandLabel(def, value) { } function StatRow({ name, def, value }) { + if (def?.type === 'text') { + const text = typeof value === 'string' ? value : (def?.initial ?? '') + return ( +
+
+ {name} + {text || '(unset)'} +
+
+ ) + } const val = typeof value === 'number' ? value : (def?.initial ?? 0) const { min, max } = def || {} const hasRange = typeof min === 'number' && typeof max === 'number' && max > min