File size: 5,337 Bytes
1082b22 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | """Unit tests for engine.py — the code-owned rules layer. No model needed.
Run from app/: python -m pytest test_engine.py
"""
import json
import engine
# --- parse_output: the lenient §13 parser -----------------------------------
def test_parse_valid_say():
raw = json.dumps({"action": "say", "text": "The moss is cool."})
assert engine.parse_output(raw) == {"action": "say", "text": "The moss is cool."}
def test_parse_valid_roll():
obj = {
"action": "roll", "stat": "might", "difficulty": "hard",
"on_success": "It moves.", "on_fail": "It does not.",
}
assert engine.parse_output(json.dumps(obj)) == obj
def test_parse_json_with_surrounding_prose():
raw = 'Sure! {"action": "say", "text": "A beetle hums."} hope that helps'
assert engine.parse_output(raw)["text"] == "A beetle hums."
def test_parse_garbage_degrades_to_say():
out = engine.parse_output("the forest just vibes, no json here")
assert out["action"] == "say"
assert "vibes" in out["text"]
def test_parse_bad_stat_degrades_to_say():
obj = {
"action": "roll", "stat": "charisma", "difficulty": "hard",
"on_success": "yes", "on_fail": "no",
}
assert engine.parse_output(json.dumps(obj))["action"] == "say"
def test_parse_empty_degrades_to_say():
out = engine.parse_output("")
assert out["action"] == "say"
assert out["text"]
def test_parse_truncated_roll_recovers_roll():
# Cut off mid-on_fail (max_tokens truncation): both narrations recoverable.
raw = (
'{"action": "roll", "stat": "speed", "difficulty": "medium", '
'"on_success": "You skitter clear.", "on_fail": "The leaf snaps and you tum'
)
out = engine.parse_output(raw)
assert out["action"] == "roll"
assert out["stat"] == "speed"
assert out["difficulty"] == "medium"
assert out["on_success"] == "You skitter clear."
assert "leaf snaps" in out["on_fail"]
def test_parse_truncated_roll_without_on_fail_degrades_to_say():
raw = (
'{"action": "roll", "stat": "might", "difficulty": "hard", '
'"on_success": "The log shifts'
)
out = engine.parse_output(raw)
assert out["action"] == "say"
assert "log shifts" in out["text"]
def test_parse_roll_with_unescaped_quotes_salvages():
raw = (
'{"action": "roll", "stat": "smarts", "difficulty": "easy", '
'"on_success": "The ant says "fine" and moves.", "on_fail": "The ant balks."}'
)
out = engine.parse_output(raw)
assert out["action"] == "roll"
assert "fine" in out["on_success"]
assert out["on_fail"] == "The ant balks."
def test_parse_log_tagged_lines_keep_narration():
raw = "[dm · Might check, Easy, failed] The bark holds firm against you."
out = engine.parse_output(raw)
assert out["action"] == "say"
assert "bark holds firm" in out["text"]
def test_parse_headers_then_prose_keeps_narration():
raw = "PLAYER'S TURN\nsay\nA snail watches you with mild interest."
out = engine.parse_output(raw)
assert out["action"] == "say"
assert out["text"] == "A snail watches you with mild interest."
# --- dice ---------------------------------------------------------------------
def test_resolve_roll_bounds():
# 2d6 is 2..12; stat 10 always beats easy DC 6, stat -10 never reaches hard DC 10.
assert all(engine.resolve_roll(10, "easy") == "success" for _ in range(50))
assert all(engine.resolve_roll(-10, "hard") == "fail" for _ in range(50))
def test_resolve_roll_returns_both_outcomes():
results = {engine.resolve_roll(2, "medium") for _ in range(300)}
assert results == {"success", "fail"}
# --- damage / parity with training ---------------------------------------------
def test_band_damage_matches_exploder():
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import explode_transcript
assert engine.BAND_DAMAGE == explode_transcript.BAND_DAMAGE
def test_apply_damage_floors_at_zero():
sheet = {"hp": {"cur": 2, "max": 8}}
assert engine.apply_damage(sheet, "hard")["hp"]["cur"] == 0
assert engine.apply_damage(sheet, "easy")["hp"]["cur"] == 2
# --- XP ladder / level-up ---------------------------------------------------------
def test_level_for_xp_ladder():
assert engine.level_for_xp(0) == 1
assert engine.level_for_xp(3) == 1
assert engine.level_for_xp(4) == 2
assert engine.level_for_xp(10) == 3
assert engine.level_for_xp(999) == 7
def test_maybe_level_up_carries_damage():
import roster
bug_id = sorted(roster.load().keys())[0]
sheet = roster.load_at_level(bug_id, 1)
sheet["hp"]["cur"] = max(1, sheet["hp"]["cur"] - 2) # wounded
old_cur = sheet["hp"]["cur"]
old_max = sheet["hp"]["max"]
leveled, notes = engine.maybe_level_up(sheet, engine.XP_LADDER[2])
assert leveled["level"] == 2
assert leveled["hp"]["max"] == old_max + 1
assert leveled["hp"]["cur"] == old_cur + 1 # gained the +1, kept the wound
assert notes and "level 2" in notes[0]
def test_maybe_level_up_noop_below_threshold():
import roster
bug_id = sorted(roster.load().keys())[0]
sheet = roster.load_at_level(bug_id, 1)
same, notes = engine.maybe_level_up(sheet, engine.XP_LADDER[2] - 1)
assert same is sheet
assert notes == []
|