dnd5e-engine 0.3.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/CHANGELOG.md +55 -0
  2. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/PKG-INFO +1 -1
  3. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/pyproject.toml +5 -1
  4. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/__init__.py +0 -2
  5. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/death_saves.py +92 -1
  6. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/orchestrator.py +1 -2
  7. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/specs.py +1 -1
  8. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/types/__init__.py +1 -7
  9. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/types/combat.py +9 -29
  10. dnd5e_engine-0.5.0/tests/activities/test_save_dc_and_damage_exprs.py +129 -0
  11. dnd5e_engine-0.5.0/tests/test_attack_classification.py +84 -0
  12. dnd5e_engine-0.5.0/tests/test_combat_bucket_keys.py +61 -0
  13. dnd5e_engine-0.5.0/tests/test_combat_flag_advantage.py +25 -0
  14. dnd5e_engine-0.5.0/tests/test_gen2_has_no_gen1_imports.py +58 -0
  15. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_public_api_surface.py +9 -16
  16. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rules_combat_data_and_skills.py +0 -149
  17. dnd5e_engine-0.5.0/tests/test_rules_dice_equipment_spells.py +302 -0
  18. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rules_effects_and_gambits.py +0 -133
  19. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rules_smoke.py +1 -8
  20. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_types_smoke.py +0 -73
  21. dnd5e_engine-0.3.2/src/dnd5e_engine/dispatch.py +0 -354
  22. dnd5e_engine-0.3.2/src/dnd5e_engine/event_dicts.py +0 -37
  23. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/_class_meta.py +0 -14
  24. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/_parsing.py +0 -36
  25. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/combat.py +0 -581
  26. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/combat_data.py +0 -230
  27. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/combat_helpers.py +0 -414
  28. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/equipment.py +0 -108
  29. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/gambits.py +0 -186
  30. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/resolution.py +0 -167
  31. dnd5e_engine-0.3.2/src/dnd5e_engine/rules/spells.py +0 -151
  32. dnd5e_engine-0.3.2/src/dnd5e_engine/types/dice.py +0 -32
  33. dnd5e_engine-0.3.2/src/dnd5e_engine/types/intent.py +0 -174
  34. dnd5e_engine-0.3.2/tests/test_combat_bucket_keys.py +0 -177
  35. dnd5e_engine-0.3.2/tests/test_combat_flag_advantage.py +0 -154
  36. dnd5e_engine-0.3.2/tests/test_rules_combat_helpers.py +0 -654
  37. dnd5e_engine-0.3.2/tests/test_rules_combat_resolution.py +0 -802
  38. dnd5e_engine-0.3.2/tests/test_rules_dice_equipment_spells.py +0 -563
  39. dnd5e_engine-0.3.2/tests/test_rules_resolution_and_parsing.py +0 -368
  40. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/.gitignore +0 -0
  41. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/LICENSE +0 -0
  42. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/Makefile +0 -0
  43. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/NOTICE +0 -0
  44. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/README.md +0 -0
  45. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/scripts/_smoke_grid_combat.py +0 -0
  46. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/scripts/smoke_clean_install.sh +0 -0
  47. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/__init__.py +0 -0
  48. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/apply.py +0 -0
  49. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/attack.py +0 -0
  50. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/build_context.py +0 -0
  51. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/cast.py +0 -0
  52. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/check.py +0 -0
  53. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/context.py +0 -0
  54. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/damage.py +0 -0
  55. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/dice.py +0 -0
  56. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/effects.py +0 -0
  57. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/formula.py +0 -0
  58. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/heal.py +0 -0
  59. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/mastery.py +0 -0
  60. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/monster_actions.py +0 -0
  61. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/passive_stats.py +0 -0
  62. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/resolver.py +0 -0
  63. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/save.py +0 -0
  64. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/save_primitive.py +0 -0
  65. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/activities/scale.py +0 -0
  66. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/build_party.py +0 -0
  67. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/build_spec.py +0 -0
  68. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/check.py +0 -0
  69. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/events.py +0 -0
  70. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/lib_loader.py +0 -0
  71. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/outcome.py +0 -0
  72. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/py.typed +0 -0
  73. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rest.py +0 -0
  74. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/results.py +0 -0
  75. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rules/__init__.py +0 -0
  76. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rules/conditions.py +0 -0
  77. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rules/dice.py +0 -0
  78. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rules/effects.py +0 -0
  79. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/rules/skills.py +0 -0
  80. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/spatial.py +0 -0
  81. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/testing.py +0 -0
  82. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/types/conditions.py +0 -0
  83. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/types/effects.py +0 -0
  84. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/src/dnd5e_engine/views.py +0 -0
  85. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/__init__.py +0 -0
  86. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/__init__.py +0 -0
  87. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_build_context.py +0 -0
  88. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_cast_level_override.py +0 -0
  89. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_monster_actions.py +0 -0
  90. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_multiattack_fidelity.py +0 -0
  91. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_resolve_check_activity.py +0 -0
  92. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_save_primitive.py +0 -0
  93. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/activities/test_weapon_mastery.py +0 -0
  94. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/__init__.py +0 -0
  95. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/conftest.py +0 -0
  96. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/harness.py +0 -0
  97. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c00_harness_sanity.py +0 -0
  98. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c01_dataset_fixes.py +0 -0
  99. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c02_small_mechanics.py +0 -0
  100. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c03_effect_modes.py +0 -0
  101. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c04_caster_plumbing.py +0 -0
  102. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c05_spatial.py +0 -0
  103. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c06_reactions.py +0 -0
  104. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c07_sneak_and_repertoire.py +0 -0
  105. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c08_passive_projection.py +0 -0
  106. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c09_rest.py +0 -0
  107. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c10_monster_behavior.py +0 -0
  108. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/e2e/test_c11_item_charges.py +0 -0
  109. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_active_effect_resistance_fold.py +0 -0
  110. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_active_effect_schema.py +0 -0
  111. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_apply_changes_to_check.py +0 -0
  112. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_apply_modifiers.py +0 -0
  113. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_build_context_abilities.py +0 -0
  114. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_build_party.py +0 -0
  115. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_build_party_passives.py +0 -0
  116. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_capability_matrix.py +0 -0
  117. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_concentration_drop_effect_name.py +0 -0
  118. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_concentration_repeat_save_identity.py +0 -0
  119. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_cover_bonus_sidecar.py +0 -0
  120. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_coverage_backfill_death_saves.py +0 -0
  121. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_coverage_backfill_seam_errors.py +0 -0
  122. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_determinism_contract.py +0 -0
  123. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_docstrings_are_host_agnostic.py +0 -0
  124. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_duration_tick_at_turn_end.py +0 -0
  125. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_effects_builder_internals.py +0 -0
  126. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_enchantment_sidecar_projection.py +0 -0
  127. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_end_combat_snapshot.py +0 -0
  128. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_expire_clears_active_conditions.py +0 -0
  129. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_feature_save_dc_no_spell_override.py +0 -0
  130. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_get_actor_active_effects.py +0 -0
  131. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_grid_topology.py +0 -0
  132. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_iter11_fixes.py +0 -0
  133. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_iter13_fixes.py +0 -0
  134. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_iter5_fixes.py +0 -0
  135. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_lib_loader.py +0 -0
  136. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_lib_loader_configure.py +0 -0
  137. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_live_combat_view.py +0 -0
  138. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_monster_dash.py +0 -0
  139. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_monster_flee_retreat.py +0 -0
  140. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_gating_typed.py +0 -0
  141. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_grid_combat.py +0 -0
  142. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_item_charges.py +0 -0
  143. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_monster_typed.py +0 -0
  144. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_move_mark_typed.py +0 -0
  145. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_orchestrator_pc_resolution_typed.py +0 -0
  146. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_party_member_reach_ft.py +0 -0
  147. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_passive_stats.py +0 -0
  148. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_phase6_public_surface.py +0 -0
  149. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rage_second_wind_e2e.py +0 -0
  150. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_reaction_queue.py +0 -0
  151. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_resolve_check.py +0 -0
  152. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_resolve_check_flag_advantage.py +0 -0
  153. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_resolve_check_specific_buckets.py +0 -0
  154. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rest.py +0 -0
  155. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_rules_conditions.py +0 -0
  156. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_scale_resolver.py +0 -0
  157. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_smoke.py +0 -0
  158. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_sneak_attack_resolver.py +0 -0
  159. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_start_combat_active_effects.py +0 -0
  160. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_start_combat_seeds_lifecycle.py +0 -0
  161. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_use_feature_intent.py +0 -0
  162. {dnd5e_engine-0.3.2 → dnd5e_engine-0.5.0}/tests/test_weapon_damage_bonus_sidecar.py +0 -0
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0]
11
+
12
+ Lockstep release with `dnd5e-srd-data` 0.5.0 and `nat20-bridge` 0.5.0.
13
+
14
+ Removes the legacy (Gen 1) rules surface deprecated in 0.4.0. The engine now
15
+ ships exactly one, seedable, documented rules implementation
16
+ (`orchestrator` + `activities/`). See `docs/migration/v0.4-to-v0.5.md`.
17
+
18
+ ### Removed
19
+
20
+ - Modules `dispatch`, `event_dicts`, `types.dice`, `types.intent`, and
21
+ `rules.{combat, combat_data, combat_helpers, equipment, gambits, resolution,
22
+ spells}` (plus the private `rules._parsing` / `rules._class_meta`).
23
+ - `dnd5e_engine.ActionType`, `CombatNPC`, and the lazy
24
+ `dnd5e_engine.types.{ActionType, CombatOutcome, DiceOutcome, CombatNPC}`
25
+ re-exports; the 0.4.0 `DeathSaveState` / `BehaviorProfile` re-export shims.
26
+ - The cross-generation parity test; its Gen 2 assertions live on in
27
+ `tests/test_attack_classification.py`.
28
+
29
+ ## [0.4.0]
30
+
31
+ Lockstep release with `dnd5e-srd-data` 0.4.0 and `nat20-bridge` 0.4.0.
32
+
33
+ Deprecates the legacy (Gen 1) rules surface. **Nothing is removed**; every
34
+ deprecated import warns. Removal lands in 0.5.0 — see
35
+ `docs/migration/v0.3-to-v0.4.md`.
36
+
37
+ ### Deprecated
38
+
39
+ - **The legacy (Gen 1) rules surface — removed in 0.5.0.** `dispatch`,
40
+ `event_dicts`, `types.dice`, `types.intent`, and `rules.{combat, combat_data,
41
+ combat_helpers, equipment, gambits, resolution, spells}` emit a
42
+ `DeprecationWarning` on import. The top-level `dnd5e_engine.ActionType` and
43
+ `dnd5e_engine.types.{ActionType, CombatOutcome, DiceOutcome, CombatNPC}`
44
+ re-exports now resolve lazily and warn on access. Every symbol's supported
45
+ route is in `docs/migration/v0.3-to-v0.4.md`. Rationale: two drifting
46
+ implementations of the same SRD rules, a non-seedable legacy path, and host
47
+ transport/persistence shapes inside a zero-I/O package.
48
+
49
+ ### Changed
50
+
51
+ - `DeathSaveState` canonical home is now `dnd5e_engine.death_saves`;
52
+ `BehaviorProfile` is now `dnd5e_engine.types.combat`. The old locations
53
+ re-export until 0.5.0. The supported engine's import tree no longer reaches
54
+ any legacy module (pinned by `tests/test_gen2_has_no_gen1_imports.py`).
55
+
56
+ ### Added
57
+
58
+ - Cross-generation attack-roll parity test
59
+ (`tests/test_generation_parity_attack.py`): `rules/combat.attack_roll` /
60
+ `resolve_player_attack` and `activities/attack.py` are fed the same d20
61
+ stream and must keep the same natural die and agree on hit / crit / miss
62
+ under advantage, disadvantage and flat rolls. This is the guard that would
63
+ have caught the drift fixed in 0.3.1 (#11). No runtime behavior change.
64
+
10
65
  ## [0.3.2]
11
66
 
12
67
  Lockstep release with `dnd5e-srd-data` 0.3.2 and `nat20-bridge` 0.3.2.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dnd5e-engine
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: Pure-Python D&D 5e SRD rules engine — combat, checks, effects. Host-agnostic, zero I/O.
5
5
  Author: Tapestria contributors
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dnd5e-engine"
7
- version = "0.3.2"
7
+ version = "0.5.0"
8
8
  description = "Pure-Python D&D 5e SRD rules engine — combat, checks, effects. Host-agnostic, zero I/O."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -87,6 +87,10 @@ max-complexity = 23
87
87
 
88
88
  [tool.pytest.ini_options]
89
89
  testpaths = ["tests"]
90
+ filterwarnings = [
91
+ # Any DeprecationWarning the engine itself raises is a bug.
92
+ "error::DeprecationWarning:dnd5e_engine",
93
+ ]
90
94
 
91
95
  [tool.coverage.run]
92
96
  source = ["dnd5e_engine"]
@@ -79,11 +79,9 @@ from dnd5e_engine.types.effects import (
79
79
  ActiveEffectChange,
80
80
  ActiveEffectDuration,
81
81
  )
82
- from dnd5e_engine.types.intent import ActionType
83
82
 
84
83
  __all__ = [
85
84
  "AbilityScores",
86
- "ActionType",
87
85
  "ActiveEffect",
88
86
  "ActiveEffectChange",
89
87
  "ActiveEffectDuration",
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import random
24
24
  from dataclasses import dataclass
25
+ from typing import Any
25
26
 
26
27
  from dnd5e_engine.events import (
27
28
  CombatEvent,
@@ -30,9 +31,98 @@ from dnd5e_engine.events import (
30
31
  DeathSaveStarted,
31
32
  Stabilized,
32
33
  )
33
- from dnd5e_engine.rules.combat_helpers import DeathSaveState
34
34
  from dnd5e_engine.types.combat import Combatant
35
35
 
36
+
37
+ @dataclass
38
+ class DeathSaveState:
39
+ """Mutable state machine for D&D 5e death saving throws.
40
+
41
+ Designed for host persistence via to_dict / from_dict.
42
+ """
43
+
44
+ successes: int = 0
45
+ failures: int = 0
46
+ is_stable: bool = False
47
+
48
+ # ------------------------------------------------------------------
49
+ # Core state transitions
50
+ # ------------------------------------------------------------------
51
+
52
+ def apply_save(self, success: bool, is_critical: bool) -> str:
53
+ """Apply a death saving throw result.
54
+
55
+ Returns one of: "critical_success" | "stabilized" | "dead" | "ongoing".
56
+
57
+ Rules:
58
+ - Nat 20 (success=True, is_critical=True): regain 1 HP -> "critical_success"
59
+ - Nat 1 (success=False, is_critical=True): 2 failures
60
+ - Normal success: 1 success; 3 successes -> "stabilized"
61
+ - Normal failure: 1 failure; 3 failures -> "dead"
62
+ """
63
+ # Nat 20 takes priority — regain 1 HP, no counter update needed
64
+ if success and is_critical:
65
+ return "critical_success"
66
+
67
+ if success:
68
+ self.successes += 1
69
+ elif is_critical:
70
+ # Nat 1 counts as 2 failures
71
+ self.failures += 2
72
+ else:
73
+ self.failures += 1
74
+
75
+ return self._check_outcome()
76
+
77
+ def apply_damage_while_unconscious(self, is_melee_within_5ft: bool) -> str:
78
+ """Apply death save failures from damage while unconscious.
79
+
80
+ D&D 5e RAW: taking any damage while at 0 HP is a death save failure.
81
+ Melee attack within 5 ft = auto-crit = 2 failures. Otherwise 1 failure.
82
+
83
+ Returns "dead" if 3+ failures reached, else "ongoing".
84
+ """
85
+ self.failures += 2 if is_melee_within_5ft else 1
86
+ return self._check_outcome()
87
+
88
+ def reset(self) -> None:
89
+ """Reset all counters (called when character regains HP via nat 20)."""
90
+ self.successes = 0
91
+ self.failures = 0
92
+ self.is_stable = False
93
+
94
+ # ------------------------------------------------------------------
95
+ # Serialization (plain dicts for host persistence)
96
+ # ------------------------------------------------------------------
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return {
100
+ "successes": self.successes,
101
+ "failures": self.failures,
102
+ "is_stable": self.is_stable,
103
+ }
104
+
105
+ @classmethod
106
+ def from_dict(cls, d: dict[str, Any]) -> DeathSaveState:
107
+ return cls(
108
+ successes=d.get("successes", 0),
109
+ failures=d.get("failures", 0),
110
+ is_stable=d.get("is_stable", False),
111
+ )
112
+
113
+ # ------------------------------------------------------------------
114
+ # Internal helpers
115
+ # ------------------------------------------------------------------
116
+
117
+ def _check_outcome(self) -> str:
118
+ if self.failures >= 3:
119
+ return "dead"
120
+ if self.successes >= 3:
121
+ self.is_stable = True
122
+ return "stabilized"
123
+ return "ongoing"
124
+
125
+
36
126
  # Outcome literal returned alongside events/state for the caller to drive
37
127
  # turn skip / combat-end / consciousness restore.
38
128
  DeathSaveOutcome = str # "ongoing" | "stabilized" | "dead" | "critical_success"
@@ -150,6 +240,7 @@ def reset_death_saves(combatant: Combatant) -> Combatant:
150
240
  __all__ = [
151
241
  "DeathSaveOutcome",
152
242
  "DeathSaveResult",
243
+ "DeathSaveState",
153
244
  "reset_death_saves",
154
245
  "roll_death_save",
155
246
  ]
@@ -119,7 +119,6 @@ from dnd5e_engine.rules.conditions import (
119
119
  project_passive_damage_modifiers,
120
120
  project_passive_save_modifiers,
121
121
  )
122
- from dnd5e_engine.rules.gambits import BehaviorProfile
123
122
  from dnd5e_engine.spatial import GridTopology, SpatialTopology
124
123
  from dnd5e_engine.specs import (
125
124
  EncounterMemberSpec,
@@ -128,7 +127,7 @@ from dnd5e_engine.specs import (
128
127
  SceneTopology,
129
128
  ZoneEdge,
130
129
  )
131
- from dnd5e_engine.types.combat import Combatant
130
+ from dnd5e_engine.types.combat import BehaviorProfile, Combatant
132
131
  from dnd5e_engine.types.conditions import ActiveCondition
133
132
  from dnd5e_engine.types.effects import ActiveEffect, ActiveEffectDuration
134
133
  from dnd5e_engine.views import LiveCombatView
@@ -170,7 +170,7 @@ class EncounterMemberSpec(BaseModel):
170
170
  # Person targets humanoids; Sleep autopasses undead/elves; etc.).
171
171
  creature_type: str | None = None
172
172
  # SRD §Damage Resistance / §Damage Immunity — per-monster type lists,
173
- # populated from MonsterTemplate (via CombatMonster / CombatNPC). Empty
173
+ # populated from the host's monster template record. Empty
174
174
  # by default for fixtures that don't specify.
175
175
  damage_resistances: list[str] = Field(default_factory=list)
176
176
  damage_immunities: list[str] = Field(default_factory=list)
@@ -1,24 +1,18 @@
1
1
  """dnd5e-engine type surface — host-agnostic Pydantic + Literal types."""
2
2
 
3
- from dnd5e_engine.types.combat import Combatant, CombatNPC
3
+ from dnd5e_engine.types.combat import Combatant
4
4
  from dnd5e_engine.types.conditions import ActiveCondition, ConditionScope
5
- from dnd5e_engine.types.dice import DiceOutcome
6
5
  from dnd5e_engine.types.effects import (
7
6
  ActiveEffect,
8
7
  ActiveEffectChange,
9
8
  ActiveEffectDuration,
10
9
  )
11
- from dnd5e_engine.types.intent import ActionType, CombatOutcome
12
10
 
13
11
  __all__ = [
14
- "ActionType",
15
12
  "ActiveCondition",
16
13
  "ActiveEffect",
17
14
  "ActiveEffectChange",
18
15
  "ActiveEffectDuration",
19
- "CombatNPC",
20
- "CombatOutcome",
21
16
  "Combatant",
22
17
  "ConditionScope",
23
- "DiceOutcome",
24
18
  ]
@@ -1,4 +1,4 @@
1
- """Combat participant types — ``Combatant`` + ``CombatNPC``.
1
+ """Combat participant types — ``Combatant`` and ``BehaviorProfile``.
2
2
 
3
3
  Host-agnostic value types: stdlib + pydantic + the engine's own
4
4
  ``ActiveCondition``. ``Combatant`` is the engine's per-creature runtime combat
@@ -7,6 +7,7 @@ state; hosts read it through ``dnd5e_engine.views.LiveCombatView``.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from enum import StrEnum
10
11
  from typing import Any
11
12
 
12
13
  from pydantic import BaseModel, Field, model_validator
@@ -15,33 +16,12 @@ from dnd5e_engine.activities.passive_stats import CombatantMovementModes, Combat
15
16
  from dnd5e_engine.types.conditions import ActiveCondition
16
17
 
17
18
 
18
- class CombatNPC(BaseModel):
19
- """Ephemeral sidecar record for an NPC in combat. Host-scoped;
20
- cleared at end of combat. Owns template-derived combat stats that don't
21
- fit on the narrow Combatant: saves, resistances, immunities, ability
22
- scores, behavior_profile. Symmetric to CombatMonster for non-Character
23
- entities."""
19
+ class BehaviorProfile(StrEnum):
20
+ """Monster AI posture consumed by the orchestrator's flee heuristic."""
24
21
 
25
- npc_id: str # persistent NPC node ID (npc:hex12)
26
- template_id: str # MonsterTemplate node ID used at materialization
27
- name: str
28
- hp_current: int
29
- hp_max: int
30
- ac: int
31
- attack_bonus: int
32
- damage_dice: str
33
- damage_type: str
34
- has_ranged_attack: bool = False
35
- dexterity: int = 10
36
- strength: int = 10
37
- constitution: int = 10
38
- wisdom: int = 10
39
- intelligence: int = 10
40
- charisma: int = 10
41
- behavior_profile: str = "AGGRESSIVE"
42
- damage_resistances: list[str] = Field(default_factory=list)
43
- damage_immunities: list[str] = Field(default_factory=list)
44
- is_alive: bool = True
22
+ AGGRESSIVE = "AGGRESSIVE"
23
+ RANGED = "RANGED"
24
+ DEFENSIVE = "DEFENSIVE"
45
25
 
46
26
 
47
27
  class Combatant(BaseModel):
@@ -101,7 +81,7 @@ class Combatant(BaseModel):
101
81
  # application path (``activities/effects.py::apply_activity_effects``)
102
82
  # suppresses a ``ConditionApplied`` whose condition is in this list
103
83
  # . Empty by default. NOTE: distinct from the dead, host-supplied
104
- # ``dispatch.py::DispatchContext.condition_immunities`` legacy surface.
84
+ # legacy dispatch surface's ``condition_immunities`` (removed in 0.5.0).
105
85
  condition_immunities: list[str] = Field(default_factory=list)
106
86
  # SRD §Senses — special senses in feet (darkvision/blindsight/tremorsense/
107
87
  # truesight). Projected from PC species + always-on feature passive_effects
@@ -214,6 +194,6 @@ class Combatant(BaseModel):
214
194
 
215
195
 
216
196
  __all__ = [
217
- "CombatNPC",
197
+ "BehaviorProfile",
218
198
  "Combatant",
219
199
  ]
@@ -0,0 +1,129 @@
1
+ """Direct pins on ``activities/save.py::_resolve_dc`` and the damage-expression
2
+ builders in ``activities/dice.py`` / ``activities/formula.py``.
3
+
4
+ The end-to-end scenarios reach these only through the corpus' common shapes
5
+ (spellcasting DC, automatic ``{n}d{d}`` formulas); the flat-DC, custom-formula
6
+ and scaling-token branches are pinned here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+
13
+ import pytest
14
+ from dnd5e_srd_data.loader import BundledAssetLoader
15
+ from dnd5e_srd_data.schema.common import (
16
+ DamageCustomBlock,
17
+ DamagePart,
18
+ DamagePartBlock,
19
+ DamageScalingBlock,
20
+ SaveActivity,
21
+ )
22
+
23
+ from dnd5e_engine.activities.context import ActivityResolutionContext
24
+ from dnd5e_engine.activities.dice import damage_part_to_expr
25
+ from dnd5e_engine.activities.formula import resolve_damage_block
26
+ from dnd5e_engine.activities.save import _resolve_dc
27
+ from dnd5e_engine.types.combat import Combatant
28
+
29
+
30
+ def _ctx(**overrides: object) -> ActivityResolutionContext:
31
+ kwargs: dict[str, object] = dict(
32
+ rng=random.Random(1),
33
+ caster=Combatant(
34
+ entity_id="char:wiz",
35
+ entity_type="Character",
36
+ name="Wiz",
37
+ initiative=10,
38
+ hp_current=10,
39
+ hp_max=10,
40
+ ),
41
+ targets=[],
42
+ event_emitter=lambda ev: None,
43
+ caster_abilities={"str": 10, "dex": 10, "con": 10, "int": 16, "wis": 10, "cha": 10},
44
+ caster_proficiency_bonus=2,
45
+ spellcasting_ability="int",
46
+ )
47
+ kwargs.update(overrides)
48
+ return ActivityResolutionContext(**kwargs) # type: ignore[arg-type]
49
+
50
+
51
+ @pytest.fixture(scope="module")
52
+ def fireball_save() -> SaveActivity:
53
+ spell = BundledAssetLoader().get_spell("fireball")
54
+ assert spell is not None
55
+ activity = next(a for a in spell.activities if a.kind == "save")
56
+ assert isinstance(activity, SaveActivity)
57
+ return activity
58
+
59
+
60
+ def _with_dc(activity: SaveActivity, calculation: str, formula: str = "") -> SaveActivity:
61
+ dc = activity.save.dc.model_copy(update={"calculation": calculation, "formula": formula})
62
+ return activity.model_copy(update={"save": activity.save.model_copy(update={"dc": dc})})
63
+
64
+
65
+ # ── _resolve_dc ──────────────────────────────────────────────────────────────
66
+
67
+
68
+ def test_spellcasting_dc_is_8_plus_prof_plus_mod(fireball_save: SaveActivity) -> None:
69
+ assert _resolve_dc(fireball_save, _ctx()) == 8 + 2 + 3
70
+
71
+
72
+ def test_spellcasting_dc_without_a_casting_ability_is_loud(fireball_save: SaveActivity) -> None:
73
+ with pytest.raises(ValueError, match="spellcasting ability"):
74
+ _resolve_dc(fireball_save, _ctx(spellcasting_ability=None))
75
+
76
+
77
+ def test_flat_dc_resolves_the_formula(fireball_save: SaveActivity) -> None:
78
+ assert _resolve_dc(_with_dc(fireball_save, "flat", "13"), _ctx()) == 13
79
+
80
+
81
+ def test_flat_dc_resolves_roll_data_tokens(fireball_save: SaveActivity) -> None:
82
+ # @prof folds to the caster's proficiency bonus before evaluation.
83
+ assert _resolve_dc(_with_dc(fireball_save, "flat", "10 + @prof"), _ctx()) == 12
84
+
85
+
86
+ def test_unknown_dc_calculation_is_loud(fireball_save: SaveActivity) -> None:
87
+ with pytest.raises(ValueError, match="not resolvable"):
88
+ _resolve_dc(_with_dc(fireball_save, "mystery"), _ctx())
89
+
90
+
91
+ def test_save_dc_override_bypasses_the_calculation(fireball_save: SaveActivity) -> None:
92
+ assert _resolve_dc(_with_dc(fireball_save, "mystery"), _ctx(save_dc_override=18)) == 18
93
+
94
+
95
+ # ── damage_part_to_expr ──────────────────────────────────────────────────────
96
+
97
+
98
+ def test_weapon_damage_part_passes_its_dice_string_through() -> None:
99
+ assert damage_part_to_expr(DamagePart(dice="2d6+3", damage_type="slashing")) == "2d6+3"
100
+
101
+
102
+ def test_activity_block_builds_the_automatic_formula() -> None:
103
+ assert damage_part_to_expr(DamagePartBlock(number=2, denomination=6, bonus="3")) == "2d6+3"
104
+
105
+
106
+ def test_activity_block_with_only_a_bonus_is_the_bare_bonus() -> None:
107
+ assert damage_part_to_expr(DamagePartBlock(bonus="5")) == "5"
108
+
109
+
110
+ def test_activity_block_custom_formula_wins_when_enabled() -> None:
111
+ block = DamagePartBlock(
112
+ number=2, denomination=6, custom=DamageCustomBlock(enabled=True, formula="1d4")
113
+ )
114
+ assert damage_part_to_expr(block) == "1d4"
115
+
116
+
117
+ # ── resolve_damage_block ─────────────────────────────────────────────────────
118
+
119
+
120
+ def test_scaling_formula_tokens_are_resolved_off_the_context() -> None:
121
+ block = DamagePartBlock(number=1, denomination=8, scaling=DamageScalingBlock(formula="@prof"))
122
+ resolved = resolve_damage_block(block, _ctx(), ability="int")
123
+ assert resolved.scaling.formula == "2"
124
+ assert resolved.number == 1 # untouched fields survive the copy
125
+
126
+
127
+ def test_block_without_tokens_is_returned_unchanged() -> None:
128
+ block = DamagePartBlock(number=1, denomination=8, bonus="2")
129
+ assert resolve_damage_block(block, _ctx(), ability="int") is block
@@ -0,0 +1,84 @@
1
+ """Pin the SRD hit / crit / miss classification of the attack resolver.
2
+
3
+ Carried over from the (now retired) cross-generation parity test: these are
4
+ the rules whose drift between the two former implementations motivated the
5
+ 0.5.0 consolidation, so they stay pinned on the surviving path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+
12
+ import pytest
13
+ from dnd5e_srd_data.loader import BundledAssetLoader
14
+ from dnd5e_srd_data.schema.common import AttackActivity
15
+
16
+ from dnd5e_engine.activities.attack import _resolve_hit_outcome, _roll_natural_d20
17
+ from dnd5e_engine.activities.context import ActivityResolutionContext
18
+ from dnd5e_engine.types.combat import Combatant
19
+
20
+
21
+ @pytest.fixture(scope="module")
22
+ def attack() -> AttackActivity:
23
+ longsword = BundledAssetLoader().get_weapon("longsword")
24
+ assert longsword is not None
25
+ activity = next(a for a in longsword.activities if a.kind == "attack")
26
+ assert isinstance(activity, AttackActivity)
27
+ return activity
28
+
29
+
30
+ def _ctx(seed: int) -> ActivityResolutionContext:
31
+ hero = Combatant(
32
+ entity_id="char:hero",
33
+ entity_type="Character",
34
+ name="Hero",
35
+ initiative=10,
36
+ hp_current=10,
37
+ hp_max=10,
38
+ )
39
+ return ActivityResolutionContext(
40
+ rng=random.Random(seed),
41
+ caster=hero,
42
+ targets=[],
43
+ event_emitter=lambda ev: None,
44
+ caster_abilities={"str": 10, "dex": 10, "con": 10, "int": 10, "wis": 10, "cha": 10},
45
+ )
46
+
47
+
48
+ def test_natural_one_always_misses_even_with_a_huge_bonus(attack: AttackActivity) -> None:
49
+ assert _resolve_hit_outcome(1, 1 + 50, 10, attack) == (False, False)
50
+
51
+
52
+ def test_natural_twenty_always_crits_even_against_impossible_ac(attack: AttackActivity) -> None:
53
+ assert _resolve_hit_outcome(20, 20, 99, attack) == (True, True)
54
+
55
+
56
+ def test_meeting_the_ac_exactly_is_a_hit(attack: AttackActivity) -> None:
57
+ assert _resolve_hit_outcome(10, 15, 15, attack) == (False, True)
58
+
59
+
60
+ def test_falling_one_short_of_the_ac_is_a_miss(attack: AttackActivity) -> None:
61
+ assert _resolve_hit_outcome(10, 14, 15, attack) == (False, False)
62
+
63
+
64
+ @pytest.mark.parametrize("seed", range(32))
65
+ def test_advantage_keeps_the_higher_and_disadvantage_the_lower_die(seed: int) -> None:
66
+ rng = random.Random(seed)
67
+ a, b = rng.randint(1, 20), rng.randint(1, 20)
68
+ assert _roll_natural_d20(_ctx(seed), "advantage") == max(a, b)
69
+ assert _roll_natural_d20(_ctx(seed), "disadvantage") == min(a, b)
70
+ assert _roll_natural_d20(_ctx(seed), "normal") == a
71
+
72
+
73
+ def test_disadvantage_does_not_crit_on_the_discarded_die(attack: AttackActivity) -> None:
74
+ # Find a seed whose two dice are (20, x<20): the kept die under disadvantage
75
+ # must not be the discarded 20.
76
+ for seed in range(500):
77
+ rng = random.Random(seed)
78
+ a, b = rng.randint(1, 20), rng.randint(1, 20)
79
+ if 20 in (a, b) and min(a, b) < 20:
80
+ natural = _roll_natural_d20(_ctx(seed), "disadvantage")
81
+ assert natural == min(a, b)
82
+ assert _resolve_hit_outcome(natural, natural, 99, attack)[0] is False
83
+ return
84
+ pytest.fail("no seed with a discarded 20 in 500 tries")
@@ -0,0 +1,61 @@
1
+ """Regression — combat resolvers use Foundry-shaped bucket keys (Phase 6).
2
+
3
+ Codex review of Phase 6 caught that ``rules/combat.py`` was passing legacy
4
+ bucket strings (``"saving_throw"``, ``"damage"``, ``"attack_roll"``, ``"ac"``)
5
+ into ``apply_changes_to_check``. The seed templates emit Foundry-shaped keys
6
+ (``save.bonus``, ``damage.bonus``, ``attack.roll.bonus``, ``ac.bonus``); the
7
+ helper does exact key matching, so the resolvers were silently no-op-ing
8
+ every effect. These tests lock the alignment.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dnd5e_engine.types.effects import (
14
+ ActiveEffect,
15
+ ActiveEffectChange,
16
+ ActiveEffectDuration,
17
+ )
18
+
19
+
20
+ def _bless(target_id: str = "char:hero") -> ActiveEffect:
21
+ """Seed-shaped Bless: +1d4 on attack rolls and saves (Foundry keys)."""
22
+ return ActiveEffect(
23
+ id="effect:bless",
24
+ name="Bless",
25
+ origin="cast:bless:1",
26
+ target_id=target_id,
27
+ duration=ActiveEffectDuration(rounds=10),
28
+ changes=[
29
+ ActiveEffectChange(key="attack.roll.bonus", mode="add", value="1d4"),
30
+ ActiveEffectChange(key="save.bonus", mode="add", value="1d4"),
31
+ ],
32
+ flags={"concentration": True},
33
+ )
34
+
35
+
36
+ def _cloak(target_id: str = "char:hero") -> ActiveEffect:
37
+ """Seed-shaped Cloak of Protection: +1 AC and +1 saves (Foundry keys)."""
38
+ return ActiveEffect(
39
+ id="effect:cloak_of_protection",
40
+ name="Cloak of Protection",
41
+ origin="item:cloak_of_protection:1",
42
+ target_id=target_id,
43
+ changes=[
44
+ ActiveEffectChange(key="ac.bonus", mode="add", value=1),
45
+ ActiveEffectChange(key="save.bonus", mode="add", value=1),
46
+ ],
47
+ )
48
+
49
+
50
+ def _plus_one_sword(target_id: str = "char:hero") -> ActiveEffect:
51
+ """Seed-shaped +1 Weapon: +1 attack and +1 damage (Foundry keys)."""
52
+ return ActiveEffect(
53
+ id="effect:weapon_plus_1",
54
+ name="+1 Weapon",
55
+ origin="item:weapon_plus_1:1",
56
+ target_id=target_id,
57
+ changes=[
58
+ ActiveEffectChange(key="attack.roll.bonus", mode="add", value=1),
59
+ ActiveEffectChange(key="damage.bonus", mode="add", value=1),
60
+ ],
61
+ )
@@ -0,0 +1,25 @@
1
+ """Regression — combat resolvers honor flag-based advantage / disadvantage.
2
+
3
+ Codex Phase 6 review iter-12 P2: combat.py's saving_throw and
4
+ resolve_player_attack didn't read flag-based advantage / disadvantage
5
+ from active_effects, so Restrained / Guidance-style / Faerie Fire /
6
+ Invisible effects didn't shift the d20 mechanic in combat. Now both
7
+ mirror the iter-10/11 resolve_check fix.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dnd5e_engine.types.effects import (
13
+ ActiveEffect,
14
+ ActiveEffectChange,
15
+ )
16
+
17
+
18
+ def _flag_effect(key: str, target_id: str = "char:hero") -> ActiveEffect:
19
+ return ActiveEffect(
20
+ id=f"effect:test_{key}",
21
+ name=f"test_{key}",
22
+ origin=f"cast:test:{key}",
23
+ target_id=target_id,
24
+ changes=[ActiveEffectChange(key=key, mode="override", value=True)],
25
+ )