Migrating from 0.1.x to 0.2.0
This is the migration guide for the nat20 v0.2.0 lockstep release — the
outcome of the gap-closing campaign (the ten clusters catalogued in
specs/e2e-scenario-catalog.md). Both workspace packages (dnd5e-engine and
dnd5e-srd-data) version in lockstep from 0.1.x to 0.2.0.
Almost every change in this release is additive — new optional
spec/Combatant fields, new public names, and same-signature behavioral
corrections that only fire when the backing dataset or host input carries the
relevant trait. The clustered sections below (Cluster 2 → Cluster 10) document
those additive deltas chronologically.
The sole exception is the two dead-code removals in
Removed in 0.2.0 below — the only non-additive changes in
the release. tests/test_public_api_surface.py pins the top-level
dnd5e_engine.__all__ surface; neither removed name was part of it, so the
guarded top-level surface is unchanged and every other entry below remains
purely additive.
Removed in 0.2.0
Two confirmed-dead, unreachable names were removed. Both were verified
caller-free across src/ and tests/ before removal (no live path, no test,
and neither was re-exported from the top-level dnd5e_engine.__all__).
ActionType.SHORT_REST removed (dnd5e_engine.types.intent)
The SHORT_REST = "short_rest" member of the legacy ActionType dispatch enum
was an orphaned fossil: it is not the live-combat intent surface (that is
events.py::IntentType, which structurally has no "short_rest" member), it
had no handler, and no code in src/ or tests/ ever constructed or matched
it. A rest cannot resolve inside a live combat anyway (SRD 5.2 lists "Rolling
Initiative" as a rest interruption).
Where hosts should look instead: the standalone, zero-I/O
dnd5e_engine.rest module (added in Cluster 9 — see below), which hosts call
between combats: resolve_short_rest, resolve_long_rest,
recover_feature_uses, and their HitDicePool / RestOutcome value types.
gambits.select_action removed (dnd5e_engine.rules.gambits)
select_action (and the two private helpers _PASS_ACTION /
_get_alive_targets that only it used) was removed from rules/gambits.py and
its module __all__. It was the legacy per-profile gambit AI that the live
monster-turn path never called — Cluster 10 landed every behaviour it offered
into the live path directly (advance_monster_turn +
activities/monster_actions.py: flee via _monster_is_fleeing /
_execute_flee_retreat, and profile/range-aware attack choice via
_select_fallback_sibling).
The still-used names in the module — BehaviorProfile, GambitAction,
MonsterActionResult, parse_damage_dice, resolve_monster_action — are
unchanged. BehaviorProfile in particular remains consumed by
orchestrator.py's flee-threshold gate.
Reviewed and retained: gambits.assign_behavior_profile was considered
for removal but kept — it is a host-facing input-construction utility, not
dead code. Cluster 10 made behavior_profile load-bearing: nat20's live path
reads EncounterMemberSpec.behavior_profile, and a host computes that field's
value from raw monster stats via this has_ranged → RANGED else AGGRESSIVE
helper. It is deliberately not called by nat20's own live monster-turn path
(which reads the spec field directly), but remains a supported public helper on
the boundary.
Cluster 2 — Small mechanics
PartyMemberSpec.reach_ft (new, additive, default 5)
PartyMemberSpec (dnd5e_engine.specs) gained an optional reach_ft: int = 5
field, mirroring the existing base_speed pattern. It threads onto the live
Combatant.melee_reach_ft at start_combat() time (previously hardcoded to
its own default of 5, unreachable from the spec layer). Omitting the field
preserves prior behavior exactly.
PartyMemberSpec(
entity_id="char:hero",
...,
equipment=("glaive",),
reach_ft=10, # a Glaive's Reach property
)
Note: the primary melee attack-range gate (_weapon_attack_range_ft) already
derives reach independently from the weapon's own WeaponProperty.REACH — a
reach weapon's attack range was not blocked before this change. The gap
this closes is narrower: Combatant.melee_reach_ft itself (the field
opportunity-attack reach checks are documented to consult) is now reachable
from the boundary spec. Wiring melee_reach_ft into the opportunity-attack
cross-zone reach check is a separate, larger follow-up and remains open.
Weapon-tagged damage.bonus active effects now reach swing damage (behavioral change, same signature)
A weapon-tagged active-effect change (e.g. a +N magic weapon's
ActiveEffectChange(key="damage.bonus", mode="add", value=N, ...), tagged
flags={"applicable_action_types": ["attack"]}) previously only ever landed
on the write-side passive_weapon_damage_bonus sidecar — nothing downstream
consumed it, so the bonus never reached an attack's actual damage roll. It
now does: attack.py's on-hit damage resolution adds it once, on any weapon
swing (melee or ranged), symmetric with how the same effect already buffed
the to-hit roll via the broader passive_damage_bonus/passive_to_hit_bonus
sidecar.
Who is affected: hosts that construct ActiveEffects carrying a
weapon-tagged damage.bonus change and pass them via
start_combat(active_effects=...). A combat that previously under-counted a
magic weapon's damage contribution will now total N higher per qualifying
hit. No API shape changed — this is a same-signature behavioral correction
(the sidecar always existed on the write side; only its consumption was
missing).
Known remaining gap (not fixed in this cluster): the sibling
passive_weapon_to_hit_bonus sidecar key is written by the same fold but is
still not consumed anywhere in src/ — a weapon-tagged attack.roll.bonus
change (e.g. a +N weapon's to-hit bonus) does not yet reach the attack roll
itself. Flagged for a future cluster; out of scope here to avoid expanding
this cluster's pinned scope unilaterally.
Monsters can Dash to close a movement gap
advance_monster_turn's gambit movement loop previously only ever spent a
monster's base_speed per turn; a target farther away than that (but
reachable within a doubled Dash move) was simply unreachable — the gambit
gave up and recorded a no-op pass turn with zero movement.
The gambit now Dashes (SRD §Actions in Combat) when needed: if the shortest
path to the chosen target exceeds the monster's remaining movement but fits
within one doubled Dash move, the monster spends its Action on Dash (doubling
movement_remaining, emitting DashTaken) before walking the path.
Dash consumes the Action, so — per SRD action economy (one Action per
turn) — the monster does not also attack in a turn it Dashed; the turn's
recorded IntentSubmitted.intent_type is "dash" in that case instead of
"attack" or "pass".
Who is affected: hosts/tests asserting monster-turn event shapes for
encounters where a monster was previously unable to reach a distant target —
those turns will now show movement (and a "dash" intent) instead of a
silent no-op.
Cluster 3 — Active-effect change modes
multiply / upgrade / downgrade ActiveEffectChange modes now apply (behavioral change, same signature)
apply_changes_to_check (rules/effects.py) previously handled only
add and override modes; multiply, upgrade, and downgrade changes
were schema-accepted but silently ignored — they contributed nothing to a
check's total. All three now apply, per core-Foundry ActiveEffect
change-mode semantics:
multiply— multiplies the bucket's accumulated numeric contribution SO FAR (not the running total, which may include the d20 roll) by the change's value. E.g. a+3bucket contribution with amultiply: 2change becomes6.upgrade— raises the bucket's contribution tomax(contribution, value); never lowers it.downgrade— the mirror:min(contribution, value); never raises it.
Ordering is by ascending ActiveEffectChange.priority (default 20,
ties preserve declaration order), matching Foundry's own apply-in-
priority-order semantics.
Who is affected: hosts that construct ActiveEffects carrying
multiply/upgrade/downgrade changes and pass them to resolve_check
or any check that flows through apply_changes_to_check (CheckSpec,
combat SKILL_CHECK/SAVING_THROW dispatch). Effects that previously had
no mechanical impact now change the resolved check's modifier and total.
Not implemented — Blocked: custom mode remains a documented no-op.
Foundry itself delegates custom to host-registered callbacks; there is
no SRD or in-repo ground truth for what it should mean in a host-agnostic
engine with no callback registry. Tracked under BACKLOG.md's new
## Blocked section pending a maintainer product decision. Downstream
canonical data carries no "mode": "custom" entries today.
Cluster 4 — Caster plumbing
Real spell save DC formula (behavioral change, biggest one so far — hosts' spell DCs change)
Every PC spell/item cast previously computed its save DC from a flat
Avrae-era approximation — 8 + 2 + max(0, attack_bonus - 2) — completely
blind to the caster's class or ability scores; the override was applied
unconditionally, so the honest SRD-shaped calculation in
activities/save.py::_resolve_dc never actually ran for a spell/item cast.
The save DC (and, transitively, cast_spell's spellcasting_ability) is
now the real SRD 5.2 formula (§Spellcasting, Spell Save DC):
Spell save DC = 8 + your Proficiency Bonus + your spellcasting ability modifier
orchestrator.py's cast_spell intent path now reads the caster's real
class → spellcasting-ability mapping off the class doc
(get_lib_loader().get_class(caster.class_slug).spellcasting.ability;
cleric → wis, wizard → int, …) instead of a hardcoded "int". Proficiency
bonus was already computed correctly from character_level
(rules/dice.py::proficiency_bonus, 2 + (level-1)//4) — only the
ability-mod term and the override that discarded it were wrong.
A system.bonuses.spell.dc active-effect bucket (e.g. a Rod of the Pact
Keeper) now also folds additively on top of the real formula.
Who is affected — this is intentionally the widest-blast-radius change in
the campaign so far: ANY host casting a PC save-DC spell (not just
Counterspell) will see a different, SRD-correct DC from this release
onward. A caster with no resolvable spellcasting ability (unknown/
non-caster class_slug, or a use_item cast — which never sets
spellcasting_ability) keeps the OLD flat approximation unchanged, so
non-caster / item-cast paths are unaffected byte-for-byte. Monster casters
are unaffected (their flat 8 + attack_bonus DC is untouched — no monster
stat block threads a per-ability sheet through today).
Feature-owned @scale ids now resolve (behavioral change)
activities/scale.py::build_scale_values/resolve_scale_value previously
only walked a caster's class/subclass/species owner docs; a scale token
owned by a granted FEATURE (e.g. Channel Divinity's
@scale.channel-divinity-cleric.spark die count) resolved to None no
matter the caster's level. _owner_doc now falls back to
loader.get_feature(identifier), and build_scale_values additionally
walks every feature slug the caster's class/subclass/species GRANT at/below
its level (the same granted_feature_slugs helper the USE_FEATURE
repertoire gate uses), folding each granted feature's own ScaleValue table
into the returned map.
This required a companion dataset change:
dnd5e_srd_data.schema.feature.Feature gained an additive
advancement: list[AdvancementEntry] = [] field (mirroring the existing
Class/Subclass/Species field), and the Foundry translator now carries
a feature YAML's own system.advancement[] into it. Regenerating the
canonical corpus from this schema change touched every
canonical/features/*.json file — each gained an "advancement" key
(non-empty only where the source YAML actually carries a ScaleValue/other
advancement block; empty [] otherwise). Purely additive; no existing
field changed.
Who is affected: hosts/tests relying on the previously-None
resolution of a feature-owned @scale token now see the real resolved
value. packages/dnd5e-engine/tests/test_scale_resolver.py's
test_unresolved_owner_returns_none (renamed
test_feature_owned_scale_resolves_via_get_feature_fallback) is the one
pre-existing test whose expectation flipped, from is None to the
resolved 1 (Channel Divinity's Divine Spark die count at level 5, the
level-2 tier).
system.bonuses.{rwak,msak,rsak}.damage + system.bonuses.spell.dc buckets now fold (behavioral change, additive sidecar fields)
orchestrator.py::_fold_active_effect_changes previously only folded
system.bonuses.mwak.damage (Rage's melee-only damage bonus) into a
consumed sidecar; the sibling rwak (ranged weapon), msak (melee spell
attack), and rsak (ranged spell attack) damage-bonus buckets fell through
every branch untouched. All four now fold symmetrically into their own
category-scoped ActivityResolutionContext sidecar field
(passive_melee_damage_bonus [existing], passive_ranged_damage_bonus,
passive_melee_spell_damage_bonus, passive_ranged_spell_damage_bonus,
all new/additive), each consumed in activities/attack.py's on-hit damage
resolution gated on the swing's own melee/ranged + weapon/spell shape (a
weapon's weapon_category for the two weapon buckets; the activity's own
attack.type.value for the two no-weapon spell-attack buckets).
system.bonuses.spell.dc (a flat/dice bonus to the CASTER's own spell save
DC — e.g. a Rod of the Pact Keeper) now folds too, additively on top of the
real save-DC formula above (see that section).
Who is affected: hosts that construct ActiveEffects carrying
system.bonuses.rwak.damage / system.bonuses.msak.damage /
system.bonuses.rsak.damage / system.bonuses.spell.dc changes and pass
them via start_combat(active_effects=...). These previously had zero
mechanical effect; they now apply. system.bonuses.mwak.damage's existing
behavior (Rage) is unchanged.
Known remaining gap (not fixed in this cluster): system.bonuses.heal.*
and system.bonuses.abilities.check/.skill remain inert — no per-actor
sidecar consumer exists for them yet (activities/heal.py reads no bonus
sidecar at all; activities/check.py's ctx.check_modifiers sidecar is
never populated from active-effect changes, only from condition-derived
projections). See the shrunk BACKLOG.md entry.
Cluster 5 — Spatial (walls, LoS, cover, AoE, terrain)
Design note: docs/dev/spatial-geometry.md. All four GridScene additions
below default empty/off, so any existing GridScene construction (or a
combat that never passes one, i.e. a zone-graph combat) behaves
byte-for-byte identically to before.
Wall geometry now blocks line of sight (behavioral change — a previously-legal ranged attack/cast can now be rejected)
GridScene gained wall_segments: list[WallSegment] = [] — a new value
type, WallSegment(x1, y1, x2, y2), grid-CORNER coordinates (mirroring
Foundry's own Wall.c convention; accepts plain mapping dicts via pydantic
coercion). GridTopology.has_line_of_sight(a, b) previously always
returned True for any in-bounds pair; it now traces the straight segment
between the two cells' CENTER points against every wall segment and returns
False on a proper intersection.
Who is affected: hosts that construct a GridScene with non-empty
wall_segments and place combatants on opposite sides of a wall. A ranged
attack or spell cast that previously resolved cleanly now fails with
AttackFailed(reason="out_of_range") — the existing rejection surface every
other range/LoS gate already used; no new AttackFailed.reason literal was
added. A GridScene with no wall_segments (the default) is unaffected.
Cover model — half / three-quarters / total (behavioral change, new AC/save consumer)
GridScene gained cover_cells: dict[str, Literal["half", "three_quarters",
"total"]] = {}, tagging an obstruction cell with the cover degree it
grants. A new SpatialTopology.cover_between(a, b) -> "none" | "half" |
"three_quarters" | "total" Protocol method (GridTopology walks the
Bresenham line of cells between a and b, excluding both endpoints, and
returns the highest tagged degree; the zone-graph backend _ZoneGraph
always returns "none" — a documented, permanent split, not a gap).
Consumers: activities/attack.py::resolve_attack folds +2/+5 onto a
target's effective AC before the hit comparison; activities/
save_primitive.py::roll_save folds the SAME +2/+5 onto a Dexterity
save's total only (SRD 5.2: cover grants "a bonus to AC and Dexterity saving
throws" — a bonus to the covered creature's own roll, not a DC change);
orchestrator._in_range_with_los gained a third conjunct,
cover_between(a, b) != "total" — a totally-covered target is untargetable,
rejected the same way an out-of-range one is
(AttackFailed(reason="out_of_range"), no new reason literal).
Both consumers read a new ActivityResolutionContext.target_cover: dict[str,
str] = {} sidecar (computed once per activity resolution by the
orchestrator's _target_cover_map); build_activity_context gained a
matching optional target_cover parameter, default None → {}.
Who is affected: hosts that construct a GridScene with non-empty
cover_cells. A ranged attack against a covered target now needs a higher
roll to hit (or is rejected outright at "total"); a covered target's
Dexterity save total is now higher. A GridScene with no cover_cells (the
default) is unaffected — every consumer's fallback is "none" (+0).
Shrunk, not closed: SRD 5.2's per-activity "ignores cover for save"
carve-out (Sacred Flame's own description text) has no backing schema field
in canonical data today (SaveBlock carries only ability/dc) — inventing
one without translator support would be a data fabrication. See the shrunk
BACKLOG.md entry.
AoE templates: GridTopology.cells_in_template (new, additive)
New method, cells_in_template(origin: str, shape: Literal["sphere", "cone",
"line"], size_ft: int, *, direction: tuple[int, int] | None = None) ->
list[str] — not part of the SpatialTopology Protocol (grid-only; the
zone-graph backend has no cell coordinates to enumerate a template over).
Chebyshev metric throughout (maintainer decision, catalog C05-S04). sphere
needs no direction; cone/line require one (raises ValueError
otherwise) — see docs/dev/spatial-geometry.md for the exact geometry
(cone/line are engine conventions, unit-tested only — no e2e scenario pins
them).
Who is affected: nobody by default — this is a new capability with no prior behavior to change; it is not called from any existing orchestrator code path.
Difficult terrain doubles movement cost (behavioral change — a move that used to succeed can now be refused)
GridScene gained difficult_terrain_cells: list[str] = [].
GridTopology.edge_distance(a, b) now returns 2 * cell_size_ft (instead of
the flat cell_size_ft) when the cell being ENTERED (b) is tagged
difficult terrain (SRD 5.2 §Difficult Terrain). _handle_move needed no
change — it already reads edge_distance as its per-step cost and rejects a
move that would exceed movement_remaining without mutating the budget
(MoveFailed(reason="insufficient_movement"), an existing literal).
Who is affected: hosts that construct a GridScene with non-empty
difficult_terrain_cells. A move into such a cell that previously succeeded
on a tight movement budget can now be refused. shortest_path is NOT
cost-aware (still uniform-cost BFS, fewest cells) — only the single-step
edge_distance primitive _handle_move consumes changed; see the shrunk
BACKLOG.md "Richer pathfinding" entry.
WallSegment (new, additive, top-level export)
WallSegment (dnd5e_engine.specs) is now also exported from the
dnd5e_engine package root (mirrors ZoneEdge); test_public_api_surface.py
was updated to include it.
Cluster 6 — Reactions & off-turn intents
The pre-armed reaction queue (see docs/dev/reaction-queue.md for the full
design). Hard model constraint: reactions are pre-armed auto-fire — a
combatant declares the reaction with a normal on-turn "ready" intent, and
the engine fires it automatically when the trigger occurs. There is no
mid-resolution host round-trip; the submit_player_intent /
advance_monster_turn contract is unchanged.
"ready" intents are now consumed; reaction_trigger narrows to a typed Literal (behavioral change)
PlayerIntent(intent_type="ready", spell_id=..., slot_level=...,
reaction_trigger=...) now registers a pending reaction (spending the Action
and ending the turn, as before — previously it was an inert Action-consuming
no-op). PlayerIntent.reaction_trigger narrows from str | None to
ReactionTrigger | None where ReactionTrigger =
Literal["cast_spell", "hit_by_attack", "targeted_by_magic_missile"]
(dnd5e_engine.orchestrator). Who is affected: hosts that were storing
arbitrary strings in the previously-unconsumed reaction_trigger field now
get a ValidationError at PlayerIntent construction for any value outside
the closed set.
CastFailedReason gains "countered" (exhaustive-matcher break risk)
The one event-union delta this cluster makes: the CastFailedReason Literal
in events.py gains "countered", emitted when a pre-armed Counterspell's
Constitution save fails and the triggering spell dissipates. No new
CombatEvent classes were added — ReactionTriggered / EffectApplied /
EffectExpired / AttackRolled / IntentSubmitted all already existed
with the right shapes. Who is affected: hosts exhaustively matching on
CastFailed.reason must add the new arm. Hosts should also expect
ReactionTriggered and reaction-spell events (e.g. Shield's
EffectApplied) to appear mid-stream inside ANOTHER actor's
intent-resolution tail.
Countered casts do not expend the spell slot (behavioral change)
_consume_spell_slot itself is unchanged, but the Counterspell drain runs
BEFORE it: a countered cast_spell intent emits
CastFailed(reason="countered") and advances the turn (the action is
wasted, mirroring the shipped no_slot precedent) without the slot gate
ever running — SRD 5.2: "If that spell was cast with a spell slot, the
slot isn't expended." An uncountered cast is byte-identical to before.
Reaction-applied 1-round buffs now survive until the owner's next turn start (behavioral change)
A round-scoped effect applied by a reaction firing during ANOTHER actor's
turn (Shield's +5 AC) previously could not outlive the cast (the
turn-end tick collapsed it immediately in the degenerate on-turn model). It
now persists until the owner's own next TurnStarted, where the engine
emits EffectExpired(reason="duration") — the SRD's "until the start of
your next turn" boundary. The generic on-turn-cast duration tick is
untouched. Shield's +5 lands on effective AC via a new
ActivityResolutionContext.passive_ac_bonus: dict[str, int] sidecar
(mirrors the cover-bonus consumer in attack.py; the attack roll itself is
never modified, only the AC comparison). With Shield up, Magic Missile's
force damage floors to 0 via a transient, spell-slug-scoped immunity
entry — deliberately not a general force-immunity mechanic.
Monsters now make opportunity attacks against moving PCs (behavioral change — PCs now provoke)
_fire_monster_opportunity_attacks_on_move mirrors the shipped PC-reactor
direction: a PC leaving a monster's reach (same-zone approximation, both
directions) without Disengage now provokes one melee AoO per eligible
monster reactor — IntentSubmitted(intent_type="reaction") +
AttackRolled(is_opportunity_attack=True), reaction spent on use, mover
dropped to 0 HP cancels the move. Who is affected: any host whose PCs
move out of melee contact — moves that were previously free can now draw
damage or kill the mover mid-move (ActorMoved is then suppressed).
Disengage is a real, turn-non-ending action (behavioral change)
"disengage" joins "move" / "move_mark" / "dash" as a
turn-non-ending intent: it consumes the Action, sets the new
Combatant.disengaging_this_turn: bool flag (reset at the actor's own
TurnStarted), and suppresses the AoO trigger entirely (no reactor spends
a Reaction). Previously it fell through to the generic pipeline and ended
the turn, making same-turn Disengage→Move impossible. Who is affected:
hosts that relied on "disengage" ending the turn must now follow it with
their next intent for the same actor (or an explicit "pass").
Cluster 7 — Sneak Attack & feature repertoires
PlayerIntent.activity_id (new, additive, default None)
PlayerIntent gains activity_id: str | None = None, parallel to
feature_id / spell_id. It disambiguates a USE_FEATURE invocation of a
feature that is a repertoire of alternatives — a feature carrying more
than one typed activity (Channel Divinity: Divine Spark Heal vs Save vs Turn
Undead; Cunning Strike's four options). _resolve_feature_invocation now:
resolves EXACTLY the named activity when activity_id matches one of the
feature's activity ids; keeps the prior safe no-op defer
(feature_multi_activity_selection_deferred, before any budget is spent)
when it is absent OR names none of them (never guess). Who is affected:
hosts constructing a USE_FEATURE intent for a multi-activity feature must
now pass activity_id to get a resolution — omitting it stays a no-op, as
before. Single-activity features (Rage, Second Wind) are unchanged.
A no-target feature heal (Divine Spark: Heal) now defaults its target to the
caster — scoped to heal-kind feature activities with no named target, so an
offensive feature activity with no target still resolves to nobody.
Sneak Attack conditional-damage rider (behavioral change — finesse/ranged rogue swings now hit harder)
activities/attack.py now injects an SRD §Sneak Attack extra-damage rider on
a qualifying weapon hit. A hit qualifies when the weapon is Finesse or Ranged
AND either the attacker has Advantage OR an ally is within 5 ft of the target
and not Incapacitated (the attacker not at Disadvantage), AND the rider is
unspent this turn. The rider dice come from the @scale-resolved
<class>.sneak-attack value and fold once into the first (weapon-typed)
damage part. On a critical hit the rider dice DOUBLE (SRD 5.2 §Critical Hit:
"Roll all of the attack's damage dice twice and add them together" — the
rider is part of the attack's damage dice), through the same dice-count
doubling idiom the base-weapon crit path uses (roll_expr(..., crit=True)
mirrors roll_damage_part's _double_dice: 3d6 rolls as six sequential
seeded d6 draws; flat modifiers are never doubled). Who is affected: any host whose combats include a rogue (a
scale_values["<class>.sneak-attack"] carrier) attacking with a finesse/
ranged weapon under one of those conditions — such swings now deal more
damage. No new CombatEvent: the extra dice ride the existing
DamageApplied amount.
New plumbing, all additive:
ActivityResolutionContext.active_effects: Sequence[ActiveEffect]— the caster's own effects, read for attacker-sideflags.advantage.attack/flags.disadvantage.attackoverride changes.ActivityResolutionContext.sneak_attack_spent: dict[str, bool]— per-attacker once-per-turn gate;ActivityResolutionContext.sneak_attack_ally_adjacent: dict[str, bool]— per-target ally-adjacency predicate (an orchestrator-side spatial read; the pure resolver never touches the spatial seam).Combatant.sneak_attack_spent_this_turn: bool(defaultFalse), reset atTurnStartedalongside the action/bonus/reaction/disengage resets; the orchestrator projects it into the sidecar per intent and records it after a rider fires.
Advantage detection gates the trigger but does NOT reroll the base attack (scope note)
Attacker-side flags.advantage.attack / flags.disadvantage.attack are now
DETECTED, but only to gate the Sneak Attack trigger — the natural d20 still
rolls mode="normal" on this path. Rolling the base attack with advantage/
disadvantage would shift the seeded dice stream and crit outcome, which the
per-scenario damage bounds isolating the Sneak Attack rider depend on staying
invariant. Wiring the flag into the d20 mode (and the target-side Faerie
Fire producer) is tracked in BACKLOG.md.
Foundry (@scale.x)dN dice-count idiom now normalizes (bug fix)
resolve_roll_data collapses a paren-wrapped bare-integer dice count
immediately preceding dN (Divine Spark's
(@scale.channel-divinity-cleric.spark)d8 → (1)d8 → 1d8) back to a
d20-parseable form. The prior output left the parens, which d20.parse
rejects — so any such formula that reached a roll previously raised
ValueError. Genuine arithmetic groupings ((2 + 3) * 2) are untouched.
Cluster 8 — Passive-stat projection
Design note: docs/dev/passive-projection.md. Four gaps where a creature's
always-on or activation-gated defensive/movement stats were declared in the
canonical dataset but never reached the live Combatant or the damage/condition
pipelines. Every change below is additive — new optional spec/Combatant
fields default empty and behavioral deltas only fire when the backing dataset
carries the trait. No CombatEvent union member is added or changed
(ConditionApplied already existed); rules/ and dispatch.py are untouched.
Rage's damage resistance now halves matching damage while raging (behavioral change, C08-S01)
orchestrator.py::_fold_active_effect_changes gained a top-of-loop
system.traits.dr.value branch (placed before the numeric mode-guard and the
signed-numeric coercion). Foundry mode=2 on this key is a set-add of a
damage-type string (not a numeric bonus): the branch appends the cleaned type
string into the resistances sidecar list apply.py::apply_damage already
unions with the target's static resistances. A raging barbarian taking
Bludgeoning/Piercing/Slashing damage now takes half (previously full — the
change was silently mangled into "+bludgeoning" and dropped). Pure
producer-side fix; the consumer is unchanged and no signature changed.
Who is affected: any host whose combats include a barbarian with an active
Rage ActiveEffect. Matching-type damage taken while raging now halves.
condition_immunities field + emit-gate: an immune condition never attaches (behavioral change + new fields, C08-S02)
New additive condition_immunities: list[str] = [] fields on PartyMemberSpec,
EncounterMemberSpec, and Combatant (dnd5e_engine.specs /
dnd5e_engine.types.combat), mirroring the damage_resistances pattern.
interpret_passive_stats projects an always-on system.traits.ci.value change
into DerivedPassiveStats.condition_immunities, normalizing the sole irregular
Foundry token poison → poisoned (a single-entry alias, not a general trait
engine); build_party_member threads it onto the spec, and start_combat also
unions a raw-spec PC's always-on subclass/class condition immunities (so a
level-10 Circle-of-Land druid gets Nature's Ward without the build seam).
activities/effects.py::apply_activity_effects now suppresses a
ConditionApplied whose condition is in the target's immunities — the
EffectApplied rider still fires (the effect's presence stays observable), but
the condition never attaches. Suppress (not emit-with-amount-0) was chosen
because a condition is binary present/absent with no "amount" to neutralize.
Who is affected: hosts whose targets carry condition_immunities (a
Nature's Ward druid, or a monster/NPC template that threads its immunities
through the spec). A matching condition that previously attached now never does.
KNOWN GAP: the gate covers the activity-effects path
(activities/effects.py) only — the weapon-mastery Topple path
(activities/mastery.py) still emits ConditionApplied("prone") ungated;
see the BACKLOG entry dated 2026-07-03.
NOTE: this is a distinct surface from the dead, host-supplied
dispatch.py::DispatchContext.condition_immunities (legacy duck-typed-intent
path), which is untouched.
Damage vulnerability now doubles a matching hit (behavioral change + new fields, C08-S03)
New additive damage_vulnerabilities: list[str] = [] fields on
PartyMemberSpec, EncounterMemberSpec, and Combatant. The consumer
(apply.py, vulnerabilities = set(sidecar.get("vulnerabilities", ()))) was
already fully wired; every producer was missing. _build_foe_combatants now
hydrates the field from the monster template
(get_lib_loader().get_monster(slug).damage_vulnerabilities) when the spec
leaves it empty (a monster_template_slug="skeleton" foe picks up its canonical
["bludgeoning"]) — scoped to vulnerabilities only: the field is new, so
auto-hydration cannot change any existing combat; resistances/immunities stay
host-populated by the existing convention. _project_target_modifiers folds
Combatant.damage_vulnerabilities into the vulnerabilities sidecar every
intent, mirroring the resistances/immunities merge exactly.
Who is affected: hosts attacking a target with a matching
damage_vulnerabilities entry (e.g. Bludgeoning vs a Skeleton) now see the
doubled damage total. Empty field (the default) is unaffected.
Movement modes: walk-speed bonus folds and non-walk modes reach the spec/Combatant (new fields, C08-S04)
New additive movement_modes: CombatantMovementModes field on PartyMemberSpec
and Combatant (default all-None). CombatantMovementModes(climb, swim, fly,
burrow: int | None) is a new value type in dnd5e_engine.activities.passive_stats,
mirroring CombatantSenses's shape (frozen, extra="forbid"). Like
CombatantSenses it is a field-type carrier, not a top-level __all__
export. DerivedPassiveStats gained walk_speed_bonus: int = 0 and
movement_modes; interpret_passive_stats gained a keyword arg
species_base_speed: int = 30.
An always-on literal system.attributes.movement.walk change (Roving's flat
+10, mode=2) now folds additively into walk_speed_bonus, and
build_party_member composes base_speed = species_walk + walk_speed_bonus
(an L6 human ranger: 30 → 40). Non-walk modes (climb/swim/fly/burrow) resolve
onto the typed carrier — the single symbolic token @attributes.movement.walk
("equal to your Speed") resolves to the boosted walk speed (Roving's
climb/swim → 40); any other @… token falls to skipped_keys. This is a
minimal purpose-built resolution, not a general Foundry-formula engine.
Collapsing modes to one scalar is a spec violation (a creature may have distinct
climb/swim/fly/burrow speeds), so the field stays multi-mode typed and is
visible via LiveCombatView.initiative.
Who is affected: hosts building a PC via build_party_member whose
class/subclass/species grants an always-on movement passive_effect. Beyond
Roving (ranger 6), the barbarian's level-5 Fast Movement (+10 walk,
always-on) now folds too — a level-5+ barbarian's base_speed rises by 10 from
this release. Such a PC's base_speed now reflects the walk bonus and its
movement_modes carrier is populated. PCs with no movement-granting feature are
unaffected (empty carrier, unchanged base_speed).
Cluster 9 — Rest & recovery
This cluster changes both workspace packages (dnd5e-engine and
dnd5e-srd-data). Their lockstep version bump (the C11 closeout) must cover
both: the engine gains a public module and a CastFailedReason value; the data
package gains a Feature.uses schema field and regenerates every
canonical/features/*.json.
New public module dnd5e_engine.rest (new, additive — __all__ extended)
A rest has no resolvable seam inside a live combat: PlayerIntent.intent_type
(events.py::IntentType) structurally cannot express "short_rest", and SRD 5.2
§Short Rest / §Long Rest list "Rolling Initiative" as one of a rest's own
interruptions — a rest inside a combat's turn loop is self-contradicting. The seam
therefore landed as a standalone, zero-I/O module mirroring dnd5e_engine.check:
hosts call these pure functions between combats. Loader access (resolving a
class's hit_die to hit_die_size) stays with the caller.
New top-level __all__ exports (tests/test_public_api_surface.py updated in
lockstep, plus dnd5e_engine.rest added to its PUBLIC_MODULES):
HitDicePool(hit_die_size, dice_remaining, dice_total)— frozen dataclass.RestOutcome(healed, dice_spent, dice_remaining, rolls, hp_current=None, pool=None)— frozen;hp_current/poolare populated by the long-rest resolver only.resolve_short_rest(pool, dice_to_spend, con_modifier, *, rng) -> RestOutcome— each spent die healsmax(1, 1d<HD> + CON)(the 2024 Foundry-parity per-die floor — applied to EACH die, never the summed total); rejects overspend / negative spend withValueError. Every draw flows through the passed-inrng.resolve_long_rest(pool, hp_current, hp_max) -> RestOutcome— full recovery: HP tohp_max, dice pool topool.dice_total. This is the SRD 5.2 (2024) rule; the stale 2014 half-hit-dice rule is NOT implemented. Exhaustion / HP-max reduction are out of scope (no producer exists in the engine).recover_feature_uses(counters, period, recovery=None) -> dict[str, int]— between-combats companion recharging the per-feature use counters, honouring each feature's typed recovery rules (see below).recoveryis optional and additive; omitting it keeps the prior full-recovery-on-any-rest default.RecoveryPeriod = Literal["sr", "lr"].
FEATURE_USE_COUNTER_PREFIX is also public, but exported from the
dnd5e_engine.rest submodule's __all__ only (not the top-level
dnd5e_engine.__all__) — import it as
from dnd5e_engine.rest import FEATURE_USE_COUNTER_PREFIX.
Data: Feature.uses (new, additive schema field — every canonical/features/*.json regenerated)
dnd5e_srd_data.schema.feature.Feature gained uses: FeatureUses | None = None,
carrying a feature's Foundry top-level system.uses block (Second Wind's capped,
Short/Long-Rest-recharged use pool). New exported models: FeatureUses(max: str,
spent: int, recovery: list[RecoveryRule]) and RecoveryRule(period: Literal["sr",
"lr", "day", "recharge", "initiative"], type: Literal["recoverAll", "formula"],
formula: str). uses is None for a feature with no meaningful cap (no max,
no recovery).
Regenerating the canonical corpus adds a "uses" key to all 260
canonical/features/*.json files (47 populated, 213 null) — no other field
changes (structural JSON audit); byte-deterministic (make check-regen-clean).
Mirrors the Cluster 4 Feature.advancement precedent exactly. Who is affected:
data-package consumers deserializing feature JSON gain a new nullable field; no
existing field moved or changed value.
CastFailedReason gains "no_uses_remaining" (exhaustive-matcher break risk)
The CastFailedReason Literal (events.py) gained a value. A capped, rest-recharged
feature (Second Wind) invoked again with no uses left and no intervening rest now
emits CastFailed(actor_id=..., spell_id="", reason="no_uses_remaining") — the
existing no_action_economy reject shape, extended from a per-turn budget to a
per-rest one. Who is affected: any host match-ing exhaustively on
CastFailedReason must add a "no_uses_remaining" arm.
Second Wind (and every capped feature) is now per-rest capped (behavioral change — previously unlimited)
Before this release, use_feature invocations were uncapped: a Fighter could fire
Second Wind every turn. Now submit_player_intent consults the feature's
uses cap (via _resolve_feature_invocation) and rejects an over-cap invocation
before any budget is consumed (no Bonus Action spent), recording the spend on a
feature_use:<slug> counter in the existing custom_counters sidecar. The counter
persists for the combat; a host resets it between combats via
rest.recover_feature_uses. Who is affected: hosts relying on unlimited feature
use will see the second same-rest invocation of any capped feature rejected.
Cap resolution reads the feature's typed uses.max: a literal integer ("1",
"3") is honoured exactly, and a @scale.<owner>.<key> roll-data token is
resolved against the caster's real ScaleValue map (the same build_scale_values
machinery activity resolution uses) — so Second Wind caps at its true
level-scaled value (3 at Fighter level 5 via the {1: 2, 4: 3, 10: 4} table),
not a flat 1. Any OTHER symbolic max — @prof, max(1, @abilities.cha.mod),
5 * @classes.paladin.levels — is NOT resolved and falls back to uncapped
(never gated), preserving the pre-Cluster-9 behaviour for those features rather
than wrongly rejecting them; lifting that residual is a recorded follow-up
(BACKLOG "Rest & recovery").
recover_feature_uses honours each feature's typed uses.recovery rules when
the host threads them via the optional recovery argument: a recoverAll entry
fully recharges the pool (spent → 0); a formula entry regains that many uses
(spent → max(0, spent - n), e.g. Second Wind's Short-Rest formula: "1" returns
one use, so a Short Rest recharges partially while a Long Rest recharges fully).
Only literal-integer recovery formulas exist in the SRD corpus today (a structural
scan of canonical/features confirms every recovery formula is "1"); an
unhandled non-literal formula leaves the counter unchanged. When recovery data is
supplied and a feature has NO rule for the rest's period, the counter is preserved
— an lr-only feature (Arcane Recovery, Divine Intervention; the corpus majority,
41 lr vs 10 sr entries) does not recharge on a Short Rest. Omitting recovery
entirely keeps the original full-recovery-on-any-rest default. No exhaustion
mechanic and no generic resource framework were added.
Cluster 10 — Monster behavior
Two behavioral corrections in the monster-turn path (advance_monster_turn +
activities/monster_actions.py). Both are additive — no CombatEvent union
member, no IntentType value, and no public spec field was added or changed
(tests/test_public_api_surface.py passes unchanged); rules/ and dispatch.py
are untouched. Monster AI is DM-adjudicated behaviour (engine/legacy-gambit
parity), not codified SRD rules text.
Fleeing monsters now retreat instead of standing still (behavioral change, C10-S01)
A monster over its flee threshold (_monster_is_fleeing — an AGGRESSIVE monster
below 10% HP, a RANGED one below 25%; DEFENSIVE never flees) previously collapsed
its entire turn to a bare IntentSubmitted(intent_type="pass") with zero
movement — it "fled" only in the sense of declining to attack, remaining exactly
where it stood. It now spends its movement putting distance between itself and its
nearest threat: advance_monster_turn's fleeing branch calls
_execute_flee_retreat, which selects the nearest alive PC as the threat and
walks the monster to the reachable zone that MAXIMIZES topology distance from it
(_plan_flee_destination — the greedy CLOSING walk inverted, composed from the
existing shortest_path/edge_distance primitives; no new SpatialTopology
capability). One ActorMoved event fires per step traversed, before the
turn's pass is recorded.
The recorded intent_type stays "pass" (with real ActorMoved events now
preceding it) — no "flee" IntentType was minted (the smaller, additive-surface
change; the catalog blesses reusing "pass"). A fleeing monster leaving a
co-located threat's reach provokes that threat's opportunity attack, exactly like
the existing closing-walk (_walk_zone_path reuses
_fire_pc_opportunity_attacks_on_move). Zone-graph only: a grid-backend flee has
no finite named-zone set to rank, so a fleeing monster on a grid still holds its
ground (grid retreat pathing stays the BACKLOG "Richer pathfinding" item).
Who is affected: hosts/tests asserting a frozen flee turn (zero movement,
unspent movement_remaining) break — a fleeing monster with a positive movement
budget and a reachable farther zone now moves, and a co-located PC may fire an AoO
in response. (The pre-existing engine test
test_wounded_aggressive_monster_below_flee_threshold_passes was updated in
kind: the monster still never attacks and still records pass, but now retreats
and provokes the co-located hero's AoO.)
Range/profile-aware multiattack fallback (behavioral change, C10-S02)
When a multiattack's sub-attacks are named only by labelless Foundry
[[/item .<id>]] tokens (no rendered {label} to join onto a typed sibling), the
fan-out falls back to repeating one sibling's attack count times. That fallback
previously always took siblings[0] — the first attack in Monster.actions order
— regardless of the target's distance. A Scout (shortsword first, longbow second)
100 ft from its target therefore locked onto its 5 ft shortsword; the movement
gate (_monster_attack_range_ft, keyed off the first resolved activity) then read
the whole turn as melee-range, 100 ft exceeded both reach and the 30 ft budget,
and the Scout did nothing — despite its own 150 ft longbow already being in
range.
expand_action_to_activities gained three optional keyword arguments —
target_distance_ft: int | None = None, behavior_profile: str | None = None,
melee_reach_ft: int = 5 — that steer this fallback's sibling choice via the new
_select_fallback_sibling: when the live distance is supplied, it prefers a
sibling whose OWN range already covers the target (the longbow, 150 ft) over one
that does not (the shortsword, 5 ft), with behavior_profile == "RANGED" breaking
ties toward the longest-reach sibling when several are in range. advance_monster_turn
passes the same zone-path distance the movement gate reads, so selection and gate
now agree (the Scout fires the longbow with zero repositioning). Omitting the
kwargs preserves the historical first-in-list-order fallback — single-attack-type
multiattacks (owlbear → Rend) and callers that pass no distance are byte-identical.
Who is affected: hosts/tests whose encounters include a multiattack monster
with a mixed melee+ranged repertoire and a labelless multiattack description. Such
a monster now fires the weapon that fits the current distance instead of always
the first-listed one — event shapes change for ranged monsters (an AttackRolled
where there was a silent pass, or a ranged attack where there was a melee one).