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>
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""Unit tests for the RPG world-state engine (Phase 12): delta extraction and
|
||
the clamp/cooldown/milestone referee.
|
||
|
||
python -m pytest tests/test_worldstate.py -v
|
||
"""
|
||
from app import worldstate as w
|
||
|
||
SCHEMA = {
|
||
"world": {"day": {"type": "counter", "min": 1, "initial": 1}},
|
||
"player": {
|
||
"hp": {"min": 0, "max": 100, "initial": 100, "max_delta_per_turn": 30,
|
||
"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": {
|
||
"name": "Gwen",
|
||
"keys": "Gwen, ranger",
|
||
"desc": "A loyal ranger",
|
||
"stats": {"trust": {"min": -100, "max": 100, "initial": 0, "cooldown": 2}},
|
||
},
|
||
"drake": {
|
||
"name": "The Drake",
|
||
"stats": {"ferocity": {"min": 0, "max": 100, "initial": 50}},
|
||
},
|
||
},
|
||
"flags": {
|
||
"has_key": {"desc": "Holds the key", "initial": False},
|
||
"disguised": {"desc": "In disguise"},
|
||
},
|
||
"milestones": {"rescue_gwen": {"desc": "Rescue Gwen"}},
|
||
}
|
||
|
||
|
||
def fresh():
|
||
return w.instantiate(SCHEMA)
|
||
|
||
|
||
def test_instantiate_uses_initials():
|
||
ws = fresh()
|
||
assert ws["world"] == {"day": 1}
|
||
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
|
||
# gwen has no "ferocity" stat, drake has no "trust" — cross paths are rejected.
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.ferocity": 5, "npc.bogus.trust": 5}, 2)
|
||
reasons = {r["reason"] for r in report["rejected"]}
|
||
assert reasons == {"unknown npc stat", "unknown npc"}
|
||
|
||
|
||
def test_has_schema():
|
||
assert w.has_schema(SCHEMA)
|
||
assert not w.has_schema(None)
|
||
assert not w.has_schema({})
|
||
assert not w.has_schema({"npc_card_types": ["npc"]}) # config only, no stats
|
||
|
||
|
||
def test_max_delta_per_turn_clamps():
|
||
ws, report = w.apply_delta(fresh(), SCHEMA, {"player.hp": -50}, 5)
|
||
assert ws["player"]["hp"] == 70 # -50 capped to -30
|
||
assert report["clamped"]
|
||
|
||
|
||
def test_clamp_to_min():
|
||
ws, _ = w.apply_delta(fresh(), SCHEMA, {"player.hp": -30}, 1)
|
||
ws, _ = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 3)
|
||
ws, _ = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 5)
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"player.hp": -30}, 7)
|
||
assert ws["player"]["hp"] == 0 # 100-30-30-30-30 clamps at min 0
|
||
assert report["clamped"]
|
||
|
||
|
||
def test_counter_rejects_negative():
|
||
ws, report = w.apply_delta(fresh(), SCHEMA, {"world.day": -1}, 3)
|
||
assert ws["world"]["day"] == 1
|
||
assert report["rejected"][0]["reason"] == "counter can't decrease"
|
||
ws, _ = w.apply_delta(ws, SCHEMA, {"world.day": 1}, 4)
|
||
assert ws["world"]["day"] == 2
|
||
|
||
|
||
def test_npc_cooldown():
|
||
ws, _ = w.apply_delta(fresh(), SCHEMA, {"npc.gwen.trust": 10}, 7)
|
||
assert ws["npc"]["gwen"]["trust"] == 10
|
||
# cooldown 2: another change at index 8 is too soon.
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 8)
|
||
assert ws["npc"]["gwen"]["trust"] == 10
|
||
assert report["rejected"][0]["reason"] == "cooldown"
|
||
# far enough later, it applies.
|
||
ws, _ = w.apply_delta(ws, SCHEMA, {"npc.gwen.trust": 10}, 10)
|
||
assert ws["npc"]["gwen"]["trust"] == 20
|
||
|
||
|
||
def test_milestone_sticky():
|
||
ws, report = w.apply_delta(fresh(), SCHEMA, {"milestones.rescue_gwen": True}, 9)
|
||
assert ws["milestones"]["rescue_gwen"] == {"reached": True, "at": 9}
|
||
assert report["applied"]
|
||
# second set is a silent no-op.
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"milestones.rescue_gwen": True}, 11)
|
||
assert ws["milestones"]["rescue_gwen"]["at"] == 9
|
||
assert not report["applied"]
|
||
# false is ignored.
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"milestones.rescue_gwen": False}, 13)
|
||
assert ws["milestones"]["rescue_gwen"]["reached"] is True
|
||
|
||
|
||
def test_flags_toggle_both_ways():
|
||
ws = fresh()
|
||
assert ws["flags"] == {"has_key": False, "disguised": False} # initials
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"flags.has_key": True}, 1)
|
||
assert ws["flags"]["has_key"] is True
|
||
assert report["applied"]
|
||
# flip back off — flags are two-way (unlike sticky milestones).
|
||
ws, _ = w.apply_delta(ws, SCHEMA, {"flags.has_key": False}, 2)
|
||
assert ws["flags"]["has_key"] is False
|
||
# setting to the same value is a no-op.
|
||
ws, report = w.apply_delta(ws, SCHEMA, {"flags.has_key": False}, 3)
|
||
assert not report["applied"]
|
||
|
||
|
||
def test_flag_rejects_non_bool_and_unknown():
|
||
ws, report = w.apply_delta(fresh(), SCHEMA, {"flags.has_key": 1, "flags.nope": True}, 1)
|
||
reasons = {r["reason"] for r in report["rejected"]}
|
||
assert reasons == {"not a boolean", "unknown flag"}
|
||
assert ws["flags"]["has_key"] is False
|
||
|
||
|
||
def test_reference_includes_desc_and_bands_independently():
|
||
guide = w.render_reference(SCHEMA)
|
||
# hp has both a description and a band ladder.
|
||
assert "very weak" in guide and "range 0–100" in guide
|
||
# day (a counter here has no desc/bands) contributes nothing; flags show desc.
|
||
assert "has_key (flag) — Holds the key." in guide
|
||
# NPCs contribute their own description and per-NPC stat lines.
|
||
assert "NPC Gwen (gwen) — A loyal ranger." in guide
|
||
assert "Gwen trust" in guide and "The Drake ferocity" in guide
|
||
|
||
|
||
def test_unknown_paths_rejected_not_fatal():
|
||
ws, report = w.apply_delta(fresh(), SCHEMA, {"player.stamina": -5, "bogus": 1}, 2)
|
||
reasons = {r["reason"] for r in report["rejected"]}
|
||
assert reasons == {"unknown stat", "unknown path"}
|
||
assert ws["player"]["hp"] == 100 # untouched
|
||
|
||
|
||
def test_extract_fenced_delta_tolerates_mess():
|
||
text = 'You strike.\n\n```state\n{"player.hp": -15, "npc.12.trust": +5,}\n```'
|
||
clean, delta = w.extract_delta(text)
|
||
assert clean == "You strike."
|
||
assert delta == {"player.hp": -15, "npc.12.trust": 5}
|
||
|
||
|
||
def test_extract_no_block():
|
||
clean, delta = w.extract_delta("Just prose that ends normally.")
|
||
assert delta == {}
|
||
assert clean == "Just prose that ends normally."
|
||
|
||
|
||
def test_extract_prose_ending_in_brace_not_eaten():
|
||
# A bare object with no dotted keys is not a delta — leave the text alone.
|
||
clean, delta = w.extract_delta('He said {this}')
|
||
assert delta == {}
|
||
assert clean == "He said {this}"
|
||
|
||
|
||
def test_band_label():
|
||
d = SCHEMA["player"]["hp"]
|
||
assert w.band_label(d, 10) == "very weak"
|
||
assert w.band_label(d, 55) == "minor damage"
|
||
assert w.band_label(d, 100) == "full health" # inclusive top edge
|