File size: 11,629 Bytes
c64cea0
 
 
0d5850f
c64cea0
 
 
 
 
 
73f6ba0
c64cea0
0d5850f
c64cea0
0d5850f
c64cea0
 
0d5850f
c64cea0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0d5850f
c64cea0
 
0d5850f
 
c64cea0
 
0d5850f
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
 
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c64cea0
 
0d5850f
c64cea0
 
0d5850f
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
 
 
c64cea0
 
0d5850f
 
 
 
 
 
c64cea0
0d5850f
c64cea0
 
0d5850f
 
 
 
 
 
 
c64cea0
0d5850f
c64cea0
 
0d5850f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c64cea0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""Deterministic unit tests for the engine's non-LLM decision points.

These touch no model: they pin the pure plumbing the LLM tests sit on top of β€”
the transcript renderer, the turn-taking picker, and the disposition row schema.
Each test fixes one input and asserts the one thing that must happen, every time.

  uv run pytest test_engine.py
"""

import engine
from dispositions import disposition_model, merge_row
from engine import (
    NARRATOR,
    Event,
    character_signature,
    new_game,
    pick_speakers,
    model_transcript,
    rewind_to,
)
from scenarios.format import Character, Scenario


def make_scen(*chars):
    return Scenario(
        id="t",
        title="t",
        intro="",
        goal="",
        characters=list(chars),
        max_turns=5,
        verdict_labels=["W", "L"],
    )


def make_char(name):
    return Character(name=name, persona="", disposition={})


# ----- model_transcript: log events -> transcript text -----


def test_player_event_is_labelled():
    assert model_transcript([Event("player", "", "hello")]) == "PLAYER: hello"


def test_line_event_uses_speaker_name():
    assert (
        model_transcript([Event("line", "Warden", "Gate's closed.")])
        == "Warden: Gate's closed."
    )


def test_beat_crosses_as_labelled_narration():
    assert (
        model_transcript([Event("beat", "", "The hall falls silent.")])
        == f"{NARRATOR} The hall falls silent."
    )


def test_beat_you_crosses_verbatim():
    # beats keep their second-person 'you' β€” no rewrite, so no case errors ("poke they")
    # and no three-way 'they' collisions. The NARRATOR label plus the signature note
    # ("a narrated 'you' is always the player") carries who it refers to.
    assert (
        model_transcript(
            [Event("beat", "", "All eyes turn as you rise from your chair.")]
        )
        == f"{NARRATOR} All eyes turn as you rise from your chair."
    )


def test_dialogue_is_never_labelled_as_narration():
    # a character's (or the player's) spoken 'you' is real dialogue: no NARRATOR label,
    # text untouched
    events = [
        Event("player", "", "Do you trust me?"),
        Event("line", "Warden", "I don't trust you."),
    ]
    assert (
        model_transcript(events)
        == "PLAYER: Do you trust me?\nWarden: I don't trust you."
    )


def test_events_join_with_newlines_in_order():
    events = [
        Event("beat", "", "The gate looms."),
        Event("player", "", "Let me pass."),
        Event("line", "Warden", "No."),
    ]
    assert (
        model_transcript(events)
        == f"{NARRATOR} The gate looms.\nPLAYER: Let me pass.\nWarden: No."
    )


# ----- disposition row: keys are fixed at game start, the model only fills slots -----


def test_schema_lists_exactly_the_canonical_keys():
    # the row's keys are known the moment the game starts; the schema the model sees
    # declares them outright, so it fills slots instead of guessing names from persona prose
    M = disposition_model(["Player", "Gregor", "Marisol"])
    assert list(M.model_json_schema()["properties"]) == ["Player", "Gregor", "Marisol"]


def test_any_character_name_is_a_valid_key():
    # creator-made casts carry spaces and punctuation in names β€” the schema holds them verbatim
    M = disposition_model(["Player", "Mr. O'Brien"])
    row = M.model_validate({"Mr. O'Brien": "suspicious"}).model_dump(by_alias=True)
    assert row["Mr. O'Brien"] == "suspicious"


def test_skipped_slot_defaults_to_blank():
    # a dropped key must parse, not fail the turn β€” merge_row turns the blank into a fallback
    M = disposition_model(["Player", "Gregor"])
    assert M.model_validate({}).model_dump(by_alias=True) == {
        "Player": "",
        "Gregor": "",
    }


def test_misnamed_key_is_ignored_not_fatal():
    # the frozen-self bug: the model wrote "Gregor Vega" for the "Gregor" slot all game.
    # A stray key now falls outside the schema entirely β€” ignored, never matched, no crash
    M = disposition_model(["Player", "Gregor"])
    row = M.model_validate({"Gregor Vega": "x", "Gregor": "y"}).model_dump(
        by_alias=True
    )
    assert row == {"Player": "", "Gregor": "y"}


def test_merge_row_accepts_the_filled_model():
    # merge_row is the single row boundary: it takes the pydantic row a turn produced
    # and falls back to prior on every slot the model skipped
    M = disposition_model(["Player", "Gregor"])
    update = M.model_validate({"Player": "warming up"})
    merged = merge_row(
        {"Player": "cold", "Gregor": "wary"}, update, ["Player", "Gregor"]
    )
    assert merged == {"Player": "warming up", "Gregor": "wary"}


def test_character_signature_pins_the_row_to_the_cast():
    scen = make_scen(make_char("Warden"), make_char("Scribe"))
    ann = character_signature(scen).output_fields["updated_dispositions"].annotation
    assert list(ann.model_json_schema()["properties"]) == ["Player", "Warden", "Scribe"]


def test_character_signature_carries_the_stage_rules():
    scen = make_scen(make_char("Warden"))
    assert character_signature(scen).instructions == scen.stage_rules


def test_pinned_row_survives_dropping_the_line_field():
    # _refresh_silent reuses the signature minus `line`; the row schema must survive that
    scen = make_scen(make_char("Warden"))
    sig = character_signature(scen).delete("line")
    assert "line" not in sig.output_fields
    ann = sig.output_fields["updated_dispositions"].annotation
    assert list(ann.model_json_schema()["properties"]) == ["Player", "Warden"]


# ----- pick_speakers: who reacts this turn -----


def pick_first(seq, weights):
    return [seq[0]]


def test_single_character_room_always_returns_the_one():
    a = make_char("Solo")
    game = new_game(make_scen(a))
    # even with a name match impossible and randomness irrelevant, it's always [a]
    assert pick_speakers(game, "anything at all") == [a]


def test_named_character_speaks(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 1.0)  # suppress interjections
    assert pick_speakers(game, "Warden, open up.") == [a]


def test_only_named_characters_when_interjection_suppressed(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 1.0)
    assert pick_speakers(game, "Scribe and Warden, hear me.") == [a, b]


def test_interjection_appends_the_others(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 0.0)  # force interjection
    speakers = pick_speakers(game, "Warden, open up.")
    assert speakers[0] is a
    assert b in speakers and len(speakers) == 2


def test_name_inside_a_word_does_not_match(monkeypatch):
    # "Ann" must not be summoned by "cannot" β€” whole-word matches only
    a, b = make_char("Ann"), make_char("Bo")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 1.0)  # suppress interjections
    monkeypatch.setattr(engine.random, "choices", lambda seq, weights: [seq[1]])
    assert pick_speakers(game, "I cannot say.") == [b]


def test_name_match_is_case_insensitive(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 1.0)
    assert pick_speakers(game, "open up, WARDEN.") == [a]


def test_no_name_guarantees_one_speaker(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    monkeypatch.setattr(engine.random, "random", lambda: 1.0)  # every roll fails
    monkeypatch.setattr(engine.random, "choices", pick_first)
    assert pick_speakers(game, "Hello, anyone there?") == [a]


def test_guaranteed_pick_is_staleness_weighted(monkeypatch):
    a, b = make_char("Warden"), make_char("Scribe")
    game = new_game(make_scen(a, b))
    game.log += [Event("line", "Warden", "x"), Event("player", "", "y")]
    game.chars["Warden"].last_spoke_at = 1  # just spoke; Scribe silent since the intro
    seen = {}

    def spy_choices(seq, weights):
        seen.update(zip([c.name for c in seq], weights))
        return [seq[0]]

    monkeypatch.setattr(engine.random, "random", lambda: 1.0)
    monkeypatch.setattr(engine.random, "choices", spy_choices)
    pick_speakers(game, "Hello, anyone there?")
    assert seen["Scribe"] > seen["Warden"]


def test_rolled_speakers_capped_at_the_stalest_three(monkeypatch):
    cast = [make_char(f"C{i}") for i in range(5)]
    game = new_game(make_scen(*cast))
    game.log += [Event("player", "", "x")] * 4
    for i, c in enumerate(cast):
        game.chars[c.name].last_spoke_at = i  # C0 longest silent … C4 freshest
    monkeypatch.setattr(engine.random, "random", lambda: 0.0)  # everyone rolls in
    # the cap keeps the three stalest and drops the freshest, in cast order
    assert pick_speakers(game, "no names here") == cast[:3]


def test_named_characters_are_exempt_from_the_cap(monkeypatch):
    cast = [make_char(n) for n in ("Ada", "Ben", "Cy", "Dot")]
    game = new_game(make_scen(*cast))
    monkeypatch.setattr(engine.random, "random", lambda: 0.0)
    speakers = pick_speakers(game, "Ada, Ben, Cy, Dot β€” all of you, listen.")
    assert speakers == cast  # all four named: every one speaks, no dice slots left


# ----- rewind: snapshots, restoring an earlier point -----


def play_fake_turn(game, directive, line, scene):
    """Mirror the turn-start bookkeeping play_turn_stream does, without any model:
    snapshot, record the player and a reply, then advance the objective scene and clock."""
    game.snapshots = game.snapshots[: game.turn]
    game.snapshots.append(engine._snapshot(game))
    game.log.append(Event("player", "", directive))
    name = next(iter(game.chars))
    game.log.append(Event("line", name, line))
    game.chars[name].disposition = {"Player": f"reacts to: {directive}"}
    game.scene = scene
    game.log.append(Event("beat", "", scene))
    game.turn += 1


def two_turn_game():
    scen = make_scen(make_char("Warden"))
    game = new_game(scen)
    play_fake_turn(game, "Let me pass.", "No.", "The gate stays shut.")
    play_fake_turn(game, "I have gold.", "Show me.", "The Warden eyes the purse.")
    return game


def test_rewind_returns_the_original_directive():
    assert rewind_to(two_turn_game(), 1) == "I have gold."


def test_rewind_restores_scene_disposition_and_clock():
    game = two_turn_game()
    rewind_to(game, 1)
    # back to the start of turn 1: turn 0 stands, turn 1 and after are gone
    assert game.turn == 1
    assert game.scene == "The gate stays shut."
    assert game.chars["Warden"].disposition == {"Player": "reacts to: Let me pass."}
    assert [e.text for e in game.log if e.kind == "player"] == ["Let me pass."]


def test_rewind_to_first_turn_clears_to_opening():
    game = two_turn_game()
    rewind_to(game, 0)
    assert game.turn == 0
    assert game.scene == new_game(game.scenario).scene
    assert [e for e in game.log if e.kind == "player"] == []
    assert game.snapshots == []


def test_replaying_after_rewind_overwrites_the_abandoned_future():
    game = two_turn_game()
    rewind_to(game, 1)
    play_fake_turn(game, "I bring a warning.", "Speak.", "The Warden leans in.")
    assert [e.text for e in game.log if e.kind == "player"] == [
        "Let me pass.",
        "I bring a warning.",
    ]
    assert len(game.snapshots) == 2