A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 530 lines 16 kB view raw
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.