dnd5e-engine 0.2.0__tar.gz → 0.3.1__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 (142) hide show
  1. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/CHANGELOG.md +34 -0
  2. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/PKG-INFO +3 -3
  3. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/pyproject.toml +4 -2
  4. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/__init__.py +5 -1
  5. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/build_context.py +10 -0
  6. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/cast.py +37 -8
  7. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/context.py +5 -0
  8. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/events.py +7 -0
  9. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/lib_loader.py +9 -0
  10. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/orchestrator.py +363 -22
  11. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rest.py +90 -27
  12. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/specs.py +3 -3
  13. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/views.py +5 -0
  14. dnd5e_engine-0.3.1/tests/activities/test_cast_level_override.py +121 -0
  15. dnd5e_engine-0.3.1/tests/e2e/test_c11_item_charges.py +276 -0
  16. dnd5e_engine-0.3.1/tests/test_lib_loader_configure.py +20 -0
  17. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_live_combat_view.py +28 -0
  18. dnd5e_engine-0.3.1/tests/test_orchestrator_item_charges.py +906 -0
  19. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_public_api_surface.py +2 -0
  20. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_rest.py +46 -0
  21. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/.gitignore +0 -0
  22. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/LICENSE +0 -0
  23. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/Makefile +0 -0
  24. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/NOTICE +0 -0
  25. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/README.md +0 -0
  26. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/scripts/_smoke_grid_combat.py +0 -0
  27. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/scripts/smoke_clean_install.sh +0 -0
  28. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/__init__.py +0 -0
  29. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/apply.py +0 -0
  30. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/attack.py +0 -0
  31. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/check.py +0 -0
  32. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/damage.py +0 -0
  33. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/dice.py +0 -0
  34. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/effects.py +0 -0
  35. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/formula.py +0 -0
  36. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/heal.py +0 -0
  37. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/mastery.py +0 -0
  38. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/monster_actions.py +0 -0
  39. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/passive_stats.py +0 -0
  40. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/resolver.py +0 -0
  41. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/save.py +0 -0
  42. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/save_primitive.py +0 -0
  43. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/activities/scale.py +0 -0
  44. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/build_party.py +0 -0
  45. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/build_spec.py +0 -0
  46. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/check.py +0 -0
  47. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/death_saves.py +0 -0
  48. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/dispatch.py +0 -0
  49. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/event_dicts.py +0 -0
  50. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/outcome.py +0 -0
  51. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/py.typed +0 -0
  52. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/results.py +0 -0
  53. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/__init__.py +0 -0
  54. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/_class_meta.py +0 -0
  55. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/_parsing.py +0 -0
  56. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/combat.py +0 -0
  57. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/combat_data.py +0 -0
  58. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/combat_helpers.py +0 -0
  59. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/conditions.py +0 -0
  60. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/dice.py +0 -0
  61. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/effects.py +0 -0
  62. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/equipment.py +0 -0
  63. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/gambits.py +0 -0
  64. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/resolution.py +0 -0
  65. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/skills.py +0 -0
  66. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/rules/spells.py +0 -0
  67. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/spatial.py +0 -0
  68. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/testing.py +0 -0
  69. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/__init__.py +0 -0
  70. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/combat.py +0 -0
  71. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/conditions.py +0 -0
  72. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/dice.py +0 -0
  73. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/effects.py +0 -0
  74. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/src/dnd5e_engine/types/intent.py +0 -0
  75. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/__init__.py +0 -0
  76. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/activities/__init__.py +0 -0
  77. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/activities/test_build_context.py +0 -0
  78. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/activities/test_monster_actions.py +0 -0
  79. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/__init__.py +0 -0
  80. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/conftest.py +0 -0
  81. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/harness.py +0 -0
  82. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c00_harness_sanity.py +0 -0
  83. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c01_dataset_fixes.py +0 -0
  84. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c02_small_mechanics.py +0 -0
  85. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c03_effect_modes.py +0 -0
  86. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c04_caster_plumbing.py +0 -0
  87. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c05_spatial.py +0 -0
  88. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c06_reactions.py +0 -0
  89. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c07_sneak_and_repertoire.py +0 -0
  90. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c08_passive_projection.py +0 -0
  91. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c09_rest.py +0 -0
  92. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/e2e/test_c10_monster_behavior.py +0 -0
  93. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_active_effect_resistance_fold.py +0 -0
  94. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_active_effect_schema.py +0 -0
  95. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_apply_changes_to_check.py +0 -0
  96. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_apply_modifiers.py +0 -0
  97. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_build_context_abilities.py +0 -0
  98. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_build_party.py +0 -0
  99. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_build_party_passives.py +0 -0
  100. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_combat_bucket_keys.py +0 -0
  101. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_combat_flag_advantage.py +0 -0
  102. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_concentration_drop_effect_name.py +0 -0
  103. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_concentration_repeat_save_identity.py +0 -0
  104. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_cover_bonus_sidecar.py +0 -0
  105. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_coverage_backfill_death_saves.py +0 -0
  106. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_coverage_backfill_seam_errors.py +0 -0
  107. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_duration_tick_at_turn_end.py +0 -0
  108. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_effects_builder_internals.py +0 -0
  109. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_enchantment_sidecar_projection.py +0 -0
  110. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_end_combat_snapshot.py +0 -0
  111. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_expire_clears_active_conditions.py +0 -0
  112. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_feature_save_dc_no_spell_override.py +0 -0
  113. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_get_actor_active_effects.py +0 -0
  114. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_grid_topology.py +0 -0
  115. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_iter11_fixes.py +0 -0
  116. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_iter13_fixes.py +0 -0
  117. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_iter5_fixes.py +0 -0
  118. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_lib_loader.py +0 -0
  119. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_monster_dash.py +0 -0
  120. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_monster_flee_retreat.py +0 -0
  121. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_orchestrator_gating_typed.py +0 -0
  122. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_orchestrator_grid_combat.py +0 -0
  123. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_orchestrator_monster_typed.py +0 -0
  124. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_orchestrator_move_mark_typed.py +0 -0
  125. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_orchestrator_pc_resolution_typed.py +0 -0
  126. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_party_member_reach_ft.py +0 -0
  127. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_passive_stats.py +0 -0
  128. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_phase6_public_surface.py +0 -0
  129. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_rage_second_wind_e2e.py +0 -0
  130. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_reaction_queue.py +0 -0
  131. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_resolve_check.py +0 -0
  132. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_resolve_check_flag_advantage.py +0 -0
  133. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_resolve_check_specific_buckets.py +0 -0
  134. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_rules_smoke.py +0 -0
  135. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_scale_resolver.py +0 -0
  136. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_smoke.py +0 -0
  137. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_sneak_attack_resolver.py +0 -0
  138. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_start_combat_active_effects.py +0 -0
  139. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_start_combat_seeds_lifecycle.py +0 -0
  140. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_types_smoke.py +0 -0
  141. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_use_feature_intent.py +0 -0
  142. {dnd5e_engine-0.2.0 → dnd5e_engine-0.3.1}/tests/test_weapon_damage_bonus_sidecar.py +0 -0
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
44
  ## [0.2.0]
11
45
 
12
46
  Lockstep release with `dnd5e-srd-data` 0.2.0 — the outcome of the gap-closing
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dnd5e-engine
3
- Version: 0.2.0
3
+ Version: 0.3.1
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.2.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.2.0"
7
+ version = "0.3.1"
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.2.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.2.0"
16
+ __version__ = "0.3.1"
17
17
 
18
18
  from dnd5e_engine.build_party import build_party_member
19
19
  from dnd5e_engine.build_spec import (
@@ -24,6 +24,7 @@ from dnd5e_engine.build_spec import (
24
24
  )
25
25
  from dnd5e_engine.check import CheckKind, CheckResult, CheckSpec, resolve_check
26
26
  from dnd5e_engine.events import CombatEvent, IntentType
27
+ from dnd5e_engine.lib_loader import configure_lib_loader
27
28
  from dnd5e_engine.orchestrator import (
28
29
  CombatHandle,
29
30
  LiveCombatView,
@@ -46,6 +47,7 @@ from dnd5e_engine.rest import (
46
47
  RecoveryPeriod,
47
48
  RestOutcome,
48
49
  recover_feature_uses,
50
+ recover_item_uses,
49
51
  resolve_long_rest,
50
52
  resolve_short_rest,
51
53
  )
@@ -100,6 +102,7 @@ __all__ = [
100
102
  "advance_monster_turn",
101
103
  "build_party_member",
102
104
  "cell_id",
105
+ "configure_lib_loader",
103
106
  "end_combat",
104
107
  "get_actor_active_effects",
105
108
  "get_live",
@@ -107,6 +110,7 @@ __all__ = [
107
110
  "narration_events",
108
111
  "parse_cell",
109
112
  "recover_feature_uses",
113
+ "recover_item_uses",
110
114
  "resolve_check",
111
115
  "resolve_long_rest",
112
116
  "resolve_short_rest",
@@ -147,6 +147,7 @@ def build_activity_context(
147
147
  target_cover: dict[str, str] | None = None,
148
148
  scale_values: dict[str, int | str] | None = None,
149
149
  class_levels: dict[str, int] | None = None,
150
+ cast_level_override: int | None = None,
150
151
  is_feature_invocation: bool = False,
151
152
  active_effects: Sequence[ActiveEffect] = (),
152
153
  sneak_attack_spent: dict[str, bool] | None = None,
@@ -185,6 +186,14 @@ def build_activity_context(
185
186
  So for a feature invocation the override is omitted (``None``), letting the
186
187
  save resolver fall through to ``save.dc.calculation``. The spell / item
187
188
  path keeps the blanket override.
189
+
190
+ ``cast_level_override`` passes straight through to
191
+ :class:`ActivityResolutionContext` — a ``use_item`` charges_to_spend
192
+ upcast's forced delegated-cast level (orchestrator-computed off the
193
+ charge gate's own cost accounting). ``None`` (the default) leaves
194
+ ``resolve_cast`` (``activities/cast.py``) to fall back to the wrapper
195
+ activity's own/base spell level, unchanged from before this field
196
+ existed.
188
197
  """
189
198
  mod = _caster_mod(caster)
190
199
  if caster.entity_type == "Character":
@@ -346,4 +355,5 @@ def build_activity_context(
346
355
  spell_book=spell_book,
347
356
  scale_values=scale_values or {},
348
357
  class_levels=class_levels or {},
358
+ cast_level_override=cast_level_override,
349
359
  )
@@ -26,8 +26,15 @@ honored: 42 canonical cast activities (Dragon Orb DC 18, Circlet of Blasting +5,
26
26
  …) carry a FIXED save DC or attack bonus. The handler threads the fixed value
27
27
  into the child context (``save_dc_override`` / ``attack_bonus_override``); the
28
28
  delegated save/attack handler uses it verbatim instead of the wielder's stats.
29
- Both fields are set unconditionally so a non-override cast clears any inherited
30
- stale value (a grandchild never inherits a parent scroll's DC).
29
+ A non-override cast still CLEARS the field for a NESTED cast (a grandchild
30
+ never inherits a parent scroll's DC) — but a TOP-LEVEL item wrapper (nothing
31
+ above it in ``ctx.parent_chain``) has no ``spellcasting_ability`` of its own,
32
+ so clearing unconditionally would strip the blanket flat DC / attack bonus
33
+ ``build_activity_context`` computed for the item path, and the delegated
34
+ save/attack handler would either raise (``save.py``, ``calculation ==
35
+ "spellcasting"`` with no ability) or silently drop the ability-mod term
36
+ (``attack.py``). A top-level wrapper without its own override therefore
37
+ FALLS BACK to the inherited ``ctx`` value instead of clearing.
31
38
  """
32
39
 
33
40
  from __future__ import annotations
@@ -61,7 +68,12 @@ def resolve_cast(activity: CastActivity, ctx: ActivityResolutionContext) -> None
61
68
  # A cantrip's base level is 0 (``0 <= 0 <= 9`` passes). An out-of-range
62
69
  # cast (e.g. a scroll forged at level 10, or below the spell's base) is a
63
70
  # LOUD no-op — never an absurd upcast (cure-wounds at level 10 → 20d8).
64
- cast_level = activity.spell.level if activity.spell.level is not None else spell.level
71
+ if ctx.cast_level_override is not None:
72
+ cast_level = ctx.cast_level_override
73
+ elif activity.spell.level is not None:
74
+ cast_level = activity.spell.level
75
+ else:
76
+ cast_level = spell.level
65
77
  if not (spell.level <= cast_level <= 9):
66
78
  _LOGGER.warning(
67
79
  "cast_invalid_level uuid=%s cast_level=%s base_level=%s",
@@ -78,9 +90,13 @@ def resolve_cast(activity: CastActivity, ctx: ActivityResolutionContext) -> None
78
90
  # A fixed item challenge (``spell.challenge.override``) threads a verbatim
79
91
  # save DC / attack bonus into the child so the delegated save/attack handler
80
92
  # uses the item's number, not the wielder's stats (Dragon Orb DC 18, Circlet
81
- # of Blasting +5). Both are set UNCONDITIONALLY: an override is honored for
82
- # THIS wrapper, and a non-override cast CLEARS any inherited stale value so a
83
- # grandchild never inherits a parent scroll's DC.
93
+ # of Blasting +5). An override is honored for THIS wrapper. Absent an
94
+ # override: a NESTED cast (``ctx.parent_chain`` non-empty — this wrapper is
95
+ # itself inside another cast's delegation) CLEARS the field so a grandchild
96
+ # never inherits a parent scroll's DC; a TOP-LEVEL wrapper (nothing above us
97
+ # in the chain) has no ``spellcasting_ability`` to fall through to, so it
98
+ # INHERITS ``ctx``'s value — the item-path blanket DC / attack bonus
99
+ # ``build_activity_context`` already computed for it.
84
100
  from .resolver import resolve_activity # function-local: breaks the resolver↔cast import cycle
85
101
 
86
102
  challenge = activity.spell.challenge
@@ -91,8 +107,21 @@ def resolve_cast(activity: CastActivity, ctx: ActivityResolutionContext) -> None
91
107
  base_spell_level=spell.level,
92
108
  concentration=spell.concentration,
93
109
  source_passive_effects=spell.passive_effects,
94
- save_dc_override=(challenge.save if challenge.override else None),
95
- attack_bonus_override=(challenge.attack if challenge.override else None),
110
+ save_dc_override=(
111
+ challenge.save
112
+ if challenge.override
113
+ else (ctx.save_dc_override if not ctx.parent_chain else None)
114
+ ),
115
+ attack_bonus_override=(
116
+ challenge.attack
117
+ if challenge.override
118
+ else (ctx.attack_bonus_override if not ctx.parent_chain else None)
119
+ ),
120
+ # M1: a nested cast must never inherit the item wrapper's forced cast
121
+ # level (``use_item`` charges_to_spend upcast) — only the wrapper's own
122
+ # ``CastActivity`` consumes it; the referenced spell's own activities
123
+ # cast at ``cast_level`` computed above, not the raw override again.
124
+ cast_level_override=None,
96
125
  parent_chain=(*ctx.parent_chain, uuid),
97
126
  )
98
127
  for child_activity in spell.activities:
@@ -58,6 +58,11 @@ class ActivityResolutionContext:
58
58
  # (`spell.challenge.override` + `attack`): when set, the attack handler uses
59
59
  # this verbatim to-hit instead of ability + proficiency + weapon. None otherwise.
60
60
  attack_bonus_override: int | None = None
61
+ # Cast level forced by a variable-charge item invocation (wand upcast):
62
+ # ``base activity level + extra charges``. When set, resolve_cast uses it
63
+ # verbatim (bounds-checked) instead of the activity/spell level. None for
64
+ # every non-item cast.
65
+ cast_level_override: int | None = None
61
66
  # Player-supplied damage-type choice per activity id, for parts that offer a
62
67
  # CHOICE of damage type (``DamagePartBlock.types`` with >1 entry — e.g.
63
68
  # Chromatic Orb's [acid, cold, fire, ...]). Keyed by activity id; the chosen
@@ -84,6 +84,13 @@ CastFailedReason = Literal[
84
84
  # rest. Mirrors the ``no_action_economy`` reject shape, extended from a
85
85
  # per-turn budget to a per-encounter / per-rest one.
86
86
  "no_uses_remaining",
87
+ # SRD 5.2 §Charges — a charged item invoked with fewer charges remaining
88
+ # than the activity's cost (``consumption.targets[type=itemUses]``).
89
+ "no_charges_remaining",
90
+ # A variable-charge invocation that violates the activity's
91
+ # ``consumption.scaling`` contract (not allowed, below base cost, or
92
+ # above the evaluated max). Emitted by the use_item charge gate.
93
+ "invalid_charge_spend",
87
94
  ]
88
95
 
89
96
  IntentType = Literal[
@@ -25,7 +25,16 @@ def set_lib_loader_for_tests(loader: AssetLoader | None) -> None:
25
25
  _LIB_LOADER = loader
26
26
 
27
27
 
28
+ def configure_lib_loader(loader: AssetLoader | None) -> None:
29
+ """Public host seam: install a custom AssetLoader (e.g. a homebrew
30
+ overlay). ``None`` reverts to the lazy bundled default. Hosts must
31
+ call this before start_combat; swapping mid-combat is unsupported."""
32
+ global _LIB_LOADER
33
+ _LIB_LOADER = loader
34
+
35
+
28
36
  __all__ = [
37
+ "configure_lib_loader",
29
38
  "get_lib_loader",
30
39
  "set_lib_loader_for_tests",
31
40
  ]