dnd5e-engine 0.1.2__tar.gz → 0.2.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 (143) hide show
  1. dnd5e_engine-0.2.0/CHANGELOG.md +117 -0
  2. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/PKG-INFO +2 -2
  3. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/pyproject.toml +2 -2
  4. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/__init__.py +17 -1
  5. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/attack.py +211 -11
  6. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/build_context.py +154 -24
  7. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/context.py +85 -1
  8. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/dice.py +11 -2
  9. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/effects.py +14 -0
  10. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/formula.py +14 -1
  11. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/monster_actions.py +97 -5
  12. dnd5e_engine-0.2.0/src/dnd5e_engine/activities/passive_stats.py +261 -0
  13. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/save_primitive.py +8 -0
  14. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/scale.py +78 -37
  15. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/build_party.py +6 -0
  16. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/events.py +10 -0
  17. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/orchestrator.py +1388 -94
  18. dnd5e_engine-0.2.0/src/dnd5e_engine/rest.py +239 -0
  19. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/effects.py +78 -13
  20. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/gambits.py +4 -71
  21. dnd5e_engine-0.2.0/src/dnd5e_engine/spatial.py +380 -0
  22. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/specs.py +74 -3
  23. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/combat.py +41 -1
  24. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/intent.py +0 -1
  25. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/activities/test_build_context.py +20 -3
  26. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/activities/test_monster_actions.py +64 -0
  27. dnd5e_engine-0.2.0/tests/e2e/__init__.py +0 -0
  28. dnd5e_engine-0.2.0/tests/e2e/conftest.py +13 -0
  29. dnd5e_engine-0.2.0/tests/e2e/harness.py +39 -0
  30. dnd5e_engine-0.2.0/tests/e2e/test_c00_harness_sanity.py +54 -0
  31. dnd5e_engine-0.2.0/tests/e2e/test_c01_dataset_fixes.py +149 -0
  32. dnd5e_engine-0.2.0/tests/e2e/test_c02_small_mechanics.py +249 -0
  33. dnd5e_engine-0.2.0/tests/e2e/test_c03_effect_modes.py +132 -0
  34. dnd5e_engine-0.2.0/tests/e2e/test_c04_caster_plumbing.py +216 -0
  35. dnd5e_engine-0.2.0/tests/e2e/test_c05_spatial.py +366 -0
  36. dnd5e_engine-0.2.0/tests/e2e/test_c06_reactions.py +807 -0
  37. dnd5e_engine-0.2.0/tests/e2e/test_c07_sneak_and_repertoire.py +363 -0
  38. dnd5e_engine-0.2.0/tests/e2e/test_c08_passive_projection.py +335 -0
  39. dnd5e_engine-0.2.0/tests/e2e/test_c09_rest.py +175 -0
  40. dnd5e_engine-0.2.0/tests/e2e/test_c10_monster_behavior.py +156 -0
  41. dnd5e_engine-0.2.0/tests/test_active_effect_resistance_fold.py +53 -0
  42. dnd5e_engine-0.2.0/tests/test_apply_changes_to_check.py +243 -0
  43. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_build_party.py +3 -1
  44. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_build_party_passives.py +30 -0
  45. dnd5e_engine-0.2.0/tests/test_cover_bonus_sidecar.py +155 -0
  46. dnd5e_engine-0.2.0/tests/test_grid_topology.py +282 -0
  47. dnd5e_engine-0.2.0/tests/test_monster_dash.py +56 -0
  48. dnd5e_engine-0.2.0/tests/test_monster_flee_retreat.py +64 -0
  49. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_orchestrator_monster_typed.py +12 -4
  50. dnd5e_engine-0.2.0/tests/test_party_member_reach_ft.py +60 -0
  51. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_passive_stats.py +95 -7
  52. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_public_api_surface.py +8 -0
  53. dnd5e_engine-0.2.0/tests/test_reaction_queue.py +299 -0
  54. dnd5e_engine-0.2.0/tests/test_rest.py +171 -0
  55. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_scale_resolver.py +33 -11
  56. dnd5e_engine-0.2.0/tests/test_sneak_attack_resolver.py +205 -0
  57. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_use_feature_intent.py +100 -2
  58. dnd5e_engine-0.2.0/tests/test_weapon_damage_bonus_sidecar.py +76 -0
  59. dnd5e_engine-0.1.2/CHANGELOG.md +0 -37
  60. dnd5e_engine-0.1.2/src/dnd5e_engine/activities/passive_stats.py +0 -143
  61. dnd5e_engine-0.1.2/src/dnd5e_engine/spatial.py +0 -148
  62. dnd5e_engine-0.1.2/tests/test_apply_changes_to_check.py +0 -96
  63. dnd5e_engine-0.1.2/tests/test_grid_topology.py +0 -128
  64. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/.gitignore +0 -0
  65. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/LICENSE +0 -0
  66. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/Makefile +0 -0
  67. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/NOTICE +0 -0
  68. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/README.md +0 -0
  69. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/scripts/_smoke_grid_combat.py +0 -0
  70. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/scripts/smoke_clean_install.sh +0 -0
  71. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/__init__.py +0 -0
  72. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/apply.py +0 -0
  73. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/cast.py +0 -0
  74. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/check.py +0 -0
  75. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/damage.py +0 -0
  76. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/heal.py +0 -0
  77. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/mastery.py +0 -0
  78. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/resolver.py +0 -0
  79. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/activities/save.py +0 -0
  80. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/build_spec.py +0 -0
  81. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/check.py +0 -0
  82. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/death_saves.py +0 -0
  83. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/dispatch.py +0 -0
  84. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/event_dicts.py +0 -0
  85. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/lib_loader.py +0 -0
  86. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/outcome.py +0 -0
  87. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/py.typed +0 -0
  88. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/results.py +0 -0
  89. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/__init__.py +0 -0
  90. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/_class_meta.py +0 -0
  91. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/_parsing.py +0 -0
  92. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/combat.py +0 -0
  93. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/combat_data.py +0 -0
  94. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/combat_helpers.py +0 -0
  95. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/conditions.py +0 -0
  96. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/dice.py +0 -0
  97. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/equipment.py +0 -0
  98. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/resolution.py +0 -0
  99. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/skills.py +0 -0
  100. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/rules/spells.py +0 -0
  101. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/testing.py +0 -0
  102. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/__init__.py +0 -0
  103. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/conditions.py +0 -0
  104. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/dice.py +0 -0
  105. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/types/effects.py +0 -0
  106. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/src/dnd5e_engine/views.py +0 -0
  107. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/__init__.py +0 -0
  108. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/activities/__init__.py +0 -0
  109. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_active_effect_schema.py +0 -0
  110. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_apply_modifiers.py +0 -0
  111. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_build_context_abilities.py +0 -0
  112. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_combat_bucket_keys.py +0 -0
  113. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_combat_flag_advantage.py +0 -0
  114. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_concentration_drop_effect_name.py +0 -0
  115. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_concentration_repeat_save_identity.py +0 -0
  116. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_coverage_backfill_death_saves.py +0 -0
  117. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_coverage_backfill_seam_errors.py +0 -0
  118. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_duration_tick_at_turn_end.py +0 -0
  119. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_effects_builder_internals.py +0 -0
  120. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_enchantment_sidecar_projection.py +0 -0
  121. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_end_combat_snapshot.py +0 -0
  122. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_expire_clears_active_conditions.py +0 -0
  123. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_feature_save_dc_no_spell_override.py +0 -0
  124. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_get_actor_active_effects.py +0 -0
  125. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_iter11_fixes.py +0 -0
  126. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_iter13_fixes.py +0 -0
  127. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_iter5_fixes.py +0 -0
  128. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_lib_loader.py +0 -0
  129. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_live_combat_view.py +0 -0
  130. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_orchestrator_gating_typed.py +0 -0
  131. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_orchestrator_grid_combat.py +0 -0
  132. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_orchestrator_move_mark_typed.py +0 -0
  133. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_orchestrator_pc_resolution_typed.py +0 -0
  134. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_phase6_public_surface.py +0 -0
  135. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_rage_second_wind_e2e.py +0 -0
  136. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_resolve_check.py +0 -0
  137. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_resolve_check_flag_advantage.py +0 -0
  138. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_resolve_check_specific_buckets.py +0 -0
  139. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_rules_smoke.py +0 -0
  140. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_smoke.py +0 -0
  141. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_start_combat_active_effects.py +0 -0
  142. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_start_combat_seeds_lifecycle.py +0 -0
  143. {dnd5e_engine-0.1.2 → dnd5e_engine-0.2.0}/tests/test_types_smoke.py +0 -0
