A 5e storytelling engine with an LLM DM
1# pyright: reportOptionalMemberAccess=false
2# Tests reach into combatant lookups via ctx.initiative._find(name) and
3# trust the result — the setup guarantees the named combatant exists.
4"""Tests for the initiative tracking state machine.
5
6The FastMCP combat tool surface is tested separately in test_mcp_server.py
7and via the in-memory client in test_execute_tool.py. This file covers
8just the InitiativeTracker dataclass behavior.
9"""
10
11import pytest
12
13from storied.initiative import (
14 Combatant,
15 InitiativeTracker,
16)
17
18
19@pytest.fixture
20def tracker() -> InitiativeTracker:
21 return InitiativeTracker()
22
23
24@pytest.fixture
25def combatants() -> list[Combatant]:
26 """Three combatants in initiative order (pre-sorted by DM)."""
27 return [
28 Combatant(name="Kira", initiative=18, hp=25, hp_max=25, ac=16, is_player=True),
29 Combatant(name="Goblin 1", initiative=14, hp=7, hp_max=7, ac=15),
30 Combatant(name="Goblin 2", initiative=10, hp=7, hp_max=7, ac=15),
31 ]
32
33
34class TestTrackerLifecycle:
35 def test_starts_inactive(self, tracker: InitiativeTracker):
36 assert not tracker.active
37
38 def test_begin_activates(
39 self, tracker: InitiativeTracker, combatants: list[Combatant]
40 ):
41 tracker.begin(combatants)
42
43 assert tracker.active
44 assert tracker.round == 1
45 assert tracker.current_index == 0
46
47 def test_begin_preserves_list_order(
48 self,
49 tracker: InitiativeTracker,
50 combatants: list[Combatant],
51 ):
52 tracker.begin(combatants)
53
54 assert [c.name for c in tracker.combatants] == ["Kira", "Goblin 1", "Goblin 2"]
55
56 def test_end_deactivates(
57 self, tracker: InitiativeTracker, combatants: list[Combatant]
58 ):
59 tracker.begin(combatants)
60 summary = tracker.end()
61
62 assert not tracker.active
63 assert "1" in summary # round count
64
65 def test_end_reports_defeated(
66 self,
67 tracker: InitiativeTracker,
68 combatants: list[Combatant],
69 ):
70 tracker.begin(combatants)
71 tracker.apply_damage("Goblin 1", 7)
72 summary = tracker.end()
73
74 assert "Goblin 1" in summary
75 assert "defeated" in summary.lower()
76
77 def test_end_reports_duration(
78 self,
79 tracker: InitiativeTracker,
80 combatants: list[Combatant],
81 ):
82 tracker.begin(combatants)
83 tracker.next_turn()
84 tracker.next_turn()
85 tracker.next_turn() # back to round 2
86 summary = tracker.end()
87
88 assert "2" in summary # round 2
89
90
91class TestTurnAdvancement:
92 def test_next_turn_advances(
93 self,
94 tracker: InitiativeTracker,
95 combatants: list[Combatant],
96 ):
97 tracker.begin(combatants)
98 assert tracker.current_combatant.name == "Kira"
99
100 result = tracker.next_turn()
101
102 assert tracker.current_combatant.name == "Goblin 1"
103 assert "Goblin 1" in result
104
105 def test_round_wraps(self, tracker: InitiativeTracker, combatants: list[Combatant]):
106 tracker.begin(combatants)
107 tracker.next_turn() # -> Goblin 1
108 tracker.next_turn() # -> Goblin 2
109 result = tracker.next_turn() # -> Kira, round 2
110
111 assert tracker.round == 2
112 assert tracker.current_combatant.name == "Kira"
113 assert "Round 2" in result
114
115 def test_skips_defeated(
116 self,
117 tracker: InitiativeTracker,
118 combatants: list[Combatant],
119 ):
120 tracker.begin(combatants)
121 tracker.apply_damage("Goblin 1", 7) # defeat Goblin 1
122 tracker.next_turn() # should skip Goblin 1 -> Goblin 2
123
124 assert tracker.current_combatant.name == "Goblin 2"
125
126 def test_hints_one_side_remaining(
127 self,
128 tracker: InitiativeTracker,
129 combatants: list[Combatant],
130 ):
131 tracker.begin(combatants)
132 tracker.apply_damage("Goblin 1", 7)
133 tracker.apply_damage("Goblin 2", 7)
134 result = tracker.next_turn() # wraps to Kira
135
136 assert "only" in result.lower() or "remaining" in result.lower()
137
138
139class TestDamageAndHealing:
140 def test_damage_reduces_hp(
141 self,
142 tracker: InitiativeTracker,
143 combatants: list[Combatant],
144 ):
145 tracker.begin(combatants)
146 result = tracker.apply_damage("Goblin 1", 3)
147
148 goblin = tracker._find("Goblin 1")
149 assert goblin.hp == 4
150 assert "3" in result # damage amount
151
152 def test_damage_defeats_at_zero(
153 self,
154 tracker: InitiativeTracker,
155 combatants: list[Combatant],
156 ):
157 tracker.begin(combatants)
158 result = tracker.apply_damage("Goblin 1", 7)
159
160 goblin = tracker._find("Goblin 1")
161 assert goblin.hp == 0
162 assert goblin.defeated
163 assert "down" in result.lower()
164
165 def test_damage_clamps_to_zero(
166 self,
167 tracker: InitiativeTracker,
168 combatants: list[Combatant],
169 ):
170 tracker.begin(combatants)
171 tracker.apply_damage("Goblin 1", 100)
172
173 assert tracker._find("Goblin 1").hp == 0
174
175 def test_damage_reports_bloodied(
176 self,
177 tracker: InitiativeTracker,
178 combatants: list[Combatant],
179 ):
180 tracker.begin(combatants)
181 result = tracker.apply_damage("Kira", 13) # 25 -> 12, half is 12
182
183 assert "bloodied" in result.lower()
184
185 def test_heal_increases_hp(
186 self,
187 tracker: InitiativeTracker,
188 combatants: list[Combatant],
189 ):
190 tracker.begin(combatants)
191 tracker.apply_damage("Kira", 10)
192 result = tracker.apply_heal("Kira", 5)
193
194 assert tracker._find("Kira").hp == 20
195 assert "5" in result
196
197 def test_heal_clamps_to_max(
198 self,
199 tracker: InitiativeTracker,
200 combatants: list[Combatant],
201 ):
202 tracker.begin(combatants)
203 tracker.apply_damage("Kira", 5)
204 tracker.apply_heal("Kira", 100)
205
206 assert tracker._find("Kira").hp == 25
207
208 def test_heal_revives_defeated(
209 self,
210 tracker: InitiativeTracker,
211 combatants: list[Combatant],
212 ):
213 tracker.begin(combatants)
214 tracker.apply_damage("Goblin 1", 7)
215 assert tracker._find("Goblin 1").defeated
216
217 tracker.apply_heal("Goblin 1", 3)
218
219 goblin = tracker._find("Goblin 1")
220 assert goblin.hp == 3
221 assert not goblin.defeated
222
223 def test_damage_unknown_target(
224 self,
225 tracker: InitiativeTracker,
226 combatants: list[Combatant],
227 ):
228 tracker.begin(combatants)
229 result = tracker.apply_damage("Nobody", 5)
230
231 assert "not found" in result.lower()
232
233 def test_heal_unknown_target(
234 self,
235 tracker: InitiativeTracker,
236 combatants: list[Combatant],
237 ):
238 tracker.begin(combatants)
239 result = tracker.apply_heal("Nobody", 5)
240
241 assert "not found" in result.lower()
242
243
244class TestConditions:
245 def test_add_condition(
246 self,
247 tracker: InitiativeTracker,
248 combatants: list[Combatant],
249 ):
250 tracker.begin(combatants)
251 result = tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira")
252
253 goblin = tracker._find("Goblin 1")
254 assert len(goblin.conditions) == 1
255 assert goblin.conditions[0].name == "Prone"
256 assert "Prone" in result
257
258 def test_remove_condition(
259 self,
260 tracker: InitiativeTracker,
261 combatants: list[Combatant],
262 ):
263 tracker.begin(combatants)
264 tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira")
265 result = tracker.remove_condition("Goblin 1", "Prone")
266
267 goblin = tracker._find("Goblin 1")
268 assert len(goblin.conditions) == 0
269 assert "Prone" in result
270
271 def test_remove_nonexistent_condition(
272 self,
273 tracker: InitiativeTracker,
274 combatants: list[Combatant],
275 ):
276 tracker.begin(combatants)
277 result = tracker.remove_condition("Goblin 1", "Invisible")
278
279 assert "not found" in result.lower() or "no" in result.lower()
280
281 def test_condition_unknown_target(
282 self,
283 tracker: InitiativeTracker,
284 combatants: list[Combatant],
285 ):
286 tracker.begin(combatants)
287 result = tracker.add_condition("Nobody", "Prone", duration=-1, source="Kira")
288
289 assert "not found" in result.lower()
290
291 def test_effect_expires_end_of_source_turn(
292 self,
293 tracker: InitiativeTracker,
294 combatants: list[Combatant],
295 ):
296 """Effect with ends_on='end' expires when leaving source's turn."""
297 tracker.begin(combatants)
298 # Kira (idx 0) applies 1-round effect on Goblin 1, ends at end of Kira's turn
299 tracker.add_condition(
300 "Goblin 1",
301 "Stunned",
302 duration=1,
303 ends_on="end",
304 source="Kira",
305 )
306
307 # Advance from Kira's turn -> processes end-of-Kira effects
308 tracker.next_turn() # Kira -> Goblin 1
309
310 goblin = tracker._find("Goblin 1")
311 assert not any(c.name == "Stunned" for c in goblin.conditions)
312
313 def test_effect_expires_start_of_source_turn(
314 self,
315 tracker: InitiativeTracker,
316 combatants: list[Combatant],
317 ):
318 """Effect with ends_on='start' expires when arriving at source's turn."""
319 tracker.begin(combatants)
320 # Kira applies 1-round effect, ends at start of Kira's next turn
321 tracker.add_condition(
322 "Goblin 1",
323 "Frightened",
324 duration=1,
325 ends_on="start",
326 source="Kira",
327 )
328
329 # Full round: Kira -> G1 -> G2 -> Kira (round 2, start of Kira's turn)
330 tracker.next_turn() # -> Goblin 1
331 tracker.next_turn() # -> Goblin 2
332 tracker.next_turn() # -> Kira (round 2)
333
334 goblin = tracker._find("Goblin 1")
335 assert not any(c.name == "Frightened" for c in goblin.conditions)
336
337 def test_indefinite_condition_persists(
338 self,
339 tracker: InitiativeTracker,
340 combatants: list[Combatant],
341 ):
342 """Duration -1 never auto-expires."""
343 tracker.begin(combatants)
344 tracker.add_condition("Goblin 1", "Grappled", duration=-1, source="Kira")
345
346 # Full round
347 for _ in range(3):
348 tracker.next_turn()
349
350 goblin = tracker._find("Goblin 1")
351 assert any(c.name == "Grappled" for c in goblin.conditions)
352
353 def test_multi_round_duration(
354 self,
355 tracker: InitiativeTracker,
356 combatants: list[Combatant],
357 ):
358 """2-round effect lasts through 2 full rounds of the source's turns."""
359 tracker.begin(combatants)
360 tracker.add_condition(
361 "Goblin 1",
362 "Held",
363 duration=2,
364 ends_on="end",
365 source="Kira",
366 )
367
368 # Round 1: Kira -> G1 -> G2 (end of Kira's turn, duration 2 -> 1)
369 tracker.next_turn() # -> G1
370 goblin = tracker._find("Goblin 1")
371 assert any(c.name == "Held" for c in goblin.conditions)
372
373 tracker.next_turn() # -> G2
374 tracker.next_turn() # -> Kira (round 2)
375
376 # Round 2: leaving Kira's turn (duration 1 -> 0, expires)
377 tracker.next_turn() # -> G1
378
379 goblin = tracker._find("Goblin 1")
380 assert not any(c.name == "Held" for c in goblin.conditions)
381
382
383class TestAddRemoveCombatant:
384 def test_add_combatant(
385 self,
386 tracker: InitiativeTracker,
387 combatants: list[Combatant],
388 ):
389 tracker.begin(combatants)
390 result = tracker.add_combatant(
391 Combatant(name="Archer", initiative=12, hp=9, hp_max=9, ac=13),
392 )
393
394 assert len(tracker.combatants) == 4
395 assert "Archer" in result
396
397 def test_add_inserts_by_initiative(
398 self,
399 tracker: InitiativeTracker,
400 combatants: list[Combatant],
401 ):
402 tracker.begin(combatants) # Kira(18), G1(14), G2(10)
403 tracker.add_combatant(
404 Combatant(name="Archer", initiative=12, hp=9, hp_max=9, ac=13),
405 )
406
407 names = [c.name for c in tracker.combatants]
408 assert names == ["Kira", "Goblin 1", "Archer", "Goblin 2"]
409
410 def test_add_before_current_adjusts_index(
411 self,
412 tracker: InitiativeTracker,
413 combatants: list[Combatant],
414 ):
415 tracker.begin(combatants)
416 tracker.next_turn() # -> Goblin 1 (index 1)
417
418 # Add someone with higher initiative (inserted before current)
419 tracker.add_combatant(
420 Combatant(name="Archer", initiative=16, hp=9, hp_max=9, ac=13),
421 )
422
423 # Current should still be Goblin 1
424 assert tracker.current_combatant.name == "Goblin 1"
425
426 def test_remove_combatant(
427 self,
428 tracker: InitiativeTracker,
429 combatants: list[Combatant],
430 ):
431 tracker.begin(combatants)
432 result = tracker.remove_combatant("Goblin 2")
433
434 assert len(tracker.combatants) == 2
435 assert "Goblin 2" in result
436
437 def test_remove_current_advances(
438 self,
439 tracker: InitiativeTracker,
440 combatants: list[Combatant],
441 ):
442 tracker.begin(combatants)
443 tracker.next_turn() # -> Goblin 1
444 tracker.remove_combatant("Goblin 1")
445
446 # Should advance to Goblin 2 (or adjust so current is valid)
447 assert tracker.current_combatant.name == "Goblin 2"
448
449 def test_remove_unknown(
450 self,
451 tracker: InitiativeTracker,
452 combatants: list[Combatant],
453 ):
454 tracker.begin(combatants)
455 result = tracker.remove_combatant("Nobody")
456
457 assert "not found" in result.lower()
458
459
460class TestFormatForContext:
461 def test_includes_table(
462 self,
463 tracker: InitiativeTracker,
464 combatants: list[Combatant],
465 ):
466 tracker.begin(combatants)
467 context = tracker.format_for_context()
468
469 assert "Kira" in context
470 assert "Goblin 1" in context
471 assert "25/25" in context # HP display
472
473 def test_marks_current_turn(
474 self,
475 tracker: InitiativeTracker,
476 combatants: list[Combatant],
477 ):
478 tracker.begin(combatants)
479 context = tracker.format_for_context()
480
481 assert "**Kira**" in context # current combatant is bold
482 assert "Current turn" in context
483
484 def test_shows_defeated(
485 self,
486 tracker: InitiativeTracker,
487 combatants: list[Combatant],
488 ):
489 tracker.begin(combatants)
490 tracker.apply_damage("Goblin 1", 7)
491 context = tracker.format_for_context()
492
493 assert "~~Goblin 1~~" in context or "Defeated" in context
494
495 def test_shows_conditions(
496 self,
497 tracker: InitiativeTracker,
498 combatants: list[Combatant],
499 ):
500 tracker.begin(combatants)
501 tracker.add_condition("Goblin 1", "Prone", duration=-1, source="Kira")
502 context = tracker.format_for_context()
503
504 assert "Prone" in context
505
506 def test_shows_up_next(
507 self,
508 tracker: InitiativeTracker,
509 combatants: list[Combatant],
510 ):
511 tracker.begin(combatants)
512 context = tracker.format_for_context()
513
514 assert "Up next" in context
515 assert "Goblin 1" in context.split("Up next")[1]
516
517 def test_shows_round(
518 self,
519 tracker: InitiativeTracker,
520 combatants: list[Combatant],
521 ):
522 tracker.begin(combatants)
523 context = tracker.format_for_context()
524
525 assert "Round" in context
526
527
528# Dispatcher tests removed — the FastMCP combat tools live in
529# storied.tools.combat now and are exercised end-to-end via
530# tests/test_mcp_server.py and tests/test_execute_tool.py.