dnd5e-engine 0.1.0__py3-none-any.whl

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 (60) hide show
  1. dnd5e_engine/__init__.py +94 -0
  2. dnd5e_engine/activities/__init__.py +13 -0
  3. dnd5e_engine/activities/apply.py +102 -0
  4. dnd5e_engine/activities/attack.py +439 -0
  5. dnd5e_engine/activities/build_context.py +219 -0
  6. dnd5e_engine/activities/cast.py +99 -0
  7. dnd5e_engine/activities/check.py +240 -0
  8. dnd5e_engine/activities/context.py +165 -0
  9. dnd5e_engine/activities/damage.py +156 -0
  10. dnd5e_engine/activities/dice.py +307 -0
  11. dnd5e_engine/activities/effects.py +238 -0
  12. dnd5e_engine/activities/formula.py +175 -0
  13. dnd5e_engine/activities/heal.py +72 -0
  14. dnd5e_engine/activities/mastery.py +209 -0
  15. dnd5e_engine/activities/monster_actions.py +183 -0
  16. dnd5e_engine/activities/passive_stats.py +143 -0
  17. dnd5e_engine/activities/resolver.py +90 -0
  18. dnd5e_engine/activities/save.py +286 -0
  19. dnd5e_engine/activities/save_primitive.py +197 -0
  20. dnd5e_engine/activities/scale.py +164 -0
  21. dnd5e_engine/build_party.py +132 -0
  22. dnd5e_engine/build_spec.py +101 -0
  23. dnd5e_engine/check.py +264 -0
  24. dnd5e_engine/death_saves.py +155 -0
  25. dnd5e_engine/dispatch.py +354 -0
  26. dnd5e_engine/event_dicts.py +37 -0
  27. dnd5e_engine/events.py +486 -0
  28. dnd5e_engine/lib_loader.py +31 -0
  29. dnd5e_engine/orchestrator.py +3575 -0
  30. dnd5e_engine/outcome.py +96 -0
  31. dnd5e_engine/py.typed +0 -0
  32. dnd5e_engine/results.py +54 -0
  33. dnd5e_engine/rules/__init__.py +1 -0
  34. dnd5e_engine/rules/_class_meta.py +14 -0
  35. dnd5e_engine/rules/_parsing.py +36 -0
  36. dnd5e_engine/rules/combat.py +581 -0
  37. dnd5e_engine/rules/combat_data.py +230 -0
  38. dnd5e_engine/rules/combat_helpers.py +414 -0
  39. dnd5e_engine/rules/conditions.py +414 -0
  40. dnd5e_engine/rules/dice.py +167 -0
  41. dnd5e_engine/rules/effects.py +130 -0
  42. dnd5e_engine/rules/equipment.py +108 -0
  43. dnd5e_engine/rules/gambits.py +253 -0
  44. dnd5e_engine/rules/resolution.py +167 -0
  45. dnd5e_engine/rules/skills.py +240 -0
  46. dnd5e_engine/rules/spells.py +151 -0
  47. dnd5e_engine/spatial.py +148 -0
  48. dnd5e_engine/specs.py +210 -0
  49. dnd5e_engine/testing.py +43 -0
  50. dnd5e_engine/types/__init__.py +24 -0
  51. dnd5e_engine/types/combat.py +179 -0
  52. dnd5e_engine/types/conditions.py +35 -0
  53. dnd5e_engine/types/dice.py +32 -0
  54. dnd5e_engine/types/effects.py +101 -0
  55. dnd5e_engine/types/intent.py +170 -0
  56. dnd5e_engine-0.1.0.dist-info/METADATA +217 -0
  57. dnd5e_engine-0.1.0.dist-info/RECORD +60 -0
  58. dnd5e_engine-0.1.0.dist-info/WHEEL +4 -0
  59. dnd5e_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. dnd5e_engine-0.1.0.dist-info/licenses/NOTICE +20 -0