@@ -0,0 +1,117 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0]
11
+
12
+ Lockstep release with `dnd5e-srd-data` 0.2.0 — the outcome of the gap-closing
13
+ campaign (ten scenario clusters). See `docs/migration/v0.1-to-v0.2.md` for the
14
+ full, host-facing migration guide. The engine now depends on
15
+ `dnd5e-srd-data>=0.2.0` (the rest-cap path reads the new `Feature.uses` schema).
16
+
17
+ ### Added
18
+
19
+ - **Rest & recovery** — new public, zero-I/O module `dnd5e_engine.rest`, called
20
+ *between* combats: `resolve_short_rest`, `resolve_long_rest`,
21
+ `recover_feature_uses`, the `HitDicePool` / `RestOutcome` value types, the
22
+ `RecoveryPeriod` literal — all added to the top-level `__all__`.
23
+ `FEATURE_USE_COUNTER_PREFIX` is also public, but is exported from the
24
+ submodule only: `from dnd5e_engine.rest import FEATURE_USE_COUNTER_PREFIX`.
25
+ - **Spatial (grid) surface** — `WallSegment` value type (also a top-level
26
+ export); `GridScene.wall_segments`, `GridScene.cover_cells`, and
27
+ `GridScene.difficult_terrain_cells` fields (all default empty/off);
28
+ `SpatialTopology.cover_between(...)`; and `GridTopology.cells_in_template(...)`
29
+ for sphere/cone/line AoE templates.
30
+ - **Passive-stat fields** — `condition_immunities` and `damage_vulnerabilities`
31
+ on `PartyMemberSpec` / `EncounterMemberSpec` / `Combatant`, and
32
+ `movement_modes` (`CombatantMovementModes`) plus `walk_speed_bonus` on
33
+ `PartyMemberSpec` / `Combatant`. All default empty and only take effect when
34
+ the backing dataset carries the trait.
35
+ - **Intent field** `PlayerIntent.activity_id` (default `None`) — disambiguates a
36
+ `USE_FEATURE` invocation of a multi-activity feature (Channel Divinity,
37
+ Cunning Strike).
38
+ - `PartyMemberSpec.reach_ft` (default `5`) — threads onto the live
39
+ `Combatant.melee_reach_ft`, making a reach weapon's opportunity-attack reach
40
+ reachable from the boundary spec.
41
+ - **Reaction surface** — `"ready"` intents now register a pre-armed reaction;
42
+ `PlayerIntent.reaction_trigger` narrows to the `ReactionTrigger` literal.
43
+ - `CastFailedReason` gained the values `"countered"` (Counterspell) and
44
+ `"no_uses_remaining"` (a capped, rest-recharged feature invoked over its cap).
45
+ - New additive `ActivityResolutionContext` sidecar fields backing the above
46
+ (cover, per-category damage-bonus buckets, Sneak Attack gates, reaction AC
47
+ bonus) — all default empty.
48
+
49
+ ### Changed
50
+
51
+ - **Spell save DC is now the real SRD 5.2 formula** (`8 + proficiency +
52
+ spellcasting-ability modifier`) for PC caster spells, replacing a flat
53
+ Avrae-era approximation — the campaign's widest-blast-radius change. Any host
54
+ casting a PC save-DC spell sees a different, SRD-correct DC. Item casts and
55
+ monster casters are unchanged.
56
+ - **Sneak Attack rider** — a qualifying finesse/ranged rogue hit now deals the
57
+ `@scale`-resolved extra damage, doubled on a critical hit.
58
+ - **Passive-projection behavioral flips** — Rage damage resistance now halves
59
+ matching damage; an immune condition never attaches; a matching damage
60
+ vulnerability doubles the hit; movement-mode / walk-speed bonuses (Fast
61
+ Movement, Roving) now fold onto `base_speed`.
62
+ - **Monster behavior** — fleeing monsters now retreat away from their nearest
63
+ threat instead of standing still; a mixed melee+ranged multiattack now fires
64
+ the weapon that fits the current distance instead of always the first-listed
65
+ one; monsters Dash to close an out-of-reach movement gap.
66
+ - **Reactions & action economy** — a countered `cast_spell` does **not** expend
67
+ its spell slot; a reaction-applied 1-round buff (Shield's +5 AC) now survives
68
+ until the owner's next turn; `"disengage"` is now a real turn-non-ending
69
+ action; PCs moving out of a monster's reach now provoke opportunity attacks.
70
+ - **Active-effect changes** — `multiply` / `upgrade` / `downgrade` change modes
71
+ now apply; the `rwak` / `msak` / `rsak` damage and `spell.dc` bonus buckets
72
+ now fold; a weapon-tagged `damage.bonus` change now reaches swing damage.
73
+ - Feature-owned `@scale` ids now resolve; the Foundry `(@scale.x)dN` dice-count
74
+ idiom now normalizes; capped features (Second Wind) are now per-rest limited.
75
+
76
+ ### Removed
77
+
78
+ - `ActionType.SHORT_REST` (`dnd5e_engine.types.intent`) — a dead, unreachable
79
+ member of the legacy dispatch enum with no handler. It was never the
80
+ live-combat intent surface (`events.py::IntentType` has no `"short_rest"`).
81
+ Hosts wanting rest resolution use the new `dnd5e_engine.rest` module.
82
+ - `rules/gambits.select_action` (with the private helpers `_PASS_ACTION` /
83
+ `_get_alive_targets` only it used) — the legacy per-profile gambit AI the live
84
+ monster-turn path never called. Every behaviour it offered now lives in
85
+ `advance_monster_turn` / `activities/monster_actions.py`. Verified caller-free;
86
+ it was not a top-level `__all__` export.
87
+ (`rules/gambits.assign_behavior_profile` was reviewed and **retained** — it is
88
+ a host-facing utility for constructing `EncounterMemberSpec.behavior_profile`
89
+ from raw monster stats, not dead code.)
90
+ ## [0.1.2]
91
+
92
+ ### Added
93
+ - `ActionType.USE_ITEM` parser-facing action member.
94
+
95
+ ## [0.1.1]
96
+
97
+ ### Added
98
+ - `LiveCombatView` — public read-model projection of live combat state.
99
+ - `get_live(handle) -> LiveCombatView` is now part of the public API (`__all__`);
100
+ it returns a point-in-time snapshot view instead of the private `_LiveCombat`.
101
+ - `roll_dice_str` — the dice-expression evaluator is now a public test seam
102
+ (renamed from the private `_roll_dice_str`).
103
+
104
+ ## [0.1.0]
105
+
106
+ First public release.
107
+
108
+ ### Added
109
+
110
+ - Pure-Python, host-agnostic D&D 5e SRD rules engine: combat orchestration,
111
+ ability checks and saving throws, and combat-scoped effects.
112
+ - SRD 5.2 (2024) content resolution against the typed-Activity corpus via
113
+ `BundledAssetLoader`, reading the canonical dataset from the
114
+ `dnd5e-srd-data` package.
115
+ - Curated public API surface (`__all__`) with a surface guard test.
116
+ - `py.typed` marker — the package ships inline type information.
117
+ - Zero-I/O guarantee: no DB, network, or async dependencies in the engine.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dnd5e-engine
3
- Version: 0.1.2
3
+ Version: 0.2.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
@@ -34,7 +34,7 @@ Classifier: Programming Language :: Python :: 3.13
34
34
  Classifier: Typing :: Typed
35
35
  Requires-Python: >=3.12
36
36
  Requires-Dist: d20>=1.1.2
37
- Requires-Dist: dnd5e-srd-data>=0.1.0
37
+ Requires-Dist: dnd5e-srd-data>=0.2.0
38
38
  Requires-Dist: pydantic>=2.10.0
39
39
  Provides-Extra: dev
40
40
  Requires-Dist: bandit>=1.8.0; extra == 'dev'
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dnd5e-engine"
7
- version = "0.1.2"
7
+ version = "0.2.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"
@@ -24,7 +24,7 @@ dependencies = [
24
24
  # lib_loader imports BundledAssetLoader at runtime; the typed-Activity
25
25
  # resolver reads the canonical corpus through it. The data lib's >=3.12
26
26
  # floor is why this package now also requires >=3.12 (see requires-python).
27
- "dnd5e-srd-data>=0.1.0",
27
+ "dnd5e-srd-data>=0.2.0",
28
28
  ]
29
29
 
30
30
  [project.optional-dependencies]
@@ -13,7 +13,7 @@ Deferred for later phases:
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- __version__ = "0.1.1"
16
+ __version__ = "0.2.0"
17
17
 
18
18
  from dnd5e_engine.build_party import build_party_member
19
19
  from dnd5e_engine.build_spec import (
@@ -41,6 +41,14 @@ from dnd5e_engine.outcome import (
41
41
  DeathRecord,
42
42
  LootDrop,
43
43
  )
44
+ from dnd5e_engine.rest import (
45
+ HitDicePool,
46
+ RecoveryPeriod,
47
+ RestOutcome,
48
+ recover_feature_uses,
49
+ resolve_long_rest,
50
+ resolve_short_rest,
51
+ )
44
52
  from dnd5e_engine.results import EndCombatResult, StartCombatResult
45
53
  from dnd5e_engine.rules.effects import roll_dice_str
46
54
  from dnd5e_engine.spatial import cell_id, parse_cell
@@ -49,6 +57,7 @@ from dnd5e_engine.specs import (
49
57
  GridScene,
50
58
  PartyMemberSpec,
51
59
  SceneTopology,
60
+ WallSegment,
52
61
  ZoneEdge,
53
62
  )
54
63
  from dnd5e_engine.types.effects import (
@@ -76,13 +85,17 @@ __all__ = [
76
85
  "EncounterMemberSpec",
77
86
  "EndCombatResult",
78
87
  "GridScene",
88
+ "HitDicePool",
79
89
  "IntentType",
80
90
  "LiveCombatView",
81
91
  "LootDrop",
82
92
  "PartyMemberSpec",
83
93
  "PlayerIntent",
94
+ "RecoveryPeriod",
95
+ "RestOutcome",
84
96
  "SceneTopology",
85
97
  "StartCombatResult",
98
+ "WallSegment",
86
99
  "ZoneEdge",
87
100
  "advance_monster_turn",
88
101
  "build_party_member",
@@ -93,7 +106,10 @@ __all__ = [
93
106
  "make_build_spec",
94
107
  "narration_events",
95
108
  "parse_cell",
109
+ "recover_feature_uses",
96
110
  "resolve_check",
111
+ "resolve_long_rest",
112
+ "resolve_short_rest",
97
113
  "roll_dice_str",
98
114
  "start_combat",
99
115
  "submit_player_intent",
@@ -12,11 +12,13 @@ MIRRORS, does not import from, ``effects/attack.py`` + ``effects/damage.py``:
12
12
 
13
13
  * The natural-d20 roll honors ``ctx.variables["force_d20"]`` (the test seam),
14
14
  else draws from ``ctx.rng`` — ``max`` of two for advantage, ``min`` of two for
15
- disadvantage, one otherwise (SRD §Advantage and Disadvantage). The mode is
16
- always ``"normal"`` today: the typed Outlined-effect layer does not yet encode
17
- an attack-advantage change, so no producer feeds a per-target adv/dis flag.
18
- Re-add a per-target reconciliation (consumer + producer together) when the
19
- data layer encodes Faerie-Fire-style ``flags.advantage.attack``.
15
+ disadvantage, one otherwise (SRD §Advantage and Disadvantage). The d20 ``mode``
16
+ itself stays ``"normal"`` on this live path: ``ctx.active_effects`` IS now read
17
+ for the attacker's ``flags.advantage.attack`` / ``flags.disadvantage.attack``
18
+ (``attacker_advantage_flags``), but only to GATE the SRD §Sneak Attack trigger
19
+ below — not to reroll the base attack. Rolling the base d20 with advantage/
20
+ disadvantage would shift the seeded stream and crit outcome; that separate
21
+ delta is deferred (see ``docs/migration/v0.1-to-v0.2.md`` / ``BACKLOG.md``).
20
22
  * Hit / crit / miss mirrors ``effects/attack.py:_resolve_hit_outcome``: natural
21
23
  20 → auto crit+hit, natural 1 → auto miss, else ``total >= AC`` (SRD §Rolling
22
24
  1 or 20 / §Making an Attack). The crit threshold is ``attack.critical.threshold
@@ -62,6 +64,7 @@ from dnd5e_engine.activities.effects import apply_activity_effects
62
64
  from dnd5e_engine.activities.formula import resolve_damage_block, resolve_roll_data
63
65
  from dnd5e_engine.activities.mastery import apply_mastery_on_hit, apply_mastery_on_miss
64
66
  from dnd5e_engine.events import AdvantageMode, AttackRolled
67
+ from dnd5e_engine.spatial import cover_bonus
65
68
 
66
69
  if TYPE_CHECKING:
67
70
  from dnd5e_srd_data.schema.common import AttackActivity, DamagePartBlock
@@ -101,17 +104,38 @@ def resolve_attack(
101
104
  # fresh d4 in the seeded stream — mirrors save_primitive's passive_save_bonus.
102
105
  attack_bonus_expr = ctx.passive_attack_bonus.get(ctx.caster.entity_id)
103
106
 
107
+ # SRD §Advantage and Disadvantage — read the attacker's own
108
+ # ``flags.advantage.attack`` / ``flags.disadvantage.attack`` override
109
+ # changes (both present cancel to normal, Avrae ``reconcile_adv``). These
110
+ # gate the SRD §Sneak Attack trigger below; they deliberately do NOT change
111
+ # the natural-d20 ``mode`` on this live path. Doing so would shift the
112
+ # seeded dice stream and the crit outcome of the base attack, perturbing
113
+ # the very damage magnitudes the once-per-turn / ally-adjacent scenarios
114
+ # isolate (a Sneak Attack rider must be the ONLY damage delta a qualifying
115
+ # trigger introduces). Rolling the base attack itself with advantage/
116
+ # disadvantage on the live path is a separate, deferred delta — see
117
+ # ``docs/migration/v0.1-to-v0.2.md`` and ``BACKLOG.md``.
118
+ attacker_has_advantage, attacker_has_disadvantage = attacker_advantage_flags(ctx)
119
+
104
120
  for index, target in enumerate(ctx.targets):
105
- # No producer feeds per-target attack adv/dis today (the typed Outlined
106
- # effect layer does not yet encode an attack-advantage change), so the
107
- # mode is always normal. Re-add a per-target reconciliation when the data
108
- # layer encodes Faerie-Fire-style ``flags.advantage.attack``.
109
121
  mode: AdvantageMode = "normal"
110
122
  natural = _roll_natural_d20(ctx, mode, target_index=index)
111
123
  total = natural + attack_bonus
112
124
  if attack_bonus_expr:
113
125
  total += roll_expr(attack_bonus_expr, ctx.rng)
114
- is_crit, is_hit = _resolve_hit_outcome(natural, total, target.ac, activity)
126
+ # SRD 5.2 §Cover — half (+2) / three-quarters (+5) cover raises the
127
+ # target's EFFECTIVE AC for this attack only; total cover is filtered
128
+ # upstream (the target is never reachable as a resolver target at all).
129
+ # SRD Shield — "+5 bonus to AC, including against the triggering
130
+ # attack" (C06-S03): a pre-armed reaction can land an AC-bonus active
131
+ # effect on the target between the trigger and this comparison; the
132
+ # natural roll itself is never touched, only this comparison.
133
+ effective_ac = (
134
+ target.ac
135
+ + cover_bonus(ctx.target_cover.get(target.entity_id, "none"))
136
+ + ctx.passive_ac_bonus.get(target.entity_id, 0)
137
+ )
138
+ is_crit, is_hit = _resolve_hit_outcome(natural, total, effective_ac, activity)
115
139
 
116
140
  ctx.event_emitter(
117
141
  AttackRolled(
@@ -126,7 +150,16 @@ def resolve_attack(
126
150
  )
127
151
 
128
152
  if is_hit:
129
- _apply_on_hit_damage(activity, ctx, target, weapon, governing_ability, is_crit=is_crit)
153
+ _apply_on_hit_damage(
154
+ activity,
155
+ ctx,
156
+ target,
157
+ weapon,
158
+ governing_ability,
159
+ is_crit=is_crit,
160
+ attacker_has_advantage=attacker_has_advantage,
161
+ attacker_has_disadvantage=attacker_has_disadvantage,
162
+ )
130
163
  apply_mastery_on_hit(weapon, ctx, target, governing_ability)
131
164
  apply_activity_effects(
132
165
  activity, ctx, target, save_succeeded=None, cast_level=cast_level
@@ -135,6 +168,84 @@ def resolve_attack(
135
168
  apply_mastery_on_miss(weapon, ctx, target, governing_ability)
136
169
 
137
170
 
171
+ # ── attacker advantage / Sneak Attack production ─────────────────────────────
172
+
173
+
174
+ def attacker_advantage_flags(ctx: ActivityResolutionContext) -> tuple[bool, bool]:
175
+ """Read the attacker's ``flags.advantage/disadvantage.attack`` override changes.
176
+
177
+ SRD §Advantage and Disadvantage. Mirrors the attacker-flag half of
178
+ ``rules/combat.py``'s reconciliation: an ``override``-mode change with value
179
+ ``True`` on an effect the CASTER carries flips the corresponding flag. Only
180
+ the caster's own effects count here (target-side Faerie-Fire production is
181
+ deferred — see the migration note); an absent/empty ``active_effects`` yields
182
+ ``(False, False)`` (``normal``), byte-identical to the prior hardcode.
183
+ """
184
+ has_advantage = False
185
+ has_disadvantage = False
186
+ for eff in ctx.active_effects:
187
+ if eff.target_id != ctx.caster.entity_id:
188
+ continue
189
+ for ch in eff.changes:
190
+ if ch.mode != "override" or ch.value is not True:
191
+ continue
192
+ if ch.key == "flags.advantage.attack":
193
+ has_advantage = True
194
+ elif ch.key == "flags.disadvantage.attack":
195
+ has_disadvantage = True
196
+ # Both present cancel to normal (Avrae ``reconcile_adv``).
197
+ if has_advantage and has_disadvantage:
198
+ return False, False
199
+ return has_advantage, has_disadvantage
200
+
201
+
202
+ def sneak_attack_dice(ctx: ActivityResolutionContext) -> str | None:
203
+ """The caster's Sneak Attack extra-damage dice expression, if granted.
204
+
205
+ SRD §Sneak Attack (Rogue): the feature's ``damage.parts[0]`` formula is
206
+ ``@scale.<class>.sneak-attack`` (``"3d6"`` at Rogue level 5). The
207
+ orchestrator pre-resolves that ``@scale`` token into ``ctx.scale_values``
208
+ at the build-party seam (loader access there); this pure resolver reads the
209
+ already-resolved dice STRING keyed ``"<class>.sneak-attack"``. Absent ⇒ the
210
+ caster has no Sneak Attack (``None``, no rider).
211
+ """
212
+ for key, value in ctx.scale_values.items():
213
+ if key.endswith(".sneak-attack") and isinstance(value, str) and value:
214
+ return value
215
+ return None
216
+
217
+
218
+ def sneak_attack_triggers(
219
+ ctx: ActivityResolutionContext,
220
+ weapon: Weapon | None,
221
+ target: Combatant,
222
+ *,
223
+ attacker_has_advantage: bool,
224
+ attacker_has_disadvantage: bool,
225
+ ) -> bool:
226
+ """SRD §Sneak Attack trigger: a Finesse-or-Ranged weapon hit made with
227
+ Advantage, OR (the ally-adjacent alternative) an ally within 5 ft of the
228
+ target who is not Incapacitated while the attacker is not at Disadvantage.
229
+
230
+ The ally-adjacent spatial predicate is evaluated orchestrator-side (a new
231
+ consumer of ``spatial.py``) and delivered as the per-target
232
+ ``ctx.sneak_attack_ally_adjacent`` flag; this pure resolver only reads it.
233
+ A spell attack (no weapon) never qualifies.
234
+ """
235
+ if weapon is None:
236
+ return False
237
+ is_finesse = WeaponProperty.FINESSE in weapon.properties
238
+ is_ranged = weapon.weapon_category in _RANGED_CATEGORIES
239
+ if not (is_finesse or is_ranged):
240
+ return False
241
+ if attacker_has_advantage:
242
+ return True
243
+ # Ally-adjacent alternative: an adjacent, non-Incapacitated ally (computed
244
+ # orchestrator-side) while the attacker is not at Disadvantage.
245
+ ally_adjacent = bool(ctx.sneak_attack_ally_adjacent.get(target.entity_id))
246
+ return ally_adjacent and not attacker_has_disadvantage
247
+
248
+
138
249
  # ── attack-bonus resolution ──────────────────────────────────────────────────
139
250
 
140
251
 
@@ -175,6 +286,27 @@ def _is_melee_weapon(weapon: Weapon | None) -> bool:
175
286
  return weapon is not None and weapon.weapon_category not in _RANGED_CATEGORIES
176
287
 
177
288
 
289
+ def _is_ranged_weapon(weapon: Weapon | None) -> bool:
290
+ """True iff ``weapon`` is a ranged weapon (Foundry rwak scope).
291
+
292
+ The ranged analog of ``_is_melee_weapon`` — the scope of
293
+ ``system.bonuses.rwak.damage`` (C04-S03).
294
+ """
295
+ return weapon is not None and weapon.weapon_category in _RANGED_CATEGORIES
296
+
297
+
298
+ def _is_melee_spell_attack(activity: AttackActivity, weapon: Weapon | None) -> bool:
299
+ """True iff this is a melee SPELL attack (Foundry msak scope: no weapon,
300
+ ``attack.type.value == "melee"`` — e.g. Shocking Grasp)."""
301
+ return weapon is None and activity.attack.type.value == "melee"
302
+
303
+
304
+ def _is_ranged_spell_attack(activity: AttackActivity, weapon: Weapon | None) -> bool:
305
+ """True iff this is a ranged SPELL attack (Foundry rsak scope: no weapon,
306
+ ``attack.type.value == "ranged"`` — e.g. Fire Bolt)."""
307
+ return weapon is None and activity.attack.type.value == "ranged"
308
+
309
+
178
310
  def _weapon_default_ability(weapon: Weapon, ctx: ActivityResolutionContext) -> str:
179
311
  """SRD default attack/damage ability for a weapon with no explicit ability.
180
312
 
@@ -295,6 +427,8 @@ def _apply_on_hit_damage(
295
427
  governing_ability: str | None,
296
428
  *,
297
429
  is_crit: bool,
430
+ attacker_has_advantage: bool = False,
431
+ attacker_has_disadvantage: bool = False,
298
432
  ) -> None:
299
433
  """Roll base weapon damage + activity parts for one hit target and apply.
300
434
 
@@ -348,6 +482,72 @@ def _apply_on_hit_damage(
348
482
  if melee_bonus_expr:
349
483
  by_type[first_type] += roll_expr(melee_bonus_expr, ctx.rng)
350
484
 
485
+ # C04-S03 / Foundry ``system.bonuses.rwak.damage`` — a ranged weapon
486
+ # attack damage bonus, the ranged analog of the melee-only bonus just
487
+ # above. Add it once to the first damage type, RANGED WEAPON only.
488
+ if first_type is not None and _is_ranged_weapon(weapon):
489
+ ranged_bonus_expr = ctx.passive_ranged_damage_bonus.get(ctx.caster.entity_id)
490
+ if ranged_bonus_expr:
491
+ by_type[first_type] += roll_expr(ranged_bonus_expr, ctx.rng)
492
+
493
+ # Foundry ``system.bonuses.msak.damage`` / ``system.bonuses.rsak.damage``
494
+ # — melee / ranged SPELL-attack damage bonuses (e.g. Shocking Grasp /
495
+ # Fire Bolt). No weapon is present for a spell attack; gate on the
496
+ # activity's own melee/ranged classification instead.
497
+ if first_type is not None and _is_melee_spell_attack(activity, weapon):
498
+ melee_spell_bonus_expr = ctx.passive_melee_spell_damage_bonus.get(ctx.caster.entity_id)
499
+ if melee_spell_bonus_expr:
500
+ by_type[first_type] += roll_expr(melee_spell_bonus_expr, ctx.rng)
501
+ if first_type is not None and _is_ranged_spell_attack(activity, weapon):
502
+ ranged_spell_bonus_expr = ctx.passive_ranged_spell_damage_bonus.get(
503
+ ctx.caster.entity_id
504
+ )
505
+ if ranged_spell_bonus_expr:
506
+ by_type[first_type] += roll_expr(ranged_spell_bonus_expr, ctx.rng)
507
+
508
+ # SRD §Making an Attack / §Magic Items — a magic weapon's bonus
509
+ # applies to BOTH the attack roll and the damage roll made with it.
510
+ # Folded into the ``passive_weapon_damage_bonus`` sidecar by the
511
+ # orchestrator (action-type-tagged so it never leaks into spell
512
+ # attacks). Applies to ANY weapon swing (melee or ranged), unlike the
513
+ # Rage-only melee bonus above. Add it once to the first damage type,
514
+ # rolled through ``ctx.rng`` so a dice-valued bonus lands in the same
515
+ # seed stream.
516
+ if first_type is not None and weapon is not None:
517
+ weapon_bonus_expr = ctx.passive_weapon_damage_bonus.get(ctx.caster.entity_id)
518
+ if weapon_bonus_expr:
519
+ by_type[first_type] += roll_expr(weapon_bonus_expr, ctx.rng)
520
+
521
+ # SRD §Sneak Attack (Rogue) — once per turn, on a qualifying hit (Finesse
522
+ # or Ranged weapon, made with Advantage OR the ally-adjacent alternative,
523
+ # rider unspent this turn), add the feature's extra dice. Folded into the
524
+ # FIRST damage type ("the extra damage's type is the same as the
525
+ # weapon's"). Rolled through ``ctx.rng`` (via ``roll_expr``, matching the
526
+ # passive-bonus fold pattern) so the dice land in the same seed stream.
527
+ # On a crit the rider dice DOUBLE — SRD 5.2 §Critical Hit ("Roll all of
528
+ # the attack's damage dice twice and add them together",
529
+ # 09_rules_glossary.md; the rider is part of the attack's damage dice) —
530
+ # via the SAME ``_double_dice`` count-doubling idiom the base-weapon
531
+ # crit path (``roll_damage_part(crit=...)``) uses.
532
+ # The once-per-turn cap gates the FOLD itself (``sneak_attack_spent``);
533
+ # recording "spent" is an orchestrator-side concern (a per-turn
534
+ # ``Combatant`` flag cleared at ``TurnStarted``).
535
+ if (
536
+ first_type is not None
537
+ and weapon is not None
538
+ and not ctx.sneak_attack_spent.get(ctx.caster.entity_id)
539
+ and sneak_attack_triggers(
540
+ ctx,
541
+ weapon,
542
+ target,
543
+ attacker_has_advantage=attacker_has_advantage,
544
+ attacker_has_disadvantage=attacker_has_disadvantage,
545
+ )
546
+ ):
547
+ sneak_dice = sneak_attack_dice(ctx)
548
+ if sneak_dice:
549
+ by_type[first_type] += roll_expr(sneak_dice, ctx.rng, crit=is_crit)
550
+
351
551
  apply_damage(target, dict(by_type), ctx)
352
552
  finally:
353
553
  if is_crit: