dnd5e-engine 0.1.2__tar.gz → 0.3.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 (147) hide show
  1. dnd5e_engine-0.3.0/CHANGELOG.md +151 -0
  2. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/PKG-INFO +2 -2
  3. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/pyproject.toml +4 -2
  4. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/__init__.py +19 -1
  5. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/attack.py +211 -11
  6. dnd5e_engine-0.3.0/src/dnd5e_engine/activities/build_context.py +359 -0
  7. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/cast.py +37 -8
  8. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/context.py +90 -1
  9. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/dice.py +11 -2
  10. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/effects.py +14 -0
  11. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/formula.py +14 -1
  12. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/monster_actions.py +97 -5
  13. dnd5e_engine-0.3.0/src/dnd5e_engine/activities/passive_stats.py +261 -0
  14. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/save_primitive.py +8 -0
  15. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/scale.py +78 -37
  16. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/build_party.py +6 -0
  17. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/events.py +17 -0
  18. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/orchestrator.py +1743 -108
  19. dnd5e_engine-0.3.0/src/dnd5e_engine/rest.py +302 -0
  20. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/effects.py +78 -13
  21. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/gambits.py +4 -71
  22. dnd5e_engine-0.3.0/src/dnd5e_engine/spatial.py +380 -0
  23. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/specs.py +77 -6
  24. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/combat.py +41 -1
  25. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/intent.py +0 -1
  26. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/views.py +5 -0
  27. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/activities/test_build_context.py +20 -3
  28. dnd5e_engine-0.3.0/tests/activities/test_cast_level_override.py +121 -0
  29. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/activities/test_monster_actions.py +64 -0
  30. dnd5e_engine-0.3.0/tests/e2e/__init__.py +0 -0
  31. dnd5e_engine-0.3.0/tests/e2e/conftest.py +13 -0
  32. dnd5e_engine-0.3.0/tests/e2e/harness.py +39 -0
  33. dnd5e_engine-0.3.0/tests/e2e/test_c00_harness_sanity.py +54 -0
  34. dnd5e_engine-0.3.0/tests/e2e/test_c01_dataset_fixes.py +149 -0
  35. dnd5e_engine-0.3.0/tests/e2e/test_c02_small_mechanics.py +249 -0
  36. dnd5e_engine-0.3.0/tests/e2e/test_c03_effect_modes.py +132 -0
  37. dnd5e_engine-0.3.0/tests/e2e/test_c04_caster_plumbing.py +216 -0
  38. dnd5e_engine-0.3.0/tests/e2e/test_c05_spatial.py +366 -0
  39. dnd5e_engine-0.3.0/tests/e2e/test_c06_reactions.py +807 -0
  40. dnd5e_engine-0.3.0/tests/e2e/test_c07_sneak_and_repertoire.py +363 -0
  41. dnd5e_engine-0.3.0/tests/e2e/test_c08_passive_projection.py +335 -0
  42. dnd5e_engine-0.3.0/tests/e2e/test_c09_rest.py +175 -0
  43. dnd5e_engine-0.3.0/tests/e2e/test_c10_monster_behavior.py +156 -0
  44. dnd5e_engine-0.3.0/tests/e2e/test_c11_item_charges.py +276 -0
  45. dnd5e_engine-0.3.0/tests/test_active_effect_resistance_fold.py +53 -0
  46. dnd5e_engine-0.3.0/tests/test_apply_changes_to_check.py +243 -0
  47. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_build_party.py +3 -1
  48. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_build_party_passives.py +30 -0
  49. dnd5e_engine-0.3.0/tests/test_cover_bonus_sidecar.py +155 -0
  50. dnd5e_engine-0.3.0/tests/test_grid_topology.py +282 -0
  51. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_live_combat_view.py +28 -0
  52. dnd5e_engine-0.3.0/tests/test_monster_dash.py +56 -0
  53. dnd5e_engine-0.3.0/tests/test_monster_flee_retreat.py +64 -0
  54. dnd5e_engine-0.3.0/tests/test_orchestrator_item_charges.py +906 -0
  55. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_orchestrator_monster_typed.py +12 -4
  56. dnd5e_engine-0.3.0/tests/test_party_member_reach_ft.py +60 -0
  57. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_passive_stats.py +95 -7
  58. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_public_api_surface.py +9 -0
  59. dnd5e_engine-0.3.0/tests/test_reaction_queue.py +299 -0
  60. dnd5e_engine-0.3.0/tests/test_rest.py +217 -0
  61. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_scale_resolver.py +33 -11
  62. dnd5e_engine-0.3.0/tests/test_sneak_attack_resolver.py +205 -0
  63. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_use_feature_intent.py +100 -2
  64. dnd5e_engine-0.3.0/tests/test_weapon_damage_bonus_sidecar.py +76 -0
  65. dnd5e_engine-0.1.2/CHANGELOG.md +0 -37
  66. dnd5e_engine-0.1.2/src/dnd5e_engine/activities/build_context.py +0 -219
  67. dnd5e_engine-0.1.2/src/dnd5e_engine/activities/passive_stats.py +0 -143
  68. dnd5e_engine-0.1.2/src/dnd5e_engine/spatial.py +0 -148
  69. dnd5e_engine-0.1.2/tests/test_apply_changes_to_check.py +0 -96
  70. dnd5e_engine-0.1.2/tests/test_grid_topology.py +0 -128
  71. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/.gitignore +0 -0
  72. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/LICENSE +0 -0
  73. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/Makefile +0 -0
  74. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/NOTICE +0 -0
  75. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/README.md +0 -0
  76. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/scripts/_smoke_grid_combat.py +0 -0
  77. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/scripts/smoke_clean_install.sh +0 -0
  78. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/__init__.py +0 -0
  79. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/apply.py +0 -0
  80. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/check.py +0 -0
  81. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/damage.py +0 -0
  82. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/heal.py +0 -0
  83. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/mastery.py +0 -0
  84. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/resolver.py +0 -0
  85. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/activities/save.py +0 -0
  86. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/build_spec.py +0 -0
  87. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/check.py +0 -0
  88. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/death_saves.py +0 -0
  89. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/dispatch.py +0 -0
  90. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/event_dicts.py +0 -0
  91. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/lib_loader.py +0 -0
  92. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/outcome.py +0 -0
  93. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/py.typed +0 -0
  94. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/results.py +0 -0
  95. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/__init__.py +0 -0
  96. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/_class_meta.py +0 -0
  97. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/_parsing.py +0 -0
  98. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/combat.py +0 -0
  99. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/combat_data.py +0 -0
  100. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/combat_helpers.py +0 -0
  101. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/conditions.py +0 -0
  102. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/dice.py +0 -0
  103. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/equipment.py +0 -0
  104. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/resolution.py +0 -0
  105. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/skills.py +0 -0
  106. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/rules/spells.py +0 -0
  107. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/testing.py +0 -0
  108. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/__init__.py +0 -0
  109. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/conditions.py +0 -0
  110. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/dice.py +0 -0
  111. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/src/dnd5e_engine/types/effects.py +0 -0
  112. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/__init__.py +0 -0
  113. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/activities/__init__.py +0 -0
  114. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_active_effect_schema.py +0 -0
  115. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_apply_modifiers.py +0 -0
  116. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_build_context_abilities.py +0 -0
  117. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_combat_bucket_keys.py +0 -0
  118. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_combat_flag_advantage.py +0 -0
  119. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_concentration_drop_effect_name.py +0 -0
  120. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_concentration_repeat_save_identity.py +0 -0
  121. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_coverage_backfill_death_saves.py +0 -0
  122. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_coverage_backfill_seam_errors.py +0 -0
  123. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_duration_tick_at_turn_end.py +0 -0
  124. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_effects_builder_internals.py +0 -0
  125. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_enchantment_sidecar_projection.py +0 -0
  126. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_end_combat_snapshot.py +0 -0
  127. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_expire_clears_active_conditions.py +0 -0
  128. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_feature_save_dc_no_spell_override.py +0 -0
  129. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_get_actor_active_effects.py +0 -0
  130. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_iter11_fixes.py +0 -0
  131. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_iter13_fixes.py +0 -0
  132. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_iter5_fixes.py +0 -0
  133. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_lib_loader.py +0 -0
  134. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_orchestrator_gating_typed.py +0 -0
  135. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_orchestrator_grid_combat.py +0 -0
  136. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_orchestrator_move_mark_typed.py +0 -0
  137. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_orchestrator_pc_resolution_typed.py +0 -0
  138. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_phase6_public_surface.py +0 -0
  139. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_rage_second_wind_e2e.py +0 -0
  140. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_resolve_check.py +0 -0
  141. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_resolve_check_flag_advantage.py +0 -0
  142. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_resolve_check_specific_buckets.py +0 -0
  143. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_rules_smoke.py +0 -0
  144. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_smoke.py +0 -0
  145. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_start_combat_active_effects.py +0 -0
  146. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_start_combat_seeds_lifecycle.py +0 -0
  147. {dnd5e_engine-0.1.2 → dnd5e_engine-0.3.0}/tests/test_types_smoke.py +0 -0
@@ -0,0 +1,151 @@
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.3.0]
11
+
12
+ Lockstep release with `dnd5e-srd-data` 0.3.0 — the item charge lifecycle: gate
13
+ → spend → cast-delegate → upcast → recharge → observe. See
14
+ `docs/migration/v0.2-to-v0.3.md` for the full, host-facing migration guide.
15
+ The engine now depends on `dnd5e-srd-data>=0.3.0` (the charge-gate and
16
+ cast-delegation paths read the new `Item.uses` / `Spell.foundry_uuid` schema).
17
+
18
+ ### Added
19
+
20
+ - **Item charges** — `use_item` now validates and spends `consumption.targets[type=itemUses]`
21
+ costs against the item's `uses.max` pool, tracked in the `custom_counters` sidecar under
22
+ `item_use:<slug>` (`{"spent": n}`). Exhausted pools reject with
23
+ `CastFailed(reason="no_charges_remaining")` before the action budget is touched. Items
24
+ without a pool are unaffected. `CastFailedReason` also gains `invalid_charge_spend`, emitted
25
+ when a `charges_to_spend` request violates the activity's `consumption.scaling` (see
26
+ "Charge upcasting" below).
27
+ - **Item recharge** — `dnd5e_engine.rest.recover_item_uses` (+ `ITEM_USE_COUNTER_PREFIX`,
28
+ submodule-only export): pure recharge over `item_use:` pools. The engine's own
29
+ `rest.RecoveryPeriod` gains `"dawn"`/`"day"`/`"dusk"` (previously just `"sr"`/`"lr"`;
30
+ the data package's `RecoveryRule` already knew `"day"` — this widens the engine's
31
+ narrower rest-period literal to match); formula recovery now rolls dice ("1d6 + 1")
32
+ through an optional `rng` (also available on `recover_feature_uses`).
33
+ - **Cast delegation** — a used item's `CastActivity` now resolves its referenced spell
34
+ (uuid→Spell via `dnd5e-srd-data`'s `get_spell_by_uuid`) on the PC-intent path, honoring
35
+ the item's flat DC / attack / level overrides. `cast_spell_unresolved` now signals a
36
+ genuinely missing uuid, not missing plumbing.
37
+ - **Charge upcasting** — `PlayerIntent.charges_to_spend` casts at
38
+ `base level + extra charges`, validated against `consumption.scaling`
39
+ (`invalid_charge_spend` on violation). New `ActivityResolutionContext.cast_level_override`.
40
+ - **Charge read path** — `LiveCombatView.custom_counters_by_entity` (three-level snapshot
41
+ copy): hosts mirror `item_use:` / `feature_use:` pools per turn the same way they mirror
42
+ `spell_slots_by_entity`.
43
+
44
+ ## [0.2.0]
45
+
46
+ Lockstep release with `dnd5e-srd-data` 0.2.0 — the outcome of the gap-closing
47
+ campaign (ten scenario clusters). See `docs/migration/v0.1-to-v0.2.md` for the
48
+ full, host-facing migration guide. The engine now depends on
49
+ `dnd5e-srd-data>=0.2.0` (the rest-cap path reads the new `Feature.uses` schema).
50
+
51
+ ### Added
52
+
53
+ - **Rest & recovery** — new public, zero-I/O module `dnd5e_engine.rest`, called
54
+ *between* combats: `resolve_short_rest`, `resolve_long_rest`,
55
+ `recover_feature_uses`, the `HitDicePool` / `RestOutcome` value types, the
56
+ `RecoveryPeriod` literal — all added to the top-level `__all__`.
57
+ `FEATURE_USE_COUNTER_PREFIX` is also public, but is exported from the
58
+ submodule only: `from dnd5e_engine.rest import FEATURE_USE_COUNTER_PREFIX`.
59
+ - **Spatial (grid) surface** — `WallSegment` value type (also a top-level
60
+ export); `GridScene.wall_segments`, `GridScene.cover_cells`, and
61
+ `GridScene.difficult_terrain_cells` fields (all default empty/off);
62
+ `SpatialTopology.cover_between(...)`; and `GridTopology.cells_in_template(...)`
63
+ for sphere/cone/line AoE templates.
64
+ - **Passive-stat fields** — `condition_immunities` and `damage_vulnerabilities`
65
+ on `PartyMemberSpec` / `EncounterMemberSpec` / `Combatant`, and
66
+ `movement_modes` (`CombatantMovementModes`) plus `walk_speed_bonus` on
67
+ `PartyMemberSpec` / `Combatant`. All default empty and only take effect when
68
+ the backing dataset carries the trait.
69
+ - **Intent field** `PlayerIntent.activity_id` (default `None`) — disambiguates a
70
+ `USE_FEATURE` invocation of a multi-activity feature (Channel Divinity,
71
+ Cunning Strike).
72
+ - `PartyMemberSpec.reach_ft` (default `5`) — threads onto the live
73
+ `Combatant.melee_reach_ft`, making a reach weapon's opportunity-attack reach
74
+ reachable from the boundary spec.
75
+ - **Reaction surface** — `"ready"` intents now register a pre-armed reaction;
76
+ `PlayerIntent.reaction_trigger` narrows to the `ReactionTrigger` literal.
77
+ - `CastFailedReason` gained the values `"countered"` (Counterspell) and
78
+ `"no_uses_remaining"` (a capped, rest-recharged feature invoked over its cap).
79
+ - New additive `ActivityResolutionContext` sidecar fields backing the above
80
+ (cover, per-category damage-bonus buckets, Sneak Attack gates, reaction AC
81
+ bonus) — all default empty.
82
+
83
+ ### Changed
84
+
85
+ - **Spell save DC is now the real SRD 5.2 formula** (`8 + proficiency +
86
+ spellcasting-ability modifier`) for PC caster spells, replacing a flat
87
+ Avrae-era approximation — the campaign's widest-blast-radius change. Any host
88
+ casting a PC save-DC spell sees a different, SRD-correct DC. Item casts and
89
+ monster casters are unchanged.
90
+ - **Sneak Attack rider** — a qualifying finesse/ranged rogue hit now deals the
91
+ `@scale`-resolved extra damage, doubled on a critical hit.
92
+ - **Passive-projection behavioral flips** — Rage damage resistance now halves
93
+ matching damage; an immune condition never attaches; a matching damage
94
+ vulnerability doubles the hit; movement-mode / walk-speed bonuses (Fast
95
+ Movement, Roving) now fold onto `base_speed`.
96
+ - **Monster behavior** — fleeing monsters now retreat away from their nearest
97
+ threat instead of standing still; a mixed melee+ranged multiattack now fires
98
+ the weapon that fits the current distance instead of always the first-listed
99
+ one; monsters Dash to close an out-of-reach movement gap.
100
+ - **Reactions & action economy** — a countered `cast_spell` does **not** expend
101
+ its spell slot; a reaction-applied 1-round buff (Shield's +5 AC) now survives
102
+ until the owner's next turn; `"disengage"` is now a real turn-non-ending
103
+ action; PCs moving out of a monster's reach now provoke opportunity attacks.
104
+ - **Active-effect changes** — `multiply` / `upgrade` / `downgrade` change modes
105
+ now apply; the `rwak` / `msak` / `rsak` damage and `spell.dc` bonus buckets
106
+ now fold; a weapon-tagged `damage.bonus` change now reaches swing damage.
107
+ - Feature-owned `@scale` ids now resolve; the Foundry `(@scale.x)dN` dice-count
108
+ idiom now normalizes; capped features (Second Wind) are now per-rest limited.
109
+
110
+ ### Removed
111
+
112
+ - `ActionType.SHORT_REST` (`dnd5e_engine.types.intent`) — a dead, unreachable
113
+ member of the legacy dispatch enum with no handler. It was never the
114
+ live-combat intent surface (`events.py::IntentType` has no `"short_rest"`).
115
+ Hosts wanting rest resolution use the new `dnd5e_engine.rest` module.
116
+ - `rules/gambits.select_action` (with the private helpers `_PASS_ACTION` /
117
+ `_get_alive_targets` only it used) — the legacy per-profile gambit AI the live
118
+ monster-turn path never called. Every behaviour it offered now lives in
119
+ `advance_monster_turn` / `activities/monster_actions.py`. Verified caller-free;
120
+ it was not a top-level `__all__` export.
121
+ (`rules/gambits.assign_behavior_profile` was reviewed and **retained** — it is
122
+ a host-facing utility for constructing `EncounterMemberSpec.behavior_profile`
123
+ from raw monster stats, not dead code.)
124
+ ## [0.1.2]
125
+
126
+ ### Added
127
+ - `ActionType.USE_ITEM` parser-facing action member.
128
+
129
+ ## [0.1.1]
130
+
131
+ ### Added
132
+ - `LiveCombatView` — public read-model projection of live combat state.
133
+ - `get_live(handle) -> LiveCombatView` is now part of the public API (`__all__`);
134
+ it returns a point-in-time snapshot view instead of the private `_LiveCombat`.
135
+ - `roll_dice_str` — the dice-expression evaluator is now a public test seam
136
+ (renamed from the private `_roll_dice_str`).
137
+
138
+ ## [0.1.0]
139
+
140
+ First public release.
141
+
142
+ ### Added
143
+
144
+ - Pure-Python, host-agnostic D&D 5e SRD rules engine: combat orchestration,
145
+ ability checks and saving throws, and combat-scoped effects.
146
+ - SRD 5.2 (2024) content resolution against the typed-Activity corpus via
147
+ `BundledAssetLoader`, reading the canonical dataset from the
148
+ `dnd5e-srd-data` package.
149
+ - Curated public API surface (`__all__`) with a surface guard test.
150
+ - `py.typed` marker — the package ships inline type information.
151
+ - 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.3.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.3.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.3.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,9 @@ 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
+ # Lockstep floor: the charge gate reads Item.uses / Spell.foundry_uuid,
28
+ # which srd-data carries from 0.3.0 on.
29
+ "dnd5e-srd-data>=0.3.0",
28
30
  ]
29
31
 
30
32
  [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,15 @@ 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
+ recover_item_uses,
50
+ resolve_long_rest,
51
+ resolve_short_rest,
52
+ )
44
53
  from dnd5e_engine.results import EndCombatResult, StartCombatResult
45
54
  from dnd5e_engine.rules.effects import roll_dice_str
46
55
  from dnd5e_engine.spatial import cell_id, parse_cell
@@ -49,6 +58,7 @@ from dnd5e_engine.specs import (
49
58
  GridScene,
50
59
  PartyMemberSpec,
51
60
  SceneTopology,
61
+ WallSegment,
52
62
  ZoneEdge,
53
63
  )
54
64
  from dnd5e_engine.types.effects import (
@@ -76,13 +86,17 @@ __all__ = [
76
86
  "EncounterMemberSpec",
77
87
  "EndCombatResult",
78
88
  "GridScene",
89
+ "HitDicePool",
79
90
  "IntentType",
80
91
  "LiveCombatView",
81
92
  "LootDrop",
82
93
  "PartyMemberSpec",
83
94
  "PlayerIntent",
95
+ "RecoveryPeriod",
96
+ "RestOutcome",
84
97
  "SceneTopology",
85
98
  "StartCombatResult",
99
+ "WallSegment",
86
100
  "ZoneEdge",
87
101
  "advance_monster_turn",
88
102
  "build_party_member",
@@ -93,7 +107,11 @@ __all__ = [
93
107
  "make_build_spec",
94
108
  "narration_events",
95
109
  "parse_cell",
110
+ "recover_feature_uses",
111
+ "recover_item_uses",
96
112
  "resolve_check",
113
+ "resolve_long_rest",
114
+ "resolve_short_rest",
97
115
  "roll_dice_str",
98
116
  "start_combat",
99
117
  "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: