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.
- dnd5e_engine/__init__.py +94 -0
- dnd5e_engine/activities/__init__.py +13 -0
- dnd5e_engine/activities/apply.py +102 -0
- dnd5e_engine/activities/attack.py +439 -0
- dnd5e_engine/activities/build_context.py +219 -0
- dnd5e_engine/activities/cast.py +99 -0
- dnd5e_engine/activities/check.py +240 -0
- dnd5e_engine/activities/context.py +165 -0
- dnd5e_engine/activities/damage.py +156 -0
- dnd5e_engine/activities/dice.py +307 -0
- dnd5e_engine/activities/effects.py +238 -0
- dnd5e_engine/activities/formula.py +175 -0
- dnd5e_engine/activities/heal.py +72 -0
- dnd5e_engine/activities/mastery.py +209 -0
- dnd5e_engine/activities/monster_actions.py +183 -0
- dnd5e_engine/activities/passive_stats.py +143 -0
- dnd5e_engine/activities/resolver.py +90 -0
- dnd5e_engine/activities/save.py +286 -0
- dnd5e_engine/activities/save_primitive.py +197 -0
- dnd5e_engine/activities/scale.py +164 -0
- dnd5e_engine/build_party.py +132 -0
- dnd5e_engine/build_spec.py +101 -0
- dnd5e_engine/check.py +264 -0
- dnd5e_engine/death_saves.py +155 -0
- dnd5e_engine/dispatch.py +354 -0
- dnd5e_engine/event_dicts.py +37 -0
- dnd5e_engine/events.py +486 -0
- dnd5e_engine/lib_loader.py +31 -0
- dnd5e_engine/orchestrator.py +3575 -0
- dnd5e_engine/outcome.py +96 -0
- dnd5e_engine/py.typed +0 -0
- dnd5e_engine/results.py +54 -0
- dnd5e_engine/rules/__init__.py +1 -0
- dnd5e_engine/rules/_class_meta.py +14 -0
- dnd5e_engine/rules/_parsing.py +36 -0
- dnd5e_engine/rules/combat.py +581 -0
- dnd5e_engine/rules/combat_data.py +230 -0
- dnd5e_engine/rules/combat_helpers.py +414 -0
- dnd5e_engine/rules/conditions.py +414 -0
- dnd5e_engine/rules/dice.py +167 -0
- dnd5e_engine/rules/effects.py +130 -0
- dnd5e_engine/rules/equipment.py +108 -0
- dnd5e_engine/rules/gambits.py +253 -0
- dnd5e_engine/rules/resolution.py +167 -0
- dnd5e_engine/rules/skills.py +240 -0
- dnd5e_engine/rules/spells.py +151 -0
- dnd5e_engine/spatial.py +148 -0
- dnd5e_engine/specs.py +210 -0
- dnd5e_engine/testing.py +43 -0
- dnd5e_engine/types/__init__.py +24 -0
- dnd5e_engine/types/combat.py +179 -0
- dnd5e_engine/types/conditions.py +35 -0
- dnd5e_engine/types/dice.py +32 -0
- dnd5e_engine/types/effects.py +101 -0
- dnd5e_engine/types/intent.py +170 -0
- dnd5e_engine-0.1.0.dist-info/METADATA +217 -0
- dnd5e_engine-0.1.0.dist-info/RECORD +60 -0
- dnd5e_engine-0.1.0.dist-info/WHEEL +4 -0
- dnd5e_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- dnd5e_engine-0.1.0.dist-info/licenses/NOTICE +20 -0
dnd5e_engine/__init__.py
ADDED
|
@@ -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)
|