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 == []