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