@@ -0,0 +1,94 @@
1
+ """dnd5e-engine — host-agnostic D&D 5e SRD rules engine.
2
+
3
+ Public API per docs/superpowers/specs/2026-05-26-dnd5e-engine-extraction-design.md.
4
+
5
+ Asset loading runs through ``dnd5e_srd_data.BundledAssetLoader`` (the typed
6
+ 2024-SRD corpus), wired via :mod:`dnd5e_engine.lib_loader`.
7
+
8
+ Deferred for later phases:
9
+ - roll (ad-hoc dice) — Phase 7
10
+ - get_state_snapshot / list_active_handles (diagnostic introspection)
11
+ — not yet implemented in orchestrator; will land alongside Phase 5 or 6.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ from dnd5e_engine.build_party import build_party_member
19
+ from dnd5e_engine.build_spec import (
20
+ AbilityScores,
21
+ CharacterBuildSpec,
22
+ CombatInstance,
23
+ make_build_spec,
24
+ )
25
+ from dnd5e_engine.check import CheckKind, CheckResult, CheckSpec, resolve_check
26
+ from dnd5e_engine.events import CombatEvent, IntentType
27
+ from dnd5e_engine.orchestrator import (
28
+ CombatHandle,
29
+ PlayerIntent,
30
+ advance_monster_turn,
31
+ end_combat,
32
+ get_actor_active_effects,
33
+ narration_events,
34
+ start_combat,
35
+ submit_player_intent,
36
+ )
37
+ from dnd5e_engine.outcome import (
38
+ CombatOutcome,
39
+ DeathRecord,
40
+ LootDrop,
41
+ )
42
+ from dnd5e_engine.results import EndCombatResult, StartCombatResult
43
+ from dnd5e_engine.spatial import cell_id, parse_cell
44
+ from dnd5e_engine.specs import (
45
+ EncounterMemberSpec,
46
+ GridScene,
47
+ PartyMemberSpec,
48
+ SceneTopology,
49
+ ZoneEdge,
50
+ )
51
+ from dnd5e_engine.types.effects import (
52
+ ActiveEffect,
53
+ ActiveEffectChange,
54
+ ActiveEffectDuration,
55
+ )
56
+ from dnd5e_engine.types.intent import ActionType
57
+
58
+ __all__ = [
59
+ "AbilityScores",
60
+ "ActionType",
61
+ "ActiveEffect",
62
+ "ActiveEffectChange",
63
+ "ActiveEffectDuration",
64
+ "CharacterBuildSpec",
65
+ "CheckKind",
66
+ "CheckResult",
67
+ "CheckSpec",
68
+ "CombatEvent",
69
+ "CombatHandle",
70
+ "CombatInstance",
71
+ "CombatOutcome",
72
+ "DeathRecord",
73
+ "EncounterMemberSpec",
74
+ "EndCombatResult",
75
+ "GridScene",
76
+ "IntentType",
77
+ "LootDrop",
78
+ "PartyMemberSpec",
79
+ "PlayerIntent",
80
+ "SceneTopology",
81
+ "StartCombatResult",
82
+ "ZoneEdge",
83
+ "advance_monster_turn",
84
+ "build_party_member",
85
+ "cell_id",
86
+ "end_combat",
87
+ "get_actor_active_effects",
88
+ "make_build_spec",
89
+ "narration_events",
90
+ "parse_cell",
91
+ "resolve_check",
92
+ "start_combat",
93
+ "submit_player_intent",
94
+ ]
@@ -0,0 +1,13 @@
1
+ """Typed Foundry Activity resolver.
2
+
3
+ Parallel to the Avrae-IR ``effects/`` package: consumes the typed
4
+ ``dnd5e_srd_data`` ``Activity`` discriminated union and emits the engine's
5
+ existing ``CombatEvent`` union via an ``event_emitter`` callback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .context import ActivityResolutionContext
11
+ from .resolver import resolve_activity
12
+
13
+ __all__ = ["ActivityResolutionContext", "resolve_activity"]
@@ -0,0 +1,102 @@
1
+ """Damage application for the Activity resolver — partition by type, apply
2
+ target-side vulnerability / resistance / immunity, emit ``DamageApplied``.
3
+
4
+ MIRRORS, does not import from, ``effects/damage.py``:
5
+
6
+ * Apply order is vulnerability ×2 → resistance //2 (integer floor) → immunity ⇒ 0,
7
+ matching ``_apply_resistance`` (Avrae's ``do_resistances`` order). An ``"all"``
8
+ wildcard is honored in each list (SRD §Conditions/Petrified emits "all").
9
+ * ``is_overkill`` mirrors ``effects/damage.py:192`` — ``final_amount >
10
+ target.hp_current`` (strictly greater).
11
+ * The ``DamageApplied`` event is emitted UNCONDITIONALLY after applying
12
+ modifiers, exactly as ``effects/damage.py`` does: an immune type yields
13
+ ``DamageApplied(amount=0)``, never a suppressed event.
14
+
15
+ Modifier sources differ from the effects path. ``effects/damage.py`` reads only
16
+ the sidecar (``_read_passive_modifiers``); the Activity resolver MERGES the
17
+ static ``Combatant`` lists (``damage_resistances`` / ``damage_immunities``) with
18
+ the sidecar lists at ``ctx.passive_damage_modifiers[entity_id]``. Vulnerabilities
19
+ have no static field on ``Combatant`` and come ONLY from the sidecar.
20
+
21
+ Unknown damage types (outside the SRD 13-type set) are logged with the
22
+ ``damage_type_invalid`` marker and skipped — the rolled dict is keyed by free
23
+ strings supplied upstream, so an unrecognized key must be loud-but-non-fatal,
24
+ not silently dropped (and not raised: a single bad key must not abort the whole
25
+ multi-type application).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from typing import TYPE_CHECKING, Final, cast, get_args
32
+
33
+ from dnd5e_engine.events import DamageApplied, DamageType
34
+
35
+ if TYPE_CHECKING:
36
+ from dnd5e_engine.activities.context import ActivityResolutionContext
37
+ from dnd5e_engine.types.combat import Combatant
38
+
39
+ _LOGGER = logging.getLogger(__name__)
40
+
41
+ # SRD 5.1 §Damage Types — the closed 13-type set, sourced from the DamageType
42
+ # Literal so the two never drift. Mirrors ``effects/damage.py:120``.
43
+ _SRD_DAMAGE_TYPES: Final[frozenset[str]] = frozenset(get_args(DamageType))
44
+
45
+
46
+ def apply_damage(
47
+ target: Combatant,
48
+ rolled_by_type: dict[str, int],
49
+ ctx: ActivityResolutionContext,
50
+ ) -> None:
51
+ """Apply a per-damage-type rolled amount to ``target`` and emit one
52
+ ``DamageApplied`` per valid type.
53
+
54
+ For each ``(damage_type, amount)``: validate the type against the SRD set
55
+ (skip + log ``damage_type_invalid`` on miss), merge the static ``Combatant``
56
+ resist/immune lists with the sidecar resist/immune/vuln lists, apply
57
+ vuln→resist→immune, compute ``is_overkill``, and emit ``DamageApplied``.
58
+ """
59
+ sidecar = ctx.passive_damage_modifiers.get(target.entity_id, {})
60
+ resistances = set(target.damage_resistances) | set(sidecar.get("resistances", ()))
61
+ immunities = set(target.damage_immunities) | set(sidecar.get("immunities", ()))
62
+ vulnerabilities = set(sidecar.get("vulnerabilities", ()))
63
+
64
+ for damage_type_str, amount in rolled_by_type.items():
65
+ if damage_type_str not in _SRD_DAMAGE_TYPES:
66
+ _LOGGER.warning(
67
+ "damage_type_invalid damage_type=%s target_id=%s",
68
+ damage_type_str,
69
+ target.entity_id,
70
+ )
71
+ continue
72
+ srd_type = cast(DamageType, damage_type_str)
73
+ final_amount = _apply_modifiers(amount, srd_type, resistances, immunities, vulnerabilities)
74
+ ctx.event_emitter(
75
+ DamageApplied(
76
+ target_id=target.entity_id,
77
+ amount=final_amount,
78
+ damage_type=srd_type,
79
+ is_overkill=final_amount > target.hp_current,
80
+ )
81
+ )
82
+
83
+
84
+ def _apply_modifiers(
85
+ amount: int,
86
+ damage_type: DamageType,
87
+ resistances: set[str],
88
+ immunities: set[str],
89
+ vulnerabilities: set[str],
90
+ ) -> int:
91
+ """Apply vuln (×2) → resist (//2 floor) → immune (⇒0) in Avrae order.
92
+
93
+ Mirrors ``effects/damage.py:_apply_resistance``. The ``"all"`` wildcard is
94
+ honored in each list (SRD §Conditions/Petrified resistance-to-all).
95
+ """
96
+ if damage_type in vulnerabilities or "all" in vulnerabilities:
97
+ amount *= 2
98
+ if damage_type in resistances or "all" in resistances:
99
+ amount //= 2
100
+ if damage_type in immunities or "all" in immunities:
101
+ amount = 0
102
+ return amount
@@ -0,0 +1,439 @@
1
+ """``attack`` kind handler for the Activity resolver.
2
+
3
+ A Foundry ``AttackActivity`` (``attack-data.mjs``) rolls a d20 attack vs the
4
+ target's AC; on a hit it rolls the weapon's base damage (when
5
+ ``damage.include_base``) plus the activity's own ``damage.parts``, doubling the
6
+ dice and adding ``damage.critical.bonus`` on a crit. Canonical SRD 5.2 examples:
7
+ Fire Bolt (a ranged spell attack, single ``1d10`` fire activity part, no base
8
+ weapon), the Longsword (base ``1d8`` slashing, no activity parts), and the Mace
9
+ of Smiting (+1 weapon, base ``1d6`` bludgeoning, ``damage.critical.bonus == "7"``).
10
+
11
+ MIRRORS, does not import from, ``effects/attack.py`` + ``effects/damage.py``:
12
+
13
+ * The natural-d20 roll honors ``ctx.variables["force_d20"]`` (the test seam),
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``.
20
+ * Hit / crit / miss mirrors ``effects/attack.py:_resolve_hit_outcome``: natural
21
+ 20 → auto crit+hit, natural 1 → auto miss, else ``total >= AC`` (SRD §Rolling
22
+ 1 or 20 / §Making an Attack). The crit threshold is ``attack.critical.threshold
23
+ or 20``.
24
+ * Crit dice doubling + the modifier-once rule run through
25
+ ``dice.roll_damage_part(crit=...)`` (SRD §Critical Hits). The activity-level
26
+ ``damage.critical.bonus`` is a flat formula added ONCE on a crit, assigned to
27
+ the first resolved damage type, mirroring ``effects/damage.py`` /
28
+ ``activities/damage.py``.
29
+
30
+ Attack-bonus model (Foundry-structural / SRD-2024 ground truth):
31
+
32
+ * ``attack.flat`` True → the parsed ``attack.bonus`` formula ALONE — no ability
33
+ mod, no proficiency, no weapon bonus (Foundry's flat-attack escape hatch).
34
+ * Otherwise → governing ability mod + proficiency (when ``ctx.is_proficient_attack``)
35
+ + parsed ``attack.bonus`` formula + the weapon's ``magical_bonus`` (a +N weapon
36
+ adds N to the attack roll). The governing ability is ``attack.ability`` when set;
37
+ else the weapon's SRD default (melee non-finesse → STR, ranged → DEX, finesse →
38
+ the better of STR/DEX) when a weapon is supplied; else the caster's spellcasting
39
+ ability for a spell attack (Foundry stores ``""`` and resolves the default at
40
+ runtime — see ``_governing_ability`` / ``_weapon_default_ability``).
41
+
42
+ Base weapon damage (``damage.include_base`` with a weapon supplied): each
43
+ ``Weapon.damage_parts`` entry is rolled (crit-doubled on a crit) keyed by its
44
+ ``damage_type``; the governing ability mod AND the weapon's ``magical_bonus`` are
45
+ added to the FIRST weapon damage part (Foundry folds ``@mod`` into the first
46
+ weapon damage term, and a +N weapon adds N to damage as well as to-hit — SRD
47
+ §Magic Weapons). The weapon ``DamagePart.dice`` is a bare ``"1d8"`` with no mod
48
+ baked in, so the mod is added here, not double-counted.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import logging
54
+ from collections import defaultdict
55
+ from typing import TYPE_CHECKING
56
+
57
+ from dnd5e_srd_data.schema.item import WeaponProperty
58
+
59
+ from dnd5e_engine.activities.apply import apply_damage
60
+ from dnd5e_engine.activities.dice import roll_damage_part, roll_expr
61
+ from dnd5e_engine.activities.effects import apply_activity_effects
62
+ from dnd5e_engine.activities.formula import resolve_damage_block, resolve_roll_data
63
+ from dnd5e_engine.activities.mastery import apply_mastery_on_hit, apply_mastery_on_miss
64
+ from dnd5e_engine.events import AdvantageMode, AttackRolled
65
+
66
+ if TYPE_CHECKING:
67
+ from dnd5e_srd_data.schema.common import AttackActivity, DamagePartBlock
68
+ from dnd5e_srd_data.schema.item import Weapon
69
+
70
+ from dnd5e_engine.types.combat import Combatant
71
+
72
+ from .context import ActivityResolutionContext
73
+
74
+ _LOGGER = logging.getLogger(__name__)
75
+
76
+ # In-crit signal key consumed by the shared dice helper's crit path — the same
77
+ # convention ``activities/damage.py`` reads. Scoped to a single target+call here.
78
+ _IN_CRIT = "in_crit"
79
+
80
+
81
+ def resolve_attack(
82
+ activity: AttackActivity,
83
+ ctx: ActivityResolutionContext,
84
+ *,
85
+ weapon: Weapon | None = None,
86
+ ) -> None:
87
+ """Roll an attack vs each target and apply on-hit damage.
88
+
89
+ For each target: compute the attack bonus once (it does not vary per target),
90
+ roll the natural d20, derive hit/crit, emit ``AttackRolled``, and on a hit roll
91
+ + apply the base weapon damage and the activity damage parts (crit-doubled, with
92
+ the activity crit bonus on a crit), then fire the activity's effect riders
93
+ (``EffectApplied`` then ``ConditionApplied``). Foundry applies attack riders on
94
+ a HIT only — a miss applies no rider.
95
+ """
96
+ governing_ability = _governing_ability(activity, ctx, weapon)
97
+ attack_bonus = _attack_bonus(activity, ctx, weapon, governing_ability)
98
+ cast_level = ctx.slot_level or ctx.base_spell_level or 0
99
+ # SRD §Bless / §Bane apply a signed d4 to the affected creature's OWN attack
100
+ # rolls (keyed on the attacker). Rolled once per attack so each swing draws a
101
+ # fresh d4 in the seeded stream — mirrors save_primitive's passive_save_bonus.
102
+ attack_bonus_expr = ctx.passive_attack_bonus.get(ctx.caster.entity_id)
103
+
104
+ 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
+ mode: AdvantageMode = "normal"
110
+ natural = _roll_natural_d20(ctx, mode, target_index=index)
111
+ total = natural + attack_bonus
112
+ if attack_bonus_expr:
113
+ total += roll_expr(attack_bonus_expr, ctx.rng)
114
+ is_crit, is_hit = _resolve_hit_outcome(natural, total, target.ac, activity)
115
+
116
+ ctx.event_emitter(
117
+ AttackRolled(
118
+ attacker_id=ctx.caster.entity_id,
119
+ target_id=target.entity_id,
120
+ roll_total=total,
121
+ advantage=mode,
122
+ is_crit=is_crit,
123
+ is_hit=is_hit,
124
+ is_opportunity_attack=False,
125
+ )
126
+ )
127
+
128
+ if is_hit:
129
+ _apply_on_hit_damage(activity, ctx, target, weapon, governing_ability, is_crit=is_crit)
130
+ apply_mastery_on_hit(weapon, ctx, target, governing_ability)
131
+ apply_activity_effects(
132
+ activity, ctx, target, save_succeeded=None, cast_level=cast_level
133
+ )
134
+ else:
135
+ apply_mastery_on_miss(weapon, ctx, target, governing_ability)
136
+
137
+
138
+ # ── attack-bonus resolution ──────────────────────────────────────────────────
139
+
140
+
141
+ def _governing_ability(
142
+ activity: AttackActivity, ctx: ActivityResolutionContext, weapon: Weapon | None
143
+ ) -> str | None:
144
+ """The ability that governs the attack roll and base weapon damage.
145
+
146
+ Resolution order (Foundry stores ``""`` and resolves the default at runtime):
147
+
148
+ 1. ``attack.ability`` when set (non-empty) → use it verbatim.
149
+ 2. else if a ``weapon`` is supplied → the weapon's SRD default ability
150
+ (``_weapon_default_ability``): a melee non-finesse weapon uses STR, a
151
+ ranged weapon uses DEX, and a finesse weapon uses whichever of STR/DEX
152
+ has the higher modifier.
153
+ 3. else (a spell attack with no weapon) → the caster's spellcasting ability.
154
+
155
+ ``None`` only when neither a weapon nor a spellcasting ability is available
156
+ (a flat attack needs no ability and simply contributes a +0 mod).
157
+ """
158
+ if activity.attack.ability:
159
+ return activity.attack.ability
160
+ if weapon is not None:
161
+ return _weapon_default_ability(weapon, ctx)
162
+ return ctx.spellcasting_ability
163
+
164
+
165
+ # Foundry ``weapon_category`` values that are ranged (DEX-governed by default).
166
+ _RANGED_CATEGORIES = frozenset({"simple_ranged", "martial_ranged"})
167
+
168
+
169
+ def _is_melee_weapon(weapon: Weapon | None) -> bool:
170
+ """True iff ``weapon`` is a melee weapon (Foundry mwak scope).
171
+
172
+ A melee weapon attack is the scope of ``system.bonuses.mwak.damage`` (Rage's
173
+ Rage Damage). A spell attack (no weapon) and a ranged weapon are excluded.
174
+ """
175
+ return weapon is not None and weapon.weapon_category not in _RANGED_CATEGORIES
176
+
177
+
178
+ def _weapon_default_ability(weapon: Weapon, ctx: ActivityResolutionContext) -> str:
179
+ """SRD default attack/damage ability for a weapon with no explicit ability.
180
+
181
+ * ranged weapon (``weapon_category`` in the ranged set) → DEX.
182
+ * finesse weapon (the ``finesse`` :class:`WeaponProperty`) → the better of the
183
+ caster's STR/DEX modifier (SRD §Finesse: the wielder chooses).
184
+ * otherwise (melee non-finesse) → STR.
185
+
186
+ A weapon that is both ranged AND finesse (none in the SRD corpus, but the
187
+ schema permits it) takes the finesse better-of branch, matching SRD intent
188
+ that finesse always grants the STR/DEX choice.
189
+ """
190
+ if WeaponProperty.FINESSE in weapon.properties:
191
+ return "str" if ctx.ability_mod("str") >= ctx.ability_mod("dex") else "dex"
192
+ if weapon.weapon_category in _RANGED_CATEGORIES:
193
+ return "dex"
194
+ return "str"
195
+
196
+
197
+ def _attack_bonus(
198
+ activity: AttackActivity,
199
+ ctx: ActivityResolutionContext,
200
+ weapon: Weapon | None,
201
+ governing_ability: str | None,
202
+ ) -> int:
203
+ """Compute the to-hit modifier added to the natural d20.
204
+
205
+ Flat attacks use the parsed ``attack.bonus`` formula alone. Otherwise the
206
+ governing ability mod, proficiency (when proficient), the parsed
207
+ ``attack.bonus`` formula, and the weapon's ``magical_bonus`` are summed.
208
+
209
+ A cast wrapper's fixed challenge override (``ctx.attack_bonus_override``)
210
+ bypasses all of that — a scroll/item to-hit (Circlet of Blasting +5) is used
211
+ verbatim, since the item carries its own attack bonus, not the wielder's.
212
+ """
213
+ if ctx.attack_bonus_override is not None:
214
+ return ctx.attack_bonus_override
215
+ flat_formula = _resolve_flat_bonus(activity, ctx, governing_ability)
216
+ if activity.attack.flat:
217
+ return flat_formula
218
+
219
+ bonus = flat_formula
220
+ if governing_ability is not None:
221
+ bonus += ctx.ability_mod(governing_ability)
222
+ if ctx.is_proficient_attack:
223
+ bonus += ctx.caster_proficiency_bonus
224
+ if weapon is not None:
225
+ bonus += weapon.magical_bonus
226
+ return bonus
227
+
228
+
229
+ def _resolve_flat_bonus(
230
+ activity: AttackActivity, ctx: ActivityResolutionContext, governing_ability: str | None
231
+ ) -> int:
232
+ """Resolve roll-data tokens in ``attack.bonus`` and fold it to an int.
233
+
234
+ The bonus is a flat formula (canonical attacks ship it empty; magic weapons
235
+ like the Mace of Smiting ship ``"2"``). It may carry ``@``-tokens, resolved
236
+ against the governing ability before the seeded eval.
237
+ """
238
+ formula = activity.attack.bonus
239
+ if not formula:
240
+ return 0
241
+ resolved = resolve_roll_data(formula, ctx, ability=governing_ability)
242
+ return roll_expr(resolved, ctx.rng)
243
+
244
+
245
+ # ── natural d20 + hit/crit/miss ──────────────────────────────────────────────
246
+
247
+
248
+ def _roll_natural_d20(
249
+ ctx: ActivityResolutionContext, mode: AdvantageMode, *, target_index: int = 0
250
+ ) -> int:
251
+ """Natural-d20 outcome, honoring ``variables["force_d20"]`` for determinism.
252
+
253
+ The ``force_d20`` seam is a TEST hook scoped to the FIRST target only
254
+ (``target_index == 0``); every other target rolls live off ``ctx.rng`` so a
255
+ forced value never silently reuses one kept d20 across a multi-target attack.
256
+ Mirrors ``effects/attack.py:_roll_natural_d20`` for the live path: advantage
257
+ keeps the higher of two ``ctx.rng`` rolls, disadvantage the lower, normal one.
258
+ """
259
+ forced = ctx.variables.get("force_d20")
260
+ if forced is not None and target_index == 0:
261
+ return int(forced)
262
+ if mode == "advantage":
263
+ return max(ctx.rng.randint(1, 20), ctx.rng.randint(1, 20))
264
+ if mode == "disadvantage":
265
+ return min(ctx.rng.randint(1, 20), ctx.rng.randint(1, 20))
266
+ return ctx.rng.randint(1, 20)
267
+
268
+
269
+ def _resolve_hit_outcome(
270
+ natural: int, total: int, target_ac: int, activity: AttackActivity
271
+ ) -> tuple[bool, bool]:
272
+ """Derive ``(is_crit, is_hit)`` per SRD §Rolling 1 or 20 / §Making an Attack.
273
+
274
+ Precedence: a natural 1 is ALWAYS an auto-miss (and never a crit), even when a
275
+ degenerate ``critical.threshold`` of 1 would otherwise classify it as a crit —
276
+ the SRD nat-1 rule wins. Then natural ≥ crit threshold
277
+ (``attack.critical.threshold or 20``) → crit + hit; else ``total >= AC``.
278
+ """
279
+ if natural == 1:
280
+ return False, False
281
+ threshold = activity.attack.critical.threshold or 20
282
+ if natural >= threshold:
283
+ return True, True
284
+ return False, total >= target_ac
285
+
286
+
287
+ # ── on-hit damage ────────────────────────────────────────────────────────────
288
+
289
+
290
+ def _apply_on_hit_damage(
291
+ activity: AttackActivity,
292
+ ctx: ActivityResolutionContext,
293
+ target: Combatant,
294
+ weapon: Weapon | None,
295
+ governing_ability: str | None,
296
+ *,
297
+ is_crit: bool,
298
+ ) -> None:
299
+ """Roll base weapon damage + activity parts for one hit target and apply.
300
+
301
+ Sets ``variables["in_crit"]`` for the duration of this target's damage rolls so
302
+ the shared dice helper doubles dice on a crit, then restores the prior value so
303
+ the signal never leaks to a sibling target or a later caller (mirrors
304
+ ``effects/attack.py:_recurse_hit`` push/pop discipline).
305
+ """
306
+ previous = ctx.variables.get(_IN_CRIT)
307
+ if is_crit:
308
+ ctx.variables[_IN_CRIT] = 1
309
+ try:
310
+ by_type: dict[str, int] = defaultdict(int)
311
+ first_type: str | None = None
312
+
313
+ if activity.damage.include_base and weapon is not None:
314
+ first_type = _roll_base_weapon_damage(
315
+ weapon, ctx, by_type, governing_ability, is_crit=is_crit
316
+ )
317
+
318
+ for part in activity.damage.parts:
319
+ damage_type = _part_type(part, activity.id, ctx)
320
+ if damage_type is None:
321
+ continue
322
+ if first_type is None:
323
+ first_type = damage_type
324
+ resolved = resolve_damage_block(part, ctx, ability=governing_ability)
325
+ by_type[damage_type] += roll_damage_part(
326
+ resolved,
327
+ ctx.rng,
328
+ crit=is_crit,
329
+ character_level=ctx.caster_level,
330
+ slot_level=ctx.slot_level,
331
+ base_level=ctx.base_spell_level,
332
+ )
333
+
334
+ if is_crit and activity.damage.critical.bonus and first_type is not None:
335
+ by_type[first_type] += _resolve_critical_bonus(
336
+ activity.damage.critical.bonus, ctx, governing_ability
337
+ )
338
+
339
+ # SRD §Rage / Foundry ``system.bonuses.mwak.damage`` — a melee weapon
340
+ # attack damage bonus (Rage's +2 at L5) the caster carries as an active
341
+ # effect, folded into the ``passive_melee_damage_bonus`` sidecar by the
342
+ # orchestrator. Add it once to the first damage type, MELEE WEAPON only
343
+ # (a weapon present that is not a ranged category) — never ranged or
344
+ # spell. Rolled through ``ctx.rng`` so a dice-valued bonus lands in the
345
+ # same seed stream (numeric bonuses are seed-inert).
346
+ if first_type is not None and _is_melee_weapon(weapon):
347
+ melee_bonus_expr = ctx.passive_melee_damage_bonus.get(ctx.caster.entity_id)
348
+ if melee_bonus_expr:
349
+ by_type[first_type] += roll_expr(melee_bonus_expr, ctx.rng)
350
+
351
+ apply_damage(target, dict(by_type), ctx)
352
+ finally:
353
+ if is_crit:
354
+ if previous is None:
355
+ ctx.variables.pop(_IN_CRIT, None)
356
+ else:
357
+ ctx.variables[_IN_CRIT] = previous
358
+
359
+
360
+ def _roll_base_weapon_damage(
361
+ weapon: Weapon,
362
+ ctx: ActivityResolutionContext,
363
+ by_type: dict[str, int],
364
+ governing_ability: str | None,
365
+ *,
366
+ is_crit: bool,
367
+ ) -> str | None:
368
+ """Roll the weapon's base ``damage_parts`` into ``by_type``; return first type.
369
+
370
+ Each part's bare ``.dice`` is rolled (crit-doubled). The governing ability mod
371
+ and the weapon's ``magical_bonus`` are added once, to the FIRST part — Foundry
372
+ folds ``@mod`` into the first weapon damage term, and a +N weapon adds N to
373
+ damage (SRD §Magic Weapons). The weapon dice carry no mod, so adding here does
374
+ not double-count.
375
+ """
376
+ first_type: str | None = None
377
+ flat_addition = weapon.magical_bonus
378
+ if governing_ability is not None:
379
+ flat_addition += ctx.ability_mod(governing_ability)
380
+
381
+ for index, part in enumerate(weapon.damage_parts):
382
+ rolled = roll_damage_part(part, ctx.rng, crit=is_crit)
383
+ if index == 0:
384
+ rolled += flat_addition
385
+ first_type = part.damage_type
386
+ by_type[part.damage_type] += rolled
387
+ return first_type
388
+
389
+
390
+ def _part_type(
391
+ part: DamagePartBlock, activity_id: str, ctx: ActivityResolutionContext
392
+ ) -> str | None:
393
+ """The single damage type an activity part applies as.
394
+
395
+ Mirrors ``activities/damage.py:_part_type``: a single-type part applies as that
396
+ type; a multi-type part resolves the player's ``ctx.damage_type_choices`` (or
397
+ defaults to the first, logged at INFO); a typeless part is logged and skipped.
398
+ """
399
+ if not part.types:
400
+ _LOGGER.warning(
401
+ "damage_part_untyped activity_id=%s denomination=%s number=%s",
402
+ activity_id,
403
+ part.denomination,
404
+ part.number,
405
+ )
406
+ return None
407
+ if len(part.types) == 1:
408
+ return part.types[0]
409
+
410
+ chosen = ctx.damage_type_choices.get(activity_id)
411
+ if chosen is not None and chosen in part.types:
412
+ return chosen
413
+ if chosen is not None:
414
+ _LOGGER.warning(
415
+ "damage_type_choice_invalid activity_id=%s chose=%s options=%s",
416
+ activity_id,
417
+ chosen,
418
+ part.types,
419
+ )
420
+ _LOGGER.info(
421
+ "damage_type_defaulted activity_id=%s chose=%s options=%s",
422
+ activity_id,
423
+ part.types[0],
424
+ part.types,
425
+ )
426
+ return part.types[0]
427
+
428
+
429
+ def _resolve_critical_bonus(
430
+ bonus: str, ctx: ActivityResolutionContext, governing_ability: str | None
431
+ ) -> int:
432
+ """Resolve @-tokens in ``damage.critical.bonus`` and fold it to an int.
433
+
434
+ The crit bonus is a flat formula (the Mace of Smiting ships ``"7"``); it folds
435
+ via the seeded rng for parity with the dice path even though the SRD corpus
436
+ carries no dice in it.
437
+ """
438
+ resolved = resolve_roll_data(bonus, ctx, ability=governing_ability)
439
+ return roll_expr(resolved, ctx.rng)