Add free-text stat type to the world-state RPG layer
Numeric stats couldn't represent things like worn armor or held items. A stat can now be marked type: "text" — the AI reports the new full value instead of a delta, with no clamping/bands (cooldown still applies). Updated the scenario editor's stat kind selector, the World State drawer's stat display, and the emit-rule prompt; added tests and an outfit stat to the RPG demo scenario. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
db1170d0db
commit
42073d5d89
@@ -21,6 +21,10 @@
|
|||||||
"desc": "Magical energy for spells; spent casting, restored by resting.",
|
"desc": "Magical energy for spells; spent casting, restored by resting.",
|
||||||
"min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25,
|
"min": 0, "max": 50, "initial": 30, "max_delta_per_turn": 25,
|
||||||
"bands": [[0, 10, "drained"], [10, 30, "steady"], [30, 51, "brimming"]]
|
"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": {
|
"flags": {
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ EMIT_RULE = (
|
|||||||
'"player.<stat>", '
|
'"player.<stat>", '
|
||||||
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
'"world.<stat>", "npc.<id>.<stat>" (use the id in parentheses, e.g. npc.gwen.trust, '
|
||||||
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
'not the display name); "flags.<name>": true or false to toggle an on/off state; and '
|
||||||
'"milestones.<id>": true when an objective is completed. Send only things that actually '
|
'"milestones.<id>": 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. "
|
"changed and never restate unchanged values; if truly nothing changed, omit the block. "
|
||||||
"Example:\n"
|
"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.
|
# ```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:
|
def _initials(defs: dict) -> dict:
|
||||||
return {
|
return {
|
||||||
name: d.get("initial", 0)
|
name: d.get("initial", "" if d.get("type") == "text" else 0)
|
||||||
for name, d in defs.items()
|
for name, d in defs.items()
|
||||||
if isinstance(d, dict)
|
if isinstance(d, dict)
|
||||||
}
|
}
|
||||||
@@ -222,6 +226,35 @@ def _apply_stat(container: dict, key: str, stat_def: dict, change,
|
|||||||
report["clamped"].append(entry)
|
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,
|
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
|
||||||
@@ -283,8 +316,12 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
|||||||
report["rejected"].append({"path": path, "reason": "unknown stat"})
|
report["rejected"].append({"path": path, "reason": "unknown stat"})
|
||||||
continue
|
continue
|
||||||
container = ws.setdefault(parts[0], {})
|
container = ws.setdefault(parts[0], {})
|
||||||
_apply_stat(container, parts[1], stat_def, change, path,
|
if stat_def.get("type") == "text":
|
||||||
action_index, meta, report)
|
_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
|
continue
|
||||||
|
|
||||||
# npc.<npcId>.<stat> — each NPC has its own stat defs.
|
# npc.<npcId>.<stat> — each NPC has its own stat defs.
|
||||||
@@ -300,8 +337,12 @@ def apply_delta(world_state: dict, stat_schema: dict, delta: dict,
|
|||||||
continue
|
continue
|
||||||
npc_state = ws.setdefault("npc", {})
|
npc_state = ws.setdefault("npc", {})
|
||||||
container = npc_state.setdefault(parts[1], _initials(stat_defs))
|
container = npc_state.setdefault(parts[1], _initials(stat_defs))
|
||||||
_apply_stat(container, parts[2], stat_def, change, path,
|
if stat_def.get("type") == "text":
|
||||||
action_index, meta, report)
|
_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
|
continue
|
||||||
|
|
||||||
report["rejected"].append({"path": path, "reason": "unknown path"})
|
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():
|
for name, d in defs.items():
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
continue
|
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))
|
val = values.get(name, d.get("initial", 0))
|
||||||
hi = d.get("max")
|
hi = d.get("max")
|
||||||
shown = f"{val}/{hi}" if hi is not None else f"{val}"
|
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
|
# Fragments are joined with "; " and end with a single ".", so drop any
|
||||||
# trailing period the author already put on the description.
|
# trailing period the author already put on the description.
|
||||||
bits.append(desc.strip().rstrip("."))
|
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")
|
lo, hi = d.get("min"), d.get("max")
|
||||||
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
|
||||||
bits.append(f"range {lo}–{hi}")
|
bits.append(f"range {lo}–{hi}")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SCHEMA = {
|
|||||||
"bands": [[0, 20, "very weak"], [20, 40, "hurt"],
|
"bands": [[0, 20, "very weak"], [20, 40, "hurt"],
|
||||||
[40, 60, "minor damage"], [60, 90, "healthy"],
|
[40, 60, "minor damage"], [60, 90, "healthy"],
|
||||||
[90, 100, "full health"]]},
|
[90, 100, "full health"]]},
|
||||||
|
"outfit": {"type": "text", "initial": "traveling clothes", "desc": "What the player is wearing"},
|
||||||
},
|
},
|
||||||
"npcs": {
|
"npcs": {
|
||||||
"gwen": {
|
"gwen": {
|
||||||
@@ -40,12 +41,29 @@ def fresh():
|
|||||||
def test_instantiate_uses_initials():
|
def test_instantiate_uses_initials():
|
||||||
ws = fresh()
|
ws = fresh()
|
||||||
assert ws["world"] == {"day": 1}
|
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.
|
# Each defined NPC is instantiated up front with its own stats.
|
||||||
assert ws["npc"] == {"gwen": {"trust": 0}, "drake": {"ferocity": 50}}
|
assert ws["npc"] == {"gwen": {"trust": 0}, "drake": {"ferocity": 50}}
|
||||||
assert ws["milestones"] == {}
|
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():
|
def test_per_npc_distinct_stats():
|
||||||
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.drake.ferocity": 20}, 1)
|
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.drake.ferocity": 20}, 1)
|
||||||
assert ws["npc"]["drake"]["ferocity"] == 70
|
assert ws["npc"]["drake"]["ferocity"] == 70
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
else next[field] = val
|
else next[field] = val
|
||||||
onChange(next)
|
onChange(next)
|
||||||
}
|
}
|
||||||
|
const isText = def.type === 'text'
|
||||||
const bands = Array.isArray(def.bands) ? def.bands : []
|
const bands = Array.isArray(def.bands) ? def.bands : []
|
||||||
const setBands = (nb) => set('bands', nb.length ? nb : undefined)
|
const setBands = (nb) => set('bands', nb.length ? nb : undefined)
|
||||||
const updBand = (i, j, raw) => {
|
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
|
nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw
|
||||||
setBands(nb)
|
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 (
|
return (
|
||||||
<div className="se-stat">
|
<div className="se-stat">
|
||||||
<div className="se-row-top">
|
<div className="se-row-top">
|
||||||
@@ -59,36 +71,52 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
|
|||||||
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-fields">
|
<div className="se-fields">
|
||||||
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
|
{isText ? (
|
||||||
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
|
<label className="se-num">
|
||||||
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
|
<span>initial</span>
|
||||||
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
|
<input type="text" value={def.initial ?? ''}
|
||||||
|
onChange={(e) => set('initial', e.target.value)} />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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)} />
|
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
|
||||||
<label className="se-check">
|
<label className="se-num">
|
||||||
<input type="checkbox" checked={def.type === 'counter'}
|
<span>kind</span>
|
||||||
onChange={(e) => set('type', e.target.checked ? 'counter' : undefined)} />
|
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
|
||||||
<span>counts up only</span>
|
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
|
||||||
|
<option value="number">number</option>
|
||||||
|
<option value="counter">counts up only</option>
|
||||||
|
<option value="text">free text</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)"
|
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)"
|
||||||
onChange={(e) => set('desc', e.target.value || undefined)} />
|
onChange={(e) => set('desc', e.target.value || undefined)} />
|
||||||
<div className="se-bands">
|
{!isText && (
|
||||||
<div className="se-sub-head">Bands — low, high, label</div>
|
<div className="se-bands">
|
||||||
{bands.map((b, i) => (
|
<div className="se-sub-head">Bands — low, high, label</div>
|
||||||
<div key={i} className="se-band">
|
{bands.map((b, i) => (
|
||||||
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
|
<div key={i} className="se-band">
|
||||||
onChange={(e) => updBand(i, 0, e.target.value)} />
|
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
|
||||||
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
|
onChange={(e) => updBand(i, 0, e.target.value)} />
|
||||||
onChange={(e) => updBand(i, 1, e.target.value)} />
|
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
|
||||||
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
|
onChange={(e) => updBand(i, 1, e.target.value)} />
|
||||||
onChange={(e) => updBand(i, 2, e.target.value)} />
|
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
|
||||||
<button type="button" className="se-remove"
|
onChange={(e) => updBand(i, 2, e.target.value)} />
|
||||||
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
|
<button type="button" className="se-remove"
|
||||||
</div>
|
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>
|
<button type="button" className="se-add-sm"
|
||||||
</div>
|
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,6 +497,17 @@ function bandLabel(def, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StatRow({ name, def, value }) {
|
function StatRow({ name, def, value }) {
|
||||||
|
if (def?.type === 'text') {
|
||||||
|
const text = typeof value === 'string' ? value : (def?.initial ?? '')
|
||||||
|
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">{text || '(unset)'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
|
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
|
||||||
const { min, max } = def || {}
|
const { min, max } = def || {}
|
||||||
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
|
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min
|
||||||
|
|||||||
Reference in New Issue
Block a user