Skip to content

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 +3 bucket contribution with a multiply: 2 change becomes 6.
  • upgrade — raises the bucket's contribution to max(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.

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-side flags.advantage.attack / flags.disadvantage.attack override 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 (default False), reset at TurnStarted alongside 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)d81d8) 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/pool are populated by the long-rest resolver only.
  • resolve_short_rest(pool, dice_to_spend, con_modifier, *, rng) -> RestOutcome — each spent die heals max(1, 1d<HD> + CON) (the 2024 Foundry-parity per-die floor — applied to EACH die, never the summed total); rejects overspend / negative spend with ValueError. Every draw flows through the passed-in rng.
  • resolve_long_rest(pool, hp_current, hp_max) -> RestOutcome — full recovery: HP to hp_max, dice pool to pool.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). recovery is 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).