Skip to content

Migration guide: v0.5 → v0.6

Summary — what a host has to do

Nothing breaks. No public name was removed or renamed, no function signature changed shape, and every new field is optional with a default that reproduces v0.5 behaviour. Concretely:

  1. New event types to tolerate. TurnPhase (turn-boundary marker) and ConcentrationCheck now appear in the stream. A renderer that switches on event.type must have a default branch that skips unknown types rather than printing or raising — the engine will keep adding marker events.
  2. New optional fields to ignore or render. natural, modifier, sources and advantage on AttackRolled / SaveRolled / CheckRolled / ConcentrationCheck; source_id and is_crit on DamageApplied (C15). Ignoring them is safe; rendering them gives you "14 + 5 = 19", the reason a roll had advantage, or who/what dealt a hit of damage.
  3. New optional inputs to populate (recommended). PartyMemberSpec.save_proficiencies / skill_proficiencies / skill_expertise. Leave them empty and your PCs roll saves and checks at their ability modifier only — correct arithmetic, just an unproficient character. Populate them to get SRD-correct totals. weapon_proficiencies is the one exception, and it matters more than the others (C15): it is read off whether the field was ever assigned, not off emptiness. Never pass it at all, and every attack with every weapon is assumed proficient (the pre-C15 legacy behaviour, unchanged — safe to do nothing here). Pass it — even as an empty list, "proficient with nothing" — and every attack roll is enforced against it by weapon category or slug, omitting Proficiency Bonus for a weapon the PC is not listed as proficient with. Do not populate it "defensively" with an empty list; that flips enforcement on. Monsters need nothing: setting monster_template_slug hydrates ability scores, save/skill proficiencies and the proficiency bonus automatically, and monsters are always assumed weapon-proficient per SRD 5.2 §Weapon Proficiency ("a monster is proficient with any weapon in its stat block"). One caveat: EncounterMemberSpec.dexterity == 10 is treated as unset when monster_template_slug resolves (the template's DEX wins); pass any other value to override. Retype to int | None is tracked in BACKLOG follow-ups.
  4. Behavioural deltas that move numbers — each has its own section below: saves and checks now add real modifiers (F1c/F1d); activity attack rolls honour advantage/disadvantage (F2b); every d20 routes through one primitive (F2c); effect durations in seconds / turns / until-end-of-next-turn now expire where they previously never did (F3b); and the SRD conditions now change resolution rather than being descriptive (C12) — the largest delta in this release for any host that carries conditions or exhaustion. (Four SRD condition rows remain unenforced; see docs/capabilities.md.) C14 changes turn shape: a main-hand attack now keeps the turn open while attacks_remaining or a two-weapon-fighting window is live, Dodge/Help/ Hide/Grapple/Shove/stand_up/escape_grapple are real, and initiative: int | None on the party/encounter specs can opt into an engine-rolled Initiative draw. New IntentType members: "grapple", "shove", "stand_up", "escape_grapple" — an exhaustive host-side match/if chain on intent_type must add a branch (or a default). C15 changes attack-roll and damage numbers: weapon proficiency, range tiers, Ranged Attacks in Close Combat, Heavy, and all eight 2024 weapon masteries now resolve for real — see the C15 sections below, and in particular the Nick mastery's Bonus-Action-exemption turn-keeping caveat, which can leave a turn open longer than a host might expect. Determinism is nearly preserved: normal-mode rolls still consume exactly one RNG draw, so a seeded replay produces the same natural dice — mostly only the modifiers added to them, and the advantage/disadvantage draws, changed. The one exception is an auto-failed save, which now draws no die at all: see "Conditions are enforced (C12)" below. C15's new draws are similarly narrow: a second d20 is drawn only under a NEWLY active disadvantage state (Heavy, "range:long", "ranged_in_melee", Sap) that a pre-C15 seeded scenario never triggered; a formerly-rejected attack that is now legal (a middle-range-tier shot, a Thrown melee weapon used at range) draws its own fresh roll where none existed before; and a Cleave proc draws one additional chained attack roll plus its damage roll. Everything else added by C15 — proficiency, source_id, Loading, Nick, Slow, Push (the forced move draws nothing) — is draw-free and leaves a byte-identical seeded stream wherever it was already legal pre-C15.
  5. Zone graph deprecated. start_combat(scene_zones=...) still works but now raises a DeprecationWarning; the backend is removed in 0.7.0. Build a GridScene instead — a one-row grid is a valid stopgap: GridScene(width=len(zones), height=1) with zone_id = cell_id(i, 0).
  6. New spatial behaviour on the grid — walls block movement, AoE spells hit every creature in the template, creatures grant cover, one ActorMoved per move intent, CombatantMoved appears after Thunderwave, and unseen advantage/disadvantage applies when lighting data is present. Each has its own section below.

Known duplicates until v0.7

The damage-triggered concentration save emits two events for one roll: the new ConcentrationCheck and the legacy SaveRolled(ability="con") that hosts consumed before the dedicated event existed. Both carry the same dc, roll_total, succeeded and breakdown fields.

This is deliberate, for exactly one release. A host that counts saves, sums them, or renders each one will double-count unless it filters:

  • Preferred: consume ConcentrationCheck and skip SaveRolled events immediately preceded by one for the same target.
  • Simplest: keep consuming SaveRolled and ignore ConcentrationCheck until you migrate.

The duplicate SaveRolled is removed in v0.7; ConcentrationCheck carries the full breakdown so nothing is lost in the swap.

The first-party bridge already does this: nat20_bridge.narrate lists concentration_check in its _SKIPPED_EVENT_TYPES and renders only the save_rolled line, so a concentration save produces exactly one narration line. The entry is removed when the SaveRolled twin is dropped in 0.7.

Behavioral changes

Saving throws apply real ability + proficiency modifiers (F1c)

Through v0.5 the orchestrator projected a DEX-only save modifier onto every combatant (_project_target_modifiers emitted {"dex": …}), and the two orchestrator-level save paths — the concentration check on damage and the end-of-turn repeat save (Hold Person / Hold Monster / Dominate Person) — rolled a raw d20 against the DC with no modifier at all. Every non-DEX save therefore resolved at a flat +0.

From v0.6 all three paths follow SRD 5.2 §Saving Throws — d20 + ability modifier + proficiency bonus (if proficient in that save) — sourced from dnd5e_engine.activities.actor_stats.save_modifier:

  • _project_target_modifiers projects all six abilities (str/dex/con/int/wis/cha) into entry["saves"], so the IR-level save activity handler sees real modifiers for every ability.
  • The concentration check in _emit_apply_damage adds the concentrating creature's CON save modifier (including CON save proficiency) to the natural d20. The DC formula is unchanged: max(10, damage // 2).
  • _run_end_of_turn_saves adds the target's modifier for the spec's ability.

The modifiers come from the ability scores, save_proficiencies and level/CR-derived proficiency bonus hydrated onto Combatant in F1a/F1b.

Determinism: the number of RNG draws is unchanged on every path — one natural d20 per save, as before. A seeded replay therefore produces the same natural rolls; only the modifier added to them changes. Hosts pinning SaveRolled. roll_total for actors with non-zero ability modifiers or save proficiency will see higher (or, for a negative modifier, lower) totals and correspondingly different success outcomes.

Re-pinned tests

None. The full engine suite (806 passed, 81 xfailed) stayed green across this change: the seeded scenarios' combatants carry default 10 ability scores and no save proficiencies, so their modifiers remain +0, and DEX already carried its ability modifier (it now additionally carries the proficiency bonus when the creature is proficient in DEX saves — no fixture exercises that, but hosts do).

Ability checks apply the actor projection and system.bonuses.* folds (F1d)

Through v0.5 build_activity_context hard-coded check_modifiers={}, so every IR-level check activity (activities/check.py::resolve_check — Maze's escape check, manacles' Escape/Burst checks, any CheckActivity) resolved as a raw d20 vs the DC: no ability modifier, no proficiency bonus, no Expertise.

From v0.6 the orchestrator projects a per-actor check sidecar for every combatant and threads it into the context:

check_modifiers[actor_id] == {
    "ability_mods": {"str": …, "dex": …, "con": …, "int": …, "wis": …, "cha": …},
    "skills": {"<skill slug>": …},   # one entry per proficient skill
    "disadvantage": bool,            # condition-derived (Frightened / Poisoned)
}

skills is keyed by the canonical long-form SRD slug ("perception", "sleight_of_hand") — the namespace Combatant.skill_proficiencies, rules/skills.SKILL_ABILITIES and the corpus all use. A CheckActivity names its skill with the Foundry 3-letter code ("prc"), so resolve_check translates code → slug at the single lookup site (_SKILL_CODE_TO_SLUG in activities/check.py); a sidecar keyed by the raw code still resolves through a documented legacy fallback.

Numbers come from dnd5e_engine.activities.actor_stats.check_modifier — SRD 5.2 §D20 Tests: d20 + ability modifier, plus the proficiency bonus when the actor is proficient in the skill used, doubled with Expertise. The condition-derived passive_check_adv / passive_check_dis lists are merged, not replaced, and the entry is no longer omitted when those lists are empty.

Three active-effect change buckets are now folded on top of the projection (orchestrator.py::_fold_active_effect_changes, add mode only — other modes fall through to the existing handling):

change key (short / Foundry-native) lands on
abilities.check / system.bonuses.abilities.check every ability_mods entry and every skills entry (a skill check IS an ability check)
abilities.skill / system.bonuses.abilities.skill every skills entry
abilities.<ab>.save / system.abilities.<ab>.bonuses.save save_modifiers[id]["saves"][<ab>]

Dice-string values are dropped on these three buckets (unlike the neighbouring passive_save_bonus / passive_to_hit_bonus sidecars, which keep a signed dice STRING their consumer rolls). These three land on resolved integer sidecars whose consumers add them straight to a natural d20 with no dice parser; rolling a formula inside the projection would also consume RNG draws in a path that must stay draw-free. Plain integer strings ("2", "-1") still fold.

Determinism: no RNG draw is added or removed on any path — resolve_check still draws exactly one d20. Hosts pinning CheckRolled.roll_total for actors with non-zero ability modifiers, skill proficiency or a check-bonus effect will see higher totals and correspondingly different succeeded outcomes.

Re-pinned tests

None. The full engine suite (814 passed, 81 xfailed) stayed green: the seeded scenarios' combatants carry default 10 ability scores and no skill proficiencies, so every projected check modifier is +0. Two direct callers of the private _fold_active_effect_changes helper in tests/test_active_effect_resistance_fold.py were updated for its new per_target_check parameter — a signature adaptation, not a behavior re-pin.

Attack rolls honour advantage/disadvantage (F2b)

Through v0.5 the live attack path hard-coded its d20 mode: activities/attack.py::resolve_attack set mode: AdvantageMode = "normal" for every target, and rules/conditions.py::conditions_grant_advantage_on_attack was dead code (imported only by tests). The attacker's own flags.advantage.attack / flags.disadvantage.attack active-effect changes were read (attacker_advantage_flags) but used only to gate the SRD §Sneak Attack rider — never to reroll the base attack. The stated reason was keeping seeded dice streams invariant.

From v0.6 the attack roll goes through the unified D20 Test primitive (activities/d20.py::roll_d20_test, F2a) with typed advantage provenance:

source produced by
"flag" (adv / dis) attacker's flags.advantage.attack / flags.disadvantage.attack active-effect change (attacker_advantage_flags)
"condition:target" (adv) target is Paralyzed, Stunned, Unconscious, Blinded, Restrained or Petrified (conditions_grant_advantage_on_attack)
"condition:target" (dis) target is Invisible
"condition:attacker" (adv) attacker is Invisible
"condition:attacker" (dis) attacker is Blinded, Poisoned, Frightened or Restrained

The condition source names the side that produced it, not the direction: neither side is one-way. conditions_grant_advantage_on_attack is therefore called once per side (activities/attack.py), with the other side's condition list empty. Note the tagging consequence for a pre-existing row: an Invisible attacker's advantage is now tagged "condition:attacker" where a v0.5-era reading of the helper would have called it "condition:target". No shipped fixture asserted that tag.

Any advantage source plus any disadvantage source cancel to normal (SRD 5.2 §Advantage and Disadvantage); multiple sources of the same kind never stack. Cancelled sources stay recorded in AttackRolled.sources — with one exception: an attacker carrying both flags.advantage.attack and flags.disadvantage.attack is reconciled to neither by attacker_advantage_flags before the source list is built, so that case emits sources == []. Flag-vs-condition and condition-vs-condition cancellations do preserve provenance.

AttackRolled now carries the F2a provenance triple: advantage reflects the real resolved mode, natural is the kept die (after advantage/disadvantage resolution — this is the die the natural-20 crit / natural-1 fumble test reads), modifier is the attack bonus, and sources lists every contributing source.

AttackRolled.modifier is the flat attack bonus (ability modifier + proficiency + the parsed attack.bonus + the weapon's magical bonus). It deliberately excludes the per-attacker passive_attack_bonus dice sidecar (SRD §Bless / §Bane — a signed d4 rolled fresh per swing), because folding a die into the primitive's modifier would change the seeded draw order. So roll_total == natural + modifier holds only when no Bless/Bane-style sidecar is active on the attacker; with one, the residual is that d4. An additive bonus_dice_total field is deferred.

The resolver-internal activities.d20.D20Result field natural was renamed to first (the first d20 drawn) so it no longer collides with AttackRolled.natural (the kept die). D20Result.kept is unchanged. This is a rename of a type that is not part of the public __all__ surface; hosts reading events are unaffected.

Opportunity attacks are NOT yet routed through the primitive. The PC and monster OA paths in orchestrator.py still roll their own d20 and emit AttackRolled(advantage="normal") with no natural/modifier/sources. That gap is tracked in BACKLOG.md and closes with C14.

The condition half is fed by two new ActivityResolutionContext sidecars, attacker_conditions: list[str] and target_conditions: dict[str, list[str]], filled by build_activity_context from Combatant.conditions via active_condition_names. Standalone consumers constructing a context by hand get empty defaults (⇒ normal).

The table above is not the complete SRD 5.2 set. Two rows still missing need information the engine does not thread into the resolver yet — a distance, or the identity of a particular creature — and are tracked in BACKLOG.md under "Audit 2026-08-26 — rolls & modifiers", closing with C12's reach/distance sidecar:

  • Prone — advantage only if the attacker is within 5 ft, disadvantage otherwise (needs a distance);
  • Grappled attacker — disadvantage against any target other than the grappler (needs the grappler's identity).

The Invisible row also carries an approximation: SRD 5.2 says "If a creature can somehow see you, you don't gain this benefit against that creature" — per-attacker senses are not modelled, so the disadvantage applies unconditionally.

Other distance-derived sources are likewise still absent: unseen attacker, ranged-in-melee and long range (all in BACKLOG.md).

Determinism: the draw discipline is preserved — a target with no advantage source still consumes exactly one rng.randint(1, 20), so every seeded scenario without an advantage producer replays byte-identically. A target with a live source consumes two draws and shifts every subsequent draw in that combat's stream (damage dice included).

Re-pinned tests

Three SRD 5.2 rows were added to conditions_grant_advantage_on_attack after the final review (target Restrained → advantage, target Petrified → advantage, target Invisible → disadvantage). No seeded fixture moved for them — no test in the suite attacked a Restrained, Petrified or Invisible target — so there is nothing to record beyond the four new cases in tests/activities/test_attack_advantage_live.py.

No seeded fixture value was re-pinned for F2b itself either. Three Sneak Attack tests measured the rider by differencing an advantage swing against an advantage-less swing — a comparison that is no longer stream-comparable now that the advantage flag also drives the base d20. All were rebased onto a same-stream reference (the identical advantage swing with the sneak dice absent), which isolates the rider more tightly than before rather than loosening anything:

  • tests/test_sneak_attack_resolver.py::test_once_per_turn_gate_blocks_second_rider: baseline _swing_total()_swing_total(active_effects=adv, scale_values={}); window 3..186..36 — source: flag (flags.advantage.attack on char:rogue) now takes two d20 draws whose kept die under seed 9 is a natural 20, so the 3d6 rider is crit-doubled to 6d6 (SRD §Critical Hits).
  • tests/e2e/test_c07_sneak_and_repertoire.py::test_c07_s01_sneak_attack_adds_bounded_extra_damage_on_advantage: reference run _run(()) (no advantage) → _run((adv_effect,), class_slug="fighter") (same advantage effect, non-rogue ⇒ no rogue.sneak-attack scale value ⇒ no rider) — source: flag (flags.advantage.attack on char:rogue), which now makes the advantage run consume two draws so the advantage-less run is no longer stream-comparable. The crit-aware 6..36 / 3..18 window is unchanged; the test additionally now asserts the two runs share a stream (identical roll_total and is_crit) and that the advantage is genuinely live (advantage == "advantage", sources == ["flag"]).
  • tests/e2e/test_c07_sneak_and_repertoire.py::test_c07_s04_sneak_attack_once_per_turn_cap_resets_next_turn: baseline_total _hit_total(active_effects=())_hit_total(active_effects=(adv_effect,), scale_values={}); windows 3..186..36 — source: same flag on char:rogue, same seed-9 natural-20 crit. (C07-S02 is unaffected: neither of its runs carries an advantage source, so both streams are identical.)

Everything else in the suite (835 passed, 81 xfailed, 0 xpassed) was untouched: no other seeded scenario carries an advantage-granting flag or condition on an attacker or its target.

Every d20 goes through the shared primitive (F2c)

Saving throws (activities/save_primitive.py), ability checks (activities/check.py), death saves (death_saves.py) and both orchestrator save paths — the concentration-on-damage CON save and the end-of-turn repeat save — now resolve their d20 through activities/d20.py::roll_d20_test, the same primitive attack rolls have used since F2b. Advantage/disadvantage reconciliation (SRD 5.2 §Advantage and Disadvantage) therefore lives in exactly one place.

Roll breakdowns on the event stream. SaveRolled and CheckRolled now populate the additive F2 fields advantage (the resolved AdvantageMode"advantage" / "disadvantage" / "normal", the field AttackRolled has carried since v0.1), natural (the die KEPT after advantage/disadvantage), modifier (the flat bonus) and sources (the typed advantage provenance). advantage defaults to "normal", so every existing constructor and every persisted pre-v0.6 event still validates; note that it reports the RESOLVED mode, so a roll with a cancelling advantage AND disadvantage source reads "normal" with two entries in sources. As on AttackRolled, modifier is the deterministic part only: it EXCLUDES Bless/Bane-style bonus DICE (passive_save_bonus), which must be rolled AFTER the d20 to preserve the seeded draw order, so roll_total == natural + modifier holds only when no such sidecar is active. A Dexterity save's cover bonus IS folded into modifier (it is deterministic). An auto-failed save (Paralyzed/Stunned/Petrified/Unconscious vs STR/DEX) reports natural = None — no die is drawn.

activities.save_primitive.roll_save now returns a SaveRoll named envelope (total, succeeded, natural, modifier, sources) instead of a (total, succeeded) tuple. It is resolver-internal — not part of the public __all__ — but standalone consumers that call it directly must unpack by attribute.

Save advantage sources are tagged "condition:target". ctx.passive_save_adv / passive_save_dis have exactly one in-engine producer, rules/conditions.py::project_passive_save_modifiers (SRD §Conditions — Restrained ⇒ disadvantage on DEX saves, and so on), so every entry the engine hydrates is a condition on the SAVING creature. Active-effect folding never writes those keys.

New event types

ConcentrationCheck(target_id, dc, roll_total, succeeded, advantage, natural, modifier, sources) — defined and exported since v0.1 but never constructed — is now emitted by the concentration-on-damage block (SRD 5.2 §Concentration, DC = 10 or half the damage taken, whichever is higher). TRANSITIONAL: it is emitted ALONGSIDE the SaveRolled(ability="con") that path has always emitted, for one release. The SaveRolled comes first (so existing listeners keep their ordering) and the duplicate is removed in v0.7. ConcentrationCheck carries the same additive F2 breakdown fields (advantage / natural / modifier / sources) as SaveRolled, so nothing is lost when that removal happens — hosts that COUNT saving throws must filter one of the two out, and hosts that want to distinguish a concentration save from an arbitrary CON save should switch to the new event now.

Ability checks honour condition disadvantage (F2c)

ctx.check_modifiers[actor]["disadvantage"] — the condition-derived flag projected by F1d (SRD 5.2 §Frightened / §Poisoned; Exhaustion is not in this set — C12 replaced the 2014 disadvantage with the numeric -2 x level D20 Test penalty, see "Conditions are enforced (C12)" below) — was carried but never consumed. activities/check.py is its FIRST consumer: a flagged actor now draws two d20s and keeps the lower, tagged sources = ["condition:attacker"] (the source is always a condition on the rolling actor). There is no check-advantage producer in the engine yet (Help / Guidance are not modelled), so the advantage side is always empty.

Determinism: every path keeps its pre-F2c draw count — one randint(1, 20) in normal mode, two under advantage/disadvantage, zero when a force_*_d20 test seam pins the natural. Death saves and both orchestrator save paths pass an empty AdvantageSources, so their streams are byte-identical. The only way a seeded stream can move is the newly-consumed check disadvantage flag: a combat in which a Frightened or Poisoned actor makes a typed ability check now draws one extra die at that point. (An exhausted actor does not — Exhaustion is a flat modifier in SRD 5.2 and adds no draw.)

Re-pinned tests

None. The full engine suite (864 passed, 81 xfailed, 0 xpassed) is green with no fixture value re-pinned: no seeded scenario carries a check-disadvantage flag on an actor that makes a typed check activity roll.

New event type: TurnPhase (F3a)

v0.6 adds turn_lifecycle.py — a per-combat registry of turn-boundary hooks (round_start / turn_start / turn_end) stored as live.lifecycle — and collapses the engine's three hand-copied turn-advance blocks (start_combat's opening emit, _advance_turn, and the tail of advance_monster_turn) into a single _end_turn_and_advance. _advance_turn is gone; internal callers now use _end_turn_and_advance (both are private, so no public API moved).

Each phase is announced in the event stream by a new closed-union member:

class TurnPhase(BaseModel):
    type: Literal["turn_phase"] = "turn_phase"
    actor_id: str | None   # None for round_start
    phase: Literal["round_start", "turn_start", "turn_end"]
    round_number: int

It is exported from dnd5e_engine.events (union member, __all__, and ALL_COMBAT_EVENT_TYPES). Like every other event class it is not re-exported from the package root — hosts reach it through dnd5e_engine.events or by matching event.type == "turn_phase".

TurnPhase is purely informational: it carries no rules outcome. Its value is that a host can render "top of round 3" / "end of Alice's turn" without inferring boundaries from TurnStarted/TurnEnded adjacency, and that turn-boundary effects landing in a later release (ongoing damage, regeneration, recharge, legendary reset) will be attributable to a phase.

Ordering at a turn boundary is fixed, and the marker always precedes the hooks of its own phase:

TurnPhase(turn_end, A) -> [turn_end hooks] -> TurnEnded(A)
  -> (on wrap) RoundStarted -> TurnPhase(round_start) -> [round_start hooks]
  -> TurnStarted(B) -> TurnPhase(turn_start, B) -> [turn_start hooks]
  -> pending death save

start_combat emits the second half only (there is no turn to end): RoundStarted -> TurnPhase(round_start) -> TurnStarted -> TurnPhase(turn_start).

What hosts must do. The union is closed and TurnPhase is new, so an exhaustive match on event.type will now fall through. Both first-party renderers already tolerated it (nat20_bridge.narrate falls back to [type] k=v …; nat20_demo.render.tape_lines falls back to "{type}: {payload}"), and neither needed a change. Hosts that COUNT events, or that assert an exact event-log length or sequence, will see additional entries: three per wrapping boundary, two otherwise, plus two from start_combat.

Determinism: no RNG is drawn by this change. Hooks run in registration order (never dict/set order), and the two hooks the orchestrator moved into the registry — the caster-keyed duration tick (turn_end) and the reaction-effect expiry (turn_start, previously inline in _emit_apply_turn_started) — are registered in the order that reproduces where they used to run. The reaction-effect EffectExpired events now fire just after the TurnPhase(turn_start) marker rather than during the TurnStarted fold; their order relative to every pre-existing event is unchanged. Seeded replays are therefore byte-identical apart from the inserted TurnPhase events.

Re-pinned tests

One. tests/e2e/test_c06_reactions.py::test_c06_s02_countered_cast_preserves_slot_and_wastes_action asserted that TurnEnded was the immediately next event after CastFailed; the TurnPhase(turn_end) marker now sits between them. The assertion was preserved in full by filtering turn_phase out of the tail before reading it — no assertion was weakened. The rest of the suite (879 passed, 81 xfailed, 0 xpassed) is green unchanged, as are the demo (73) and bridge (48) suites.

End-of-turn repeat saves run once, as a turn_end hook (F3a follow-up)

SRD 5.2 §Hold Person / §Hold Monster / §Dominate Person: "At the end of each of its turns, the target repeats the save." Through v0.5 the engine implemented this as _run_end_of_turn_saves, a hand-placed call at two sites in orchestrator.py — one in the PC path, one in the monster path — rather than a registered hook. From v0.6 it is the engine:repeat-save turn_end hook and both call sites are gone. Two host-visible consequences:

  1. The save lands inside its phase. Its SaveRolled now falls between TurnPhase(actor, "turn_end") and TurnEnded(actor) instead of before the marker, so a host using the phase markers to attribute boundary effects attributes this one correctly. Nothing else about the event changed.
  2. A bonus action no longer triggers a second end-of-turn save. The PC call site sat above the if is_bonus_action: early return, so an actor who took a bonus action rolled its repeat save, did not end its turn, then rolled the repeat save again on its real Action: two escape attempts per turn against Hold Person, and — since F2c routed the save through roll_d20_test — one extra rng.randint(1, 20) draw. As a hook it is unreachable from the bonus-action path, which returns before _end_turn_and_advance.

Hook order. engine:repeat-save is registered FIRST among the turn_end hooks, ahead of engine:duration-tick and engine:timed-effect-expiry: the repeat save must resolve while its source effect is still live, i.e. before the duration tick could expire that effect on the same boundary. The order is pinned by tests/test_turn_lifecycle.py::test_hooks_registered_by_the_engine_are_present_and_ordered.

Determinism. A turn in which the actor takes no bonus action draws exactly the same dice as in v0.5, in the same order. A turn with a bonus action draws one fewer d20 per pending repeat-save spec, and every subsequent draw in that combat's stream shifts accordingly. This only affects a seeded replay in which a creature that is currently the target of a pending repeat save takes a bonus action.

Re-pinned tests

None. No seeded fixture in the suite has a pending repeat save on an actor that takes a bonus action, so nothing moved. Three new tests pin the behaviour (tests/test_repeat_save_runs_once_per_turn.py: action-only ⇒ one save, bonus action then action ⇒ one save, and the save sits inside the turn_end phase) plus the registry-order assertion above.

Timed effect expiry: seconds, turns, until end of next turn (F3b)

Through v0.5 only ActiveEffectDuration.rounds ticked. seconds and turns were parsed off the packs (activities/effects.py::_duration_from_passive), stored on every ActiveEffect, and read by nobody — an effect whose duration is expressed only in seconds or turns never expired inside a combat.

From v0.6 a second turn_end hook (engine:timed-effect-expiry, registered immediately after engine:duration-tick) owns the three remaining shapes. SRD 5.2 §Duration puts a round at about 6 seconds, which is the whole conversion:

  • secondsceil(seconds / 6) rounds, ticked exactly like rounds: caster-keyed, at the caster's turn end (item/environment origins fall back to the target's turn end, as the round tick already did). The derived count is materialised once into duration.rounds and decremented in the same pass, so seconds=12 is indistinguishable from rounds=2; from the next turn the pre-existing round tick owns it. seconds itself is never mutated.
  • rounds + seconds togetherrounds wins. The seconds branch only fires when rounds is None, so Bless (rounds=10, seconds=60) still runs its ten rounds and a pack that ships a shorter seconds value can never cut a rounds counter short.
  • turns → decremented at the target's own turn end, not the caster's. It is independent of rounds: whichever counter reaches zero first expires the effect.
  • flags["until_end_of_next_turn_of"] = "<entity_id>" (new, engine-read) → expires at that actor's next turn end, with a one-turn grace when the effect was applied during that actor's own turn ("until the end of your next turn"). The grace is derived read-only from the event log (the most recent TurnStarted for that actor vs. the effect's EffectApplied), so no new state crosses the boundary and seeded effects — which emit no EffectApplied — expire at that actor's very next turn end.

Every branch emits EffectExpired(reason="duration"), the same event the round tick has always emitted, and concentration-flagged effects are exempt from all of them, exactly as they already were from the round tick: the concentration cascade and the per-turn repeat save own those effects' lifetime and the packs' counters on them are display-only (Hunter's Mark ships seconds=600).

Unchanged: reaction buffs. Shield-shaped effects still expire at the owner's next turn start via engine:reaction-effect-expiry (docs/dev/reaction-queue.md), which runs a phase earlier and therefore always wins the race with the new turn-end hook.

Determinism: no RNG is drawn by this change, and the hook is appended to turn_end after the round tick, so no pre-existing event moves.

Effects that now expire where they previously did not

113 non-concentration pack effects carry a turns counter or a seconds duration short enough to run out inside a plausible encounter (≤ 600 s = 100 rounds). These are the behavioural change; hosts that read get_actor_active_effects or EndCombatResult.final_active_effects will see them disappear where they used to linger for the whole combat.

Canonical entry Effect Duration
features/abjure-foes.json Abjured seconds=60 (10 rounds)
features/ascendant-step.json Levitating seconds=600 (100 rounds)
features/brutal-strike.json Hamstrung turns=1
features/channel-divinity-cleric.json Turned seconds=60 (10 rounds)
features/channel-divinity-paladin.json Divine Sense seconds=600 (100 rounds)
features/cunning-strike.json Cunning Strike: Poisoned seconds=60 (10 rounds)
features/defensive-tactics.json Multiattack Defense turns=1
features/devious-strikes.json Devious Strikes: Blinded turns=1
features/devious-strikes.json Devious Strikes: Dazed turns=1
features/devious-strikes.json Devious Strikes: Knocked Out seconds=60 (10 rounds)
features/draconic-flight.json Draconic Flight seconds=600 (100 rounds)
features/frosts-chill.json Chilled turns=1
features/holy-nimbus.json Holy Nimbus seconds=600 (100 rounds)
features/improved-brutal-strike.json Staggered turns=1
features/improved-brutal-strike.json Sundered turns=1
features/innate-sorcery.json Innate Sorcery seconds=60 (10 rounds)
features/investment-of-the-chain-master.json Resist Acid turns=1
features/investment-of-the-chain-master.json Resist Bludgeoning turns=1
features/investment-of-the-chain-master.json Resist Cold turns=1
features/investment-of-the-chain-master.json Resist Fire turns=1
features/investment-of-the-chain-master.json Resist Force turns=1
features/investment-of-the-chain-master.json Resist Lightning turns=1
features/investment-of-the-chain-master.json Resist Necrotic turns=1
features/investment-of-the-chain-master.json Resist Piercing turns=1
features/investment-of-the-chain-master.json Resist Poison turns=1
features/investment-of-the-chain-master.json Resist Psychic turns=1
features/investment-of-the-chain-master.json Resist Radiant turns=1
features/investment-of-the-chain-master.json Resist Slashing turns=1
features/investment-of-the-chain-master.json Resist Thunder turns=1
features/large-form.json Large Form seconds=600 (100 rounds)
features/monks-focus.json Disengaged turns=1
features/monks-focus.json Patient Defense (Focus Point) turns=1
features/natures-veil.json Veiled turns=1
features/open-hand-technique.json Addled turns=1
features/sacred-weapon.json Sacred Weapon seconds=600 (100 rounds)
features/sear-undead.json Turn Undead seconds=60 (10 rounds)
features/stonecunning.json Stonecunning seconds=600 (100 rounds)
features/stunning-strike.json Slowed turns=1
features/stunning-strike.json Stunned turns=1
features/superior-defense.json Superior Defense seconds=60 (10 rounds)
features/superior-hunters-defense.json Hunter's Defense: Acid turns=1
features/superior-hunters-defense.json Hunter's Defense: Bludgeoning turns=1
features/superior-hunters-defense.json Hunter's Defense: Cold turns=1
features/superior-hunters-defense.json Hunter's Defense: Fire turns=1
features/superior-hunters-defense.json Hunter's Defense: Force turns=1
features/superior-hunters-defense.json Hunter's Defense: Lightning turns=1
features/superior-hunters-defense.json Hunter's Defense: Necrotic turns=1
features/superior-hunters-defense.json Hunter's Defense: Piercing turns=1
features/superior-hunters-defense.json Hunter's Defense: Poison turns=1
features/superior-hunters-defense.json Hunter's Defense: Psychic turns=1
features/superior-hunters-defense.json Hunter's Defense: Radiant turns=1
features/superior-hunters-defense.json Hunter's Defense: Slashing turns=1
features/superior-hunters-defense.json Hunter's Defense: Thunder turns=1
items/animated-shield.json Animated Shield seconds=60 (10 rounds)
items/armor-of-invulnerability.json Metal Shell seconds=600 (100 rounds)
items/boots-of-speed.json Boots of Speed Active seconds=600 (100 rounds)
items/caltrops.json Slowed turns=1
items/candle-of-invocation.json Lit Candle of Invocation seconds=60 (10 rounds)
items/crawler-mucus.json Poisoned and Paralyzed seconds=60 (10 rounds)
items/dagger-of-venom.json Poison Coat seconds=60 (10 rounds)
items/dagger-of-venom.json Poisoned seconds=60 (10 rounds)
items/energy-bow.json Restrained (Arrow of Restraint) seconds=60 (10 rounds)
items/gem-of-brightness.json Blinded by Brilliance seconds=60 (10 rounds)
items/gem-of-seeing.json Seeing seconds=600 (100 rounds)
items/horn-of-blasting.json Deafened seconds=60 (10 rounds)
items/mace-of-terror.json Frightened seconds=60 (10 rounds)
items/oil-of-sharpness.json Oiled seconds=60 (10 rounds)
items/oil-of-sharpness.json Oiled Ammunition seconds=60 (10 rounds)
items/philter-of-love.json In Love seconds=600 (100 rounds)
items/pipes-of-haunting.json Frightened seconds=60 (10 rounds)
items/potion-of-invulnerability.json Resistant seconds=600 (100 rounds)
items/ring-of-elemental-command.json Charmed turns=1
items/ring-of-x-ray-vision.json X-Ray Vision seconds=60 (10 rounds)
items/rod-of-alertness.json Protective Aura seconds=600 (100 rounds)
items/rod-of-lordly-might.json Frightened seconds=60 (10 rounds)
items/rod-of-lordly-might.json Paralyzed seconds=60 (10 rounds)
items/staff-of-thunder-and-lightning.json Deafened seconds=60 (10 rounds)
items/staff-of-thunder-and-lightning.json Stunned turns=1
items/wand-of-wonder.json Blinded seconds=60 (10 rounds)
spells/acid-arrow.json Lingering Acid turns=1
spells/blindness-deafness.json Blindness seconds=60 (10 rounds)
spells/blindness-deafness.json Deafness seconds=60 (10 rounds)
spells/divine-favor.json Divine Favor seconds=60 (10 rounds)
spells/divine-word.json Blinded, Deafened seconds=600 (100 rounds)
spells/divine-word.json Deafened seconds=60 (10 rounds)
spells/fire-shield.json Chill Shield seconds=600 (100 rounds)
spells/fire-shield.json Warm Shield seconds=600 (100 rounds)
spells/freezing-sphere.json Trapped in Ice seconds=60 (10 rounds)
spells/hypnotic-pattern.json Hypnotized seconds=60 (10 rounds)
spells/mirror-image.json Duplicate A seconds=60 (10 rounds)
spells/mirror-image.json Duplicate B seconds=60 (10 rounds)
spells/mirror-image.json Duplicate C seconds=60 (10 rounds)
spells/prismatic-wall.json Prismatic Blinding seconds=60 (10 rounds)
spells/resilient-sphere.json Enclosed in Sphere seconds=60 (10 rounds)
spells/sanctuary.json Warded seconds=60 (10 rounds)
spells/searing-smite.json Seared seconds=60 (10 rounds)
spells/shillelagh.json Shillelagh (1d10) seconds=60 (10 rounds)
spells/shillelagh.json Shillelagh (1d12) seconds=60 (10 rounds)
spells/shillelagh.json Shillelagh (1d8) seconds=60 (10 rounds)
spells/shillelagh.json Shillelagh (2d6) seconds=60 (10 rounds)
spells/shocking-grasp.json Shocked turns=1
spells/speak-with-plants.json Speaking with Plants seconds=600 (100 rounds)
spells/sunburst.json Blinded seconds=60 (10 rounds)
spells/symbol.json Arguing seconds=60 (10 rounds)
spells/symbol.json Frightened seconds=60 (10 rounds)
spells/symbol.json Incapacitated seconds=60 (10 rounds)
spells/symbol.json Sleeping seconds=600 (100 rounds)
spells/symbol.json Stunned seconds=60 (10 rounds)
spells/thaumaturgy.json Booming Voice seconds=60 (10 rounds)
spells/true-strike.json Ranged Weapon turns=1
spells/true-strike.json True Strike turns=1
spells/vitriolic-sphere.json Lingering Acid turns=1
spells/zone-of-truth.json Cannot Lie seconds=600 (100 rounds)

A further 103 non-concentration effects carry seconds ≥ 3600 (≥ 600 rounds — comprehend languages, darkvision, planar binding, geas, …). They are now ticked too, but cannot reach zero in any realistic combat.

Re-pinned tests

None. The full engine suite went 879 passed / 81 xfailed / 0 xpassed before the change to 890 passed / 81 xfailed / 0 xpassed after (the 11 new tests in tests/test_effect_timed_expiry.py); no seeded scenario's assertions moved. The only existing test edited is tests/test_turn_lifecycle.py::test_hooks_registered_by_the_engine_are_present_and_ordered, which pins the default hook registry by key and had to learn the new engine:timed-effect-expiry entry — no assertion was weakened.

start_combat rejects an illegal grid start cell (C16)

New hard exception. start_combat(grid_scene=...) now raises ValueError when any PartyMemberSpec.zone_id / EncounterMemberSpec.zone_id is out of bounds or names a blocked_cells cell:

ValueError: start_combat: char:hero start cell '99,99' is out of bounds or blocked

In v0.5 such a combatant started on an unusable cell and silently escaped every range, line-of-sight and movement gate (they all read actor_zone). This is a misconfigured-host error, not a rules outcome, so it raises rather than emitting an event. Validate scene placement before calling start_combat, or catch the ValueError at your call site.

Zone graph deprecated (D8)

start_combat(scene_zones=...) now emits a DeprecationWarning. Nothing about zone-graph resolution changed — distances, ActorMoved, monster walks and "always clear line of sight" behave exactly as in v0.5 — but the backend is removed in 0.7.0, and every spatial feature added in 0.6 (AoE geometry, cover from creatures, multi-cell moves, forced movement, vision) is grid-only.

Migrating: replace the zone dict with a GridScene. If your world has no real map, a one-row grid preserves adjacency semantics:

scene = GridScene(width=len(zones), height=1)
# zone i becomes cell_id(i, 0)

Suppressing the warning (warnings.filterwarnings) is a stopgap, not a migration — the parameter itself goes away in 0.7.0.

AoE spells resolve against the template (C16)

Before. _expand_aoe_target_list selected every combatant whose actor_zone string equalled the anchor's. On a grid that string is a cell id, so a 20-ft-radius Fireball hit whoever happened to share the named target's single 5-ft square.

After. The spell's typed target.template is resolved to a shape (sphere / cone / line / cube / cylinder; Foundry's radius, circle and square are mapped), placed at its SRD point of origin, and expanded with GridTopology.cells_in_template. Cells with no line of effect from the origin are dropped (SRD 5.2 §Point of Origin: "To block a line, an obstruction must provide Total Cover"). Every alive combatant standing in a surviving cell is a target — including the caster and their allies when the geometry says so.

Hosts should expect AoE casts to produce more SaveRolled / DamageApplied events than in v0.5, and to hit friendlies. A cone, line or cube is aimed by the new PlayerIntent.direction: tuple[int, int] | None; when it is omitted the engine aims caster → named target. A self-origin cone/line/cube with neither a direction nor a distinct named target is now rejected before the slot and the action are spent, with CastFailed(reason="target_invalid") — in v0.5 the same cast silently degenerated to zone-equality targeting.

Cover for an area effect is measured from the point of origin, not from the caster (SRD 5.2 §Cover). A Fireball centred forty feet away shields its victims by what stands between them and the burst point, so the per-target ActivityResolutionContext.target_cover fed to the Dexterity saves is computed from the resolved template origin (_aoe_cover_origin), the same cell the target list itself was expanded from. Because the caster is then not on the origin, the caster's own square counts as an interposing creature like any other. Single-target casts and attacks are unchanged — they still measure from the attacker's cell.

The zone-graph backend keeps the v0.5 zone-equality behaviour until removal.

Walls, blocked cells and creatures form one obstruction model (C16)

Four deltas, all grid-only:

  • Walls now stop movement, not only sight. GridTopology.edge_distance returns None for a step through a WallSegment, and a diagonal step may no longer cut a wall's corner. A route that v0.5 allowed can now fail with MoveFailed(reason="blocked_path").
  • blocked_cells block line of sight and grant Total Cover. In v0.5 they blocked movement only; has_line_of_sight ignored them.
  • Interposed creatures grant Half Cover (+2 AC, +2 Dexterity saves). cover_between(a, b, occupied_cells) counts every other live combatant on the line; neither endpoint creature grants cover against the other (a creature never shields itself). Attack rolls that hit in v0.5 can now miss by 1 or 2.
  • A cover_cells tag on the TARGET's own cell now counts (the origin cell's never does). SRD 5.2 §Cover, Half Cover: "an object that covers at least half of the target" — an object in the target's own space shields it, which is what the tag models. Ruling shared with cluster C22.

Consequence worth knowing: a creature standing on a cell tagged cover_cells: {..: "total"} becomes untargetable_in_range_with_los rejects any attack or cast whose cover_between(...) is "total" (SRD: a target with total cover "can't be targeted directly"), and unlike blocked_cells a cover cell is passable, so a creature can walk onto one. If you author scene geometry, use blocked_cells for impassable full-height obstructions and reserve cover_cells: "total" for spaces no creature can occupy (or accept that anything standing there cannot be attacked).

SaveBlock.ignore_cover is read when the dataset carries it (getattr(activity.save, "ignore_cover", False)), so Sacred Flame's carve-out takes effect the day C22 ships the field — no engine change needed.

Multi-cell moves (C16)

Before. A "move" intent had to name an adjacent cell; anything else was MoveFailed(reason="not_adjacent") regardless of remaining budget, so crossing 30 ft took six submit_player_intent calls.

After, on the grid backend only. A "move" intent names any destination. _handle_move routes with GridTopology.shortest_path, prices the whole route by edge_distance, and walks it if the budget covers it (rejecting atomically if not). On the grid not_adjacent now means only "no destination given, untracked position, or the destination is the actor's own cell"; the three new reasons join the existing insufficient_movement:

Reason Meaning
not_adjacent no destination given, an untracked position, or the destination is the mover's own cell (the legacy reason name is kept for hosts)
occupied the destination holds another creature — ally or enemy
blocked_path the destination is adjacent, but the single step crosses a wall or cuts a blocked corner
unreachable no legal route at all (enemy-occupied cells are impassable; allies may be passed through)
insufficient_movement a legal route exists but costs more than the remaining budget — atomic, nothing moves

Occupancy (grid backend only): allies are passable, enemies are impassable, and no move may end on an occupied cell.

The zone-graph backend is unchanged — multi-hop routing is grid-only. A zone "move" intent is still the single step to an adjacent zone it was in v0.5; a non-adjacent destination is still rejected MoveFailed(reason="not_adjacent") and is never routed through intermediate zones, even though _ZoneGraph.shortest_path could find such a route. Zone occupancy is likewise unmodelled, so a PC may still move into a zone an enemy holds in order to engage it. If you have not yet migrated off scene_zones= (deprecated, removal in 0.7), movement behaves exactly as it did in v0.5 — including its one-ActorMoved-per-intent event shape. Pinned by tests/test_c16_orchestrator.py::test_zone_graph_move_to_a_non_adjacent_zone_is_still_rejected and ::test_zone_graph_adjacent_move_keeps_the_pre_c16_event_shape.

On the grid, a move intent now emits exactly one summarising ActorMoved carrying the whole route's distance, where a host previously saw one per step. Monster movement (_walk_zone_path, _execute_flee_retreat and the closing walk in advance_monster_turn) still emits one ActorMoved per step and still ignores occupancy — a deliberate asymmetry with the PC "move" intent, recorded in BACKLOG.md ("Monster walks ignore occupancy").

Forced movement: CombatantMoved (C16)

orchestrator.push_combatant(live, target_id, origin_cell, distance_ft) is the new forced-movement primitive, and it emits a new event type, CombatantMoved(actor_id, from_zone, to_zone, distance_ft, forced=True). Intent-driven movement is unchanged and still emits ActorMoved — the two are deliberately distinct so a renderer can say "is pushed" rather than "moves".

It is wired for Thunderwave today (via activities/forced_movement.py), so a Thunderwave cast that any target fails now produces CombatantMoved events your renderer must tolerate. Forced movement provokes no opportunity attack.

C16-S07's scenario seed was re-pinned from rng_seed=7 to rng_seed=1: seed 7 could never fail the save (natural 13 vs DC 10), and the catalog itself flagged 7 as a placeholder for the implementer to replace. No assertion changed.

Vision & light (C16b)

Entirely opt-in: a GridScene with no lighting data behaves exactly as in v0.5. GridScene gained three optional fields:

Field Meaning
lighting per-cell LightLevel (bright / dim / dark)
default_lighting the level for every unlisted cell (default "bright")
obscurement_cells per-cell Obscurement (light / heavy) — fog, foliage

GridTopology.can_see(a, b, senses) answers whether a viewer at a perceives b, reading the scene data together with the viewer's projected senses (darkvision, blindsight, truesight). Tremorsense is deliberately not sight — SRD 5.2 defines it as detecting location through vibration, which does not satisfy "a target you can see".

_target_visibility_maps feeds the result into the attack resolver in both directions as the unseen AdvantageSource: a hero without darkvision attacking into a dark cell rolls at Disadvantage, and an attacker the target cannot see rolls at Advantage. When neither can see the other the two cancel to normal per SRD §Advantage and Disadvantage. AttackRolled gained two new additive fields, advantage_sources: list[AdvantageSource] and disadvantage_sources: list[AdvantageSource] — the directional split, so mutual unseen now reads ["unseen"] on each list rather than the old sources = ["unseen", "unseen"] merge (sources itself is unchanged, still the union of both directions, for back-compat).

No light sources are modelled (torches, Light, Darkness), and darkness does not emit the Blinded condition; those are C18.

Behavioral deltas (C16b consumers)

A second commit batch on top of the base vision model above wires a composite "can see" predicate (orchestrator.py::_combatant_can_see — Blinded viewer, Invisible target, blindsight/truesight reach, else the scene model above) into every other SRD 5.2 "can see" conjunct. Each bullet is a new seeded-stream activation a host may not have seen before:

  • Dodge. SRD 5.2 "any attack roll made against you has Disadvantage if you can see the attacker" is now conditioned — a Blinded dodger no longer imposes the disadvantage, at both the regular-attack context sites and on opportunity attacks.
  • Invisible. The "unless a creature can somehow see you" carve-out now actually pierces: an Invisible creature within a viewer's blindsight or truesight range, with line of sight, no longer benefits from the Invisible-derived advantage/disadvantage against that viewer. Darkvision never pierces. This also governs a creature hidden via Hide, since Hide grants Invisible.
  • Frightened. The attack-roll disadvantage now only applies while the Frightened creature can see a known, living, tracked fear source — an unknown, dead, or untracked source keeps the SRD-conservative disadvantage (can't prove it's out of sight). Separately, a "move" intent is checked per step: if ANY single step of the walked path reduces the distance to a visible fear source, the whole move is rejected with the new MoveFailed.reason = "frightened" Literal member (SRD 5.2 "You can't willingly move closer to the source of fear") — even if the path's net displacement is neutral or away from the source overall.
  • Hide. The gate now also enforces "you must be out of any enemy's line of sight" — scanned per living, non-Incapacitated hostile via the composite predicate — but only when the hider's own cell lacks Three-Quarters/Total cover, since per-cell cover is omnidirectional and already breaks every line of sight. A failing scan raises IntentRejectedError("target_invalid") with zero d20 draws, same as the pre-existing cover/obscurement gate. Darkness on the hider's own cell now also satisfies the "Heavily Obscured" half of the gate (SRD 5.2 glossary: "An area of darkness is Heavily Obscured"), via the new SpatialTopology.light_on_cell(cell) -> LightLevel method both topology backends implement.
  • Opportunity attacks. The "a creature that you can see leaves your reach" TRIGGER is now honoured in both directions, gated on the composite _combatant_can_see: a reactor who cannot see the mover spends no Reaction and fires no attack at all (previously the AoO fired unconditionally once the reach/zone gates passed). Separately (plan ruling R4), the AoO roll's unseen AdvantageSource row — "mover can't see reactor" — reads raw scene vision (SpatialTopology.can_see) rather than the composite, exactly as _target_visibility_maps does for the regular Attack path, so a Blinded or Invisible mover is not double-tagged with both unseen and its own condition:* source.

None of this is free of draw perturbation: activities/d20.py draws one d20 in normal mode but two under advantage/disadvantage, so removing (or adding) one of these new gates' AdvantageSource rows can flip a roll's mode between one and two d20 draws — and a suppressed AoO trigger removes a draw outright. Either way, every later draw in the combat shifts. A seeded scenario diverges from a v0.5 replay only if one of the new conditions (Blinded dodger/ attacker, Invisible target within a pierce range, a Frightened creature that regains or loses sight of its source, a Hide attempt now blocked by an enemy's line of sight, or an AoO reactor that cannot see its mover) is actually active somewhere in it; a scenario that never activates any of them is byte-identical.

Re-pinned tests

Three, all pinning old behaviour that this cluster deliberately replaced, and none weakened:

  • tests/test_grid_topology.py::test_has_line_of_sight_ignores_blocked_cells_not_walls::test_has_line_of_sight_false_through_a_blocked_cell — inverted, because blocked cells now block sight.
  • tests/test_orchestrator_grid_combat.py::test_pc_move_to_non_adjacent_cell_is_rejected::test_pc_move_to_non_adjacent_cell_walks_the_path_in_one_intent — a non-adjacent reachable destination now succeeds; the residual not_adjacent case is pinned by test_move_to_own_cell_or_without_destination_keeps_not_adjacent.
  • tests/test_grid_topology.py::test_cover_between_ignores_cover_on_the_endpoints_themselves::test_cover_between_ignores_cover_on_the_origin_cell — the target-cell assertion inverted from == "none" to == "total", because a cover_cells tag on the target's own cell now counts (see "Walls, blocked cells and creatures form one obstruction model" above). The origin half of the old assertion is unchanged and still pinned.

Plus C16-S07's rng_seed placeholder (7 → 1), described above.

Conditions are enforced (C12)

Every SRD 5.2 condition now changes combat resolution. Hosts that seeded conditions purely for narration will see behavioural deltas:

Condition New behaviour Host-visible surface
Incapacitated, Paralyzed, Stunned, Petrified, Unconscious action / bonus-action / reaction intents rejected; pass and move still accepted; reactions and monster actions skipped IntentRejectedError(reason="actor_incapacitated")
Grappled, Restrained, Paralyzed, Petrified, Unconscious Speed 0: movement_remaining projected to 0, Dash adds 0 MoveFailed(reason="speed_zero")
Exhaustion -2 × level on attacks, saves (incl. death saves) and checks; -5 ft × level Speed; no longer ability-check disadvantage AttackRolled.modifier / SaveRolled.modifier / CheckRolled.modifier include the penalty
Prone attacker has disadvantage; attacks against a Prone target have advantage within 5 ft, disadvantage beyond AttackRolled.sources condition:attacker / condition:target
Grappled (attacker) disadvantage against any target other than the grappler (the grappler is resolved from the imposing effect's cast:<slug>:<id> origin; unknown → no penalty) same
Paralyzed, Unconscious (target) any hit from within 5 ft is a Critical Hit AttackRolled.is_crit
Charmed cannot attack the charmer, nor cast_spell an attack/damage/save spell at them AttackFailed / CastFailed(reason="target_is_charmer")
Character at 0 HP gains Unconscious (and, by implication, Incapacitated + Prone); remainder ≥ HP max → Death(reason="instant_kill"); further damage while at 0 HP → one death-save failure Unconscious, ConditionApplied(condition="unconscious"), Death
Revive from 0 HP remains Prone ConditionRemoved("unconscious"), ConditionApplied("prone")
Frightened, Poisoned disadvantage on ability checks (Frightened is new in 0.6 — see below) CheckRolled.advantage / sources
Any condition on an immune creature never attaches: neither Combatant.conditions nor the host-facing active_conditions acquires it, on the seed path or the runtime EffectApplied fold the EffectApplied still fires; no ConditionApplied
Character hydrated at 0 HP gains Unconscious at start_combat, not only on the next hit Combatant.conditions

conditions_grant_disadvantage_on_ability_checks — a PyPI-live pure function — changed its answer for two condition slugs, in opposite directions:

  • ["exhaustion"] now reports no disadvantage (was True). SRD 5.2 replaced the 2014 Level-1 effect with the numeric -2 x level penalty.
  • ["frightened"] now reports disadvantage (was False). SRD 5.2 Frightened: "You have Disadvantage on ability checks and attack rolls while the source of fear is within line of sight." The line-of-sight gate is not modelled for this ability-check half (C16b gated the attack-roll half and the no-approach movement rule; the ability-check half remains a residual — see BACKLOG.md "Conditions — SRD 5.2 rows not enforced"), so the engine currently applies the disadvantage whenever the condition is present — strictly harsher than SRD in the case where the source of fear is out of sight.

project_passive_check_modifiers mirrors both flips. The repeat-save and concentration paths honour auto-fail / Restrained DEX disadvantage / exhaustion. Stunned does not zero Speed (SRD 5.2 dropped the 2014 "can't move" clause) — a Stunned creature may still move, it just cannot take an action.

Two further host-visible details:

  • DeathSaveRolled.roll_total now carries the penalised total rather than the natural die. Identical at exhaustion 0, but a host that reconstructed the die from roll_total will see the difference on an exhausted Character.
  • ConditionApplied / ConditionRemoved now materialise on Combatant.conditions, which reaches well beyond the 0-HP path — e.g. a target knocked Prone by the Topple mastery is thereafter attacked at advantage in melee.

Massive-damage instant death is implemented here, not in C15: the C15-S06 scenario's strict-xfail marker was removed by this cluster.

Determinism: conditions are overwhelmingly flat modifiers, but this cluster does move the RNG draw order in two cases, so a host that replays a seeded scenario across the upgrade may see different natural dice from that point on:

  • An auto-failed save (Paralyzed / Stunned / Petrified / Unconscious on a STR or DEX save) now draws no d20 at all — including on the end-of-turn repeat-save path (_run_end_of_turn_saves) and the concentration path, which C12 taught the auto-fail projection. Where 0.5 drew a die on those paths, 0.6 draws none, so every subsequent draw in that combat shifts by one.
  • A Frightened or Poisoned actor making a typed check activity now draws two dice and keeps the lower (the F2c disadvantage consumer above), where 0.5 drew one.

Everything else — the exhaustion penalty, the Speed projections, the auto-crit, the Charmed and Incapacitated gates — adds or removes no draw. (A blocked intent naturally removes the draws the resolution it prevented would have made.)

Re-pinned tests

Each of these pins a behaviour SRD 5.2 changed relative to what the engine shipped; none was weakened.

  • tests/test_rules_conditions.py::test_disadvantage_on_ability_checks — the exhaustion parameter was dropped from the disadvantage list (SRD 5.2 Exhaustion is the numeric -2 × level D20 Test penalty, not disadvantage); test_exhaustion_no_longer_projects_check_disadvantage pins the new behaviour explicitly.
  • tests/activities/test_resolve_check_activity.py::test_condition_disadvantage_merges_with_the_projected_modifiers[exhaustion-…] — expectation flipped TrueFalse, for the same sentence.
  • tests/e2e/test_c12_conditions.py — all six @xfail_cluster(12, …) markers removed (C12-S01…S06 now pass).
  • tests/e2e/test_c15_attack_rules.py::test_c15_s06_massive_damage_triggers_instant_death_for_a_character@xfail_cluster(15, …) removed; the massive-damage rule landed with C12's 0-HP fold rather than with C15.
  • apps/demo SHOWCASE_SCRIPTS["hold-the-line"] gained a second round. The scenario proves a concentration save under fire; the Paralyzed bandit captain can no longer attack, so the pressure now comes from the two bandits and takes one more round to land. A good illustration of the shape of delta a host should expect: no API changed, but a scripted encounter's timeline did.

C22 — dataset-backed mechanics (Magic Resistance, ignore_cover, magical weapons, target-cell cover)

Who moves: a seeded combat in which (a) a monster_template_slug monster with Magic Resistance (34 in the corpus: pit-fiend, balor, …) makes a save against a spell — it now draws two d20s and keeps the higher; where the target ALSO has disadvantage on the save (e.g. Restrained), the trait instead cancels to normal and one fewer die is drawn; (b) a save activity resolves against a target whose OWN cell is tagged in GridScene.cover_cells — the tag now grants that target cover (+2/+5 to Dexterity saves, +2/+5 AC); (c) a magic weapon (Weapon.magical, e.g. Dagger of Venom, Flame Tongue) or a spell deals B/P/S damage to a target whose damage_resistances list a physical type — the damage is no longer halved (set EncounterMemberSpec.physical_resistances_nonmagical_only=False for a creature whose resistance is unconditional); (d) bandit-captain, doppelganger, chain-devil, scout or ettin use Multiattack — they now swing their real attack mix instead of repeating one attack; an "in any combination" multiattack whose tokens all join is distributed range-aware (a RANGED monster keeps its longest-reach attack; others alternate).

Draw order: unchanged. (a) adds one d20 draw only when the trait is present; (b)–(d) add no draws.

Repinned fixtures: tests/test_save_cover_hydration.py::test_single_target_templated_save_cast_reads_cover_from_the_castertest_cover_tag_on_the_targets_own_cell_reaches_a_templated_single_target_save (Task 6 changed the cover mechanism for a templated single-target save from "read cover from the caster" to "measure from the point of origin", which for a single target IS the target's own cell; GridTopology.cover_between itself is unchanged — the degenerate-origin case is folded in orchestrator.py::_target_cover_map via the new cover_on_cell primitive). tests/activities/test_monster_actions.py:: test_scout_multiattack_any_combination_without_distance_uses_one_of_each (was …fallback_defaults_to_first_sibling: shortsword ×2 → shortsword + longbow). tests/activities/test_monster_actions.py::test_multiattack_fallback_non_ranged_keeps_list_order_on_tie (was test_scout_… — Scout now joins its multiattack precisely under the corpus-wide token-labelling translator change, so the labelless-fallback, non-RANGED tie-break branch needed a synthetic monster fixture instead; retargeted following the file's existing base.model_copy(...) pattern). tests/e2e/test_c22_dataset.py::test_c22_s07_shield_reaction_trigger_is_typed_not_free_text — setup repair: EncounterMemberSpec gained monster_template_slug="goblin-warrior" (catalog defect — a template-less foe cannot attack; assertions untouched; precedent: C16-S07 seed re-pin).

Concentration lifecycle is enforced (C13)

SRD 5.2 §Concentration names five rules the v0.5 engine only half-implemented: a damage-triggered save that could exceed the SRD maximum DC, no one-at-a-time enforcement, no drop on death or an Incapacitated-implying condition, no voluntary drop, and no maximum-duration expiry. All five now resolve on the live combat path.

The damage-triggered concentration save DC is capped at 30. _emit_apply_damage computed dc = max(10, damage // 2) uncapped; SRD 5.2 §Concentration reads "DC 10 or half the damage taken, whichever number is higher, up to a maximum DC of 30" — the cap is explicit, not implied. From v0.6 the formula is dc = min(30, max(10, damage // 2)). This only moves a result when a single damage instance is 62 or more (62 // 2 == 31 is the first value the cap bites on); below that threshold the DC is identical to v0.5. No RNG draw is added or removed.

Concentration is one-at-a-time. SRD 5.2: "You lose Concentration on an effect the moment you start casting a spell that requires Concentration or activate another effect that requires Concentration." From v0.6, casting a second concentration spell (or activating a second concentration effect) while already concentrating on one first drops the prior effect — the caster's concentration_chain no longer grows unbounded. A host that previously relied on a caster stacking multiple concentration buffs (a v0.5 defect, not an SRD-legal state) will see the older effect torn down the moment the new cast lands: ConcentrationDropped(target_id=caster_id, effect_name=<old effect id>), then per affected target EffectExpired(effect_id=<old effect id>, reason="concentration_drop"), then a ConditionRemoved for every condition that dropped effect had installed. Hosts that fold EffectExpired / ConditionRemoved into their own state already have everything they need; hosts that ignored ConcentrationDropped (defined since v0.1, never emitted until now) should start consuming it as the single explicit marker that a cast cascaded a drop rather than merely adding a new effect.

Concentration ends on death and on gaining an Incapacitated-implying condition. _record_death now calls _drop_concentration after recording the kill (Death is emitted, then ConcentrationDropped), so any death path — not only the damage-triggered one — clears the dying caster's concentration (including a Character hydrated into combat already at 0 HP with a pre-seeded concentration effect). Separately, the condition-application fold calls _drop_concentration the moment a combatant first acquires Incapacitated, Paralyzed, Petrified, Stunned or Unconscious (SRD 5.2 Incapacitated: "No Concentration. Your Concentration is broken.") — keyed to the state transition, so re-entering an already-active condition does not re-fire the drop. Both reuse the same ConcentrationDropped + EffectExpired(reason="concentration_drop") + ConditionRemoved cascade as the one-at-a-time case above.

New voluntary-drop intent. SRD 5.2: "The creator can end Concentration at any time (no action required)." IntentType gains "drop_concentration", handled by _handle_drop_concentration: it touches no Action / Bonus Action / Reaction and does not end the turn, and — unlike every other intent — it is listed in _INCAPACITATED_ALLOWED_INTENTS, since an Incapacitated creature still needs a way to voluntarily end a concentration effect that survived some other path (an item-sourced or seeded one, say) without being blocked by the same condition that would otherwise break it automatically. IntentSubmitted(intent_type="drop_concentration") is emitted before the cascade. This is an additive IntentType member — existing exhaustive match/if chains on intent_type in a host must add a branch (or a default) to avoid silently ignoring the new value.

Concentration spells expire at their maximum typed duration. SRD 5.2 §Concentration caps how long a caster can hold an effect regardless of combat length ("up to 1 minute", "up to 1 hour", …), independent of the per-effect rounds counter the round tick already ignores for concentration-flagged effects (F3b). From v0.6, casting resolves the caster's concentration_rounds_remaining[caster_id] from the cast spell's typed Spell.duration at cast time — SRD round = 1, minute = 10, hour = 600, day = 14400 (6 s/round) — and a new engine:concentration-expiry turn_end hook, registered LAST (after engine:timed-effect-expiry), decrements it at the caster's own turn end and calls _drop_concentration(..., reason="duration") when it reaches zero. A spell with a non-metric duration (instantaneous / until-dispelled / special, …) or one cast outside the typed cast path (a monster-cast or a host-seeded concentration effect) gets no cap and remains cascade-governed only — a host-relied-upon seam, not an oversight (C18 owns monster-cast duration caps as a follow-up). A seeded replay that previously held a concentration effect alive past what the SRD duration allows will now see it expire mid-combat, distinguishable from a broken-concentration drop by EffectExpired.reason == "duration" rather than "concentration_drop".

Unrelated delta landing in the same PR: template-less monsters attack. While repairing the C13 e2e scenarios it surfaced that a Monster party member with no monster_template_slug silently passed every turn — the typed-activity cutover dropped the legacy evaluator's spec-level swing without a replacement, even though specs.py still documents attack_bonus / damage_dice / damage_type as a supported fallback. _synthesize_attack_from_legacy_fields now builds one typed AttackActivity per turn from those three fields when no template is set (unparseable damage_dice still no-ops, unchanged). Hosts with template-less Monster party members will see them start attacking; a seeded stream containing a template-less monster's turn moves from that point on.

Catalog setup repairs

  • C13-S01. Bless is an Action cast, so casting it a second time to trigger the drop needs the caster's next turn. The scenario's foe was moved from cell(4, 0) to cell(9, 9) (out of the fighter's reach), and a char:fighter "pass" intent plus an advance_monster_turn call were inserted between the two Bless casts so the second cast happens on the cleric's round-2 turn. No assertion changed.
  • C13-S04. The scenario's concentration spell was swapped from Bless (an Action cast, which would end the turn before the voluntary drop could be demonstrated mid-turn) to the bonus-action Shield of Faith, and a "move" intent to cell(3, 0) was inserted before the mace attack so the cleric is in melee reach. No assertion changed.

Re-pinned fixtures

One. tests/test_turn_lifecycle.py::test_hooks_registered_by_the_engine_are_present_and_ordered gained "engine:concentration-expiry" as the fifth and last entry in the pinned turn_end hook-key list, alongside a new ordering comment: it must run LAST because a same-boundary race with engine:timed-effect-expiry or engine:duration-tick on the same effect is immaterial (_drop_concentration is idempotent against an already-gone chain entry). No other fixture moved.

Action economy: Extra Attack, two-weapon fighting, turn-keeping attacks (C14 Tasks 1–2)

Extra Attack. _attacks_per_action(current) reads the caster's granted feature slugs (_granted_feature_slugs) for extra-attack / two-extra-attacks / three-extra-attacks and returns the single highest matching count (2 / 3 / 4) — SRD 5.2's multiclass non-stacking rule, so a Fighter/Barbarian multiclass carrying both extra-attack tiers is capped at whichever tier grants more attacks, never their sum. A caster with none of the three slugs (or no class_slug at all) gets 1. This count seeds a new per-Action counter, Combatant.attacks_remaining, reset to _attacks_per_action(current) at the start of every Attack action and decremented by one on each resolved swing (_consume_attack_budget). LiveCombatView.turn: TurnCombatView (new, attacks_remaining: int) projects it for hosts, defaulting to TurnCombatView(attacks_remaining=0) when combat has ended or between the current actor's turns.

R1 — a main-hand attack keeps the turn. Through v0.5 every attack ended the turn unconditionally (_end_turn_and_advance was called after every resolved swing). From v0.6, _attack_action_is_spent(current) decides:

current.attacks_remaining <= 0
and _attacks_per_action(current) == 1
and not _twf_window_open(current)

The turn advances only when all three hold: no swings remain this Action, the actor gets exactly one attack per Action, and no two-weapon- fighting off-hand Bonus Action window is open (see below). Note the middle conjunct: a multi-attack actor (_attacks_per_action(current) != 1) never satisfies _attack_action_is_spent through this branch at all, so an attack intent never ends their turn on its own — not just "until the counter reaches zero", but also after it hits zero. An Extra-Attack actor's first swing therefore keeps the turn, as does every subsequent swing including the last one that exhausts attacks_remaining; a single-attack actor whose main-hand weapon is Light also keeps the turn until the off-hand swing fires or its window closes.

Ending a multi-attack turn. Since no attack intent ever ends a multi-attack actor's turn, the host must submit a "pass" intent once it is done acting (out of swings, no more Bonus Action / movement / spells it wants to spend) to advance to the next actor. "pass" is exempt from every action-economy budget check — it is always accepted and always ends the turn, regardless of what has already been spent this turn (final-review fix F1; see _action_economy_gate_failure's docstring). The same applies to any other turn-keeping Action intent (Dash, Disengage, an off-hand swing): a follow-up "pass" is the correct way to close out the turn.

R2 — only the first swing of the Action hard-gates. The pre-C14 hard Action gate (no Action ⇒ reject) still applies to the very first "attack" intent of a turn (not current.attack_action_engaged and not current.action_availableIntentRejectedError("no_action_economy")) — this is what stops a turn-keeping Action intent like Dash or Disengage from being chained into a free attack sequence. Every SUBSEQUENT swing within the same Attack action skips that check: the Action was already paid for (or soft-consumed) by the first swing, so _consume_attack_budget only flips action_available to False on that first swing (spend_action = not c.attack_action_engaged and c.action_available) and leaves it alone afterward. An exhausted attacks_remaining on a later swing now emits a turn-keeping AttackFailed(reason="no_action_economy") — unlike every other Action-costed intent's rejection, this one does not end the turn or look like a fresh "no Action" failure, since the actor may still have a Bonus Action, Reaction, or movement left to spend.

Two-weapon fighting. _twf_window_open(current) is true while a Light main-hand weapon has been swung this turn (light_weapon_swing_slug set), the off-hand swing has not fired (not offhand_attack_spent), and the Bonus Action is still available. _is_offhand_attack_swing classifies a subsequent "attack" intent as the off-hand swing when it names a DIFFERENT Light weapon than the main-hand swing AND either the Attack-action budget is already exhausted (the common case) or the host explicitly asked for it now (PlayerIntent.use_bonus_action=True — interleaving the off-hand swing before a multiattack sequence finishes). Controller ruling: main-action swings take priority — an Extra-Attack actor with budget left swinging a second Light weapon is an ordinary Attack-action swing, not an automatic off-hand attack; the SRD off-hand option is something the wielder chooses to spend a Bonus Action on. _consume_offhand_attack_budget spends the Bonus Action (not attacks_remaining, already decremented by the main-hand swing) and sets offhand_attack_spent so _twf_window_open closes. SRD 5.2 §Two-Weapon Fighting: the ability modifier is added to the off-hand hit's damage only if it is negative; positive modifiers are omitted.

Sneak Attack's once-per-turn flag already resets at the start of each new turn (sneak_attack_spent_this_turn: False, folded alongside the attack counter reset), so a multi-swing turn can only trigger the rider on one qualifying swing, as SRD 5.2 requires.

Determinism. No RNG draw is added or removed by the counter/gate changes themselves — every swing still draws through the pre-existing attack roll path. A seeded replay that previously ended a turn after one attack now continues resolving further "attack" / off-hand intents the host submits, so a scenario script written for the old one-attack-per-turn contract must submit the additional swings explicitly or the actor's turn simply sits open on attacks_remaining > 0 until the host passes.

Catalog setup repairs

  • C14-S06. Seed 23 rolled a natural-1 off-hand fumble, so the scripted scenario (both the main-hand and off-hand swing must land) could never pass regardless of engine correctness. Swapped to rng_seed=24, the nearest seed at which both swings hit. No assertion changed.
  • C14-S04. Seed 5 rolled a natural-20 grapple save (DC 15, modifier 0), so the target never acquired Grappled and the scripted escape_grapple/shove/stand_up sequence was unreachable. Swapped to rng_seed=1, the nearest seed below 5 that produces grapple FAIL / escape SUCCEED / shove FAIL as the scenario requires. No assertion changed.

Dodge, Help, Hide (C14 Tasks 3–5)

Three IntentType members that existed since v0.1 but resolved as no-ops (dodge, help, hide were accepted and immediately ended the turn with no mechanical effect) now have live dispatch handlers.

Dodge. _set_dodging(live, actor_id) marks the actor dodging until the start of their next turn: attackers targeting them roll with Disadvantage (_dodge_benefit_active, tagged dodge in sources — this is also the source the opportunity-attack path picks up, see below) and their own Dexterity saves roll with Advantage. The benefit is lost the instant the actor becomes Incapacitated or its Speed drops to 0 (SRD 5.2: Dodge requires being able to move). No "can see the attacker" check yet — no vision model. Superseded by C16b — see Vision & light (C16b).

Help (assist-an-attack-roll flavor only). live.help_grants[target_id] accumulates helper ids; the next ally attack roll against that target within 5 ft of the helper resolves with Advantage, consumed by that one attack roll (hit, miss, or cancelled to normal) and otherwise expiring at the start of the helper's own next turn. The ability-check flavor (granting Advantage on an ally's ability check) is NOT implemented — there is no check-advantage producer on the check-resolution path — and Help does not gate on "the target is an enemy of the helper" (SRD 5.2 phrasing for the check flavor); both gaps are tracked in BACKLOG.md.

Hide. Gated on the hider's own cell being behind Three-Quarters/Total cover or Heavily Obscured (IntentRejectedError("target_invalid"), zero d20 draws, if not); on success, rolls a DC 15 Dexterity (Stealth) check (CheckRolled) and, if it succeeds, emits ConditionApplied(condition="invisible") and records the hider in live.hidden_entities, ending the moment the hider makes an attack roll or casts a spell with a Verbal component. The "out of any enemy's line of sight" conjunct is deferred to C16b (no per-enemy vision scan wired here yet, and this predates the new darkness/Heavily-Obscured gate too). Superseded by C16b — see Vision & light (C16b). Controller ruling — Hide costs no Action-economy budget at all (the same turn-keeping shape as drop_concentration/Dash), diverging from SRD 5.2's Action cost: an Action-consuming Hide would make the approved S02 catalog script (hide, then attack, in the same turn) unsatisfiable against R2's hard Action gate on the first attack swing. This divergence is deliberate and recorded in BACKLOG.md, not a defect.

Determinism. Hide's Stealth check and Dodge draw no dice beyond what they explicitly roll (Hide: one d20 on a gate pass, zero on a gate rejection; Dodge: zero, it is a flag flip). A seeded scenario that never submits "dodge", "help", or "hide" is unaffected; one that does will see the new CheckRolled (Hide) event and the behavioural effects above where it previously saw only a turn-ending no-op.

Grapple, Shove, stand_up (C14 Tasks 6–7)

Four intents new to IntentType: "grapple", "shove", "stand_up", "escape_grapple".

Shared save primitive. _roll_unarmed_option_save(live, attacker, target) is the SRD 5.2 Unarmed Strike save both Grapple and Shove make: the target saves with whichever of STR/DEX has the higher save modifier (tie → STR, Controller ruling R3 — the engine has no player-facing choice prompt), against DC 8 + attacker's STR modifier + proficiency bonus (_unarmed_option_dc). Auto-fail conditions (Paralyzed, Stunned, Petrified, Unconscious) and the Exhaustion D20-Test penalty apply with zero extra draws, mirroring _run_end_of_turn_saves; there is no advantage source of its own. Emits SaveRolled.

Grapple. On a failed save, applies the engine-owned Grappled condition with the escape DC and the imposing effect's id stored on the ActiveCondition (_emit_grapple_condition_applied), so escape_grapple later reads the STORED DC rather than recomputing it. Always ends the turn.

Shove. On a failed save: Prone (default) or a 5-ft forced push away from the shover via push_combatant (CombatantMoved(forced=True)), per the new PlayerIntent.shove_push: bool = False — the shover's pre-declared choice, since no player-facing choice prompt exists at this seam. No damage either way. Always ends the turn.

escape_grapple. SRD 5.2 "Ending a Grapple": the Grappled creature rolls a Strength (Athletics) or Dexterity (Acrobatics) check against the STORED escape DC. Controller ruling R3 again picks the higher modifier (tie → Athletics/STR). The Exhaustion D20-Test penalty applies. Emits CheckRolled; on success, the Grappled condition is removed. Always ends the turn.

stand_up. SRD 5.2 Prone, "Restricted Movement": spends half Speed (rounded down) from the movement budget to end the actor's own Prone condition. Touches no Action/Bonus Action budget — turn-KEEPING, like Hide/Dash. Gates, in order, each a no-draw IntentRejectedError: the actor must be Prone ("target_invalid"), effective Speed must be nonzero ("speed_zero"), and the remaining movement budget must cover the cost ("insufficient_movement").

Out of scope for C14 (tracked in BACKLOG.md): Grapple/Shove's size gate ("no more than one size larger than you") and Grapple's free-hand gate are unmodelled — neither option blocks today regardless of relative size or whether the attacker's hands are full; a forced move that separates a grappled pair beyond reach does not auto-release the condition; a redundant second Grapple attempt on an already-Grappled target appends an orphaned, inert ActiveEffect rather than being rejected or renewing the existing one; and a grappler removed from combat by a path that never applies Incapacitated does not auto-release its victim (_release_grapple_victims_of fires only from the Incapacitated fold — SRD 5.2 names only that case, so this is RAW-arguable rather than a clear defect).

Determinism. Grapple/Shove each draw one save d20 in normal mode (two under advantage/disadvantage, none on an auto-fail); escape_grapple draws one check d20 the same way; stand_up draws nothing. A seeded scenario that never submits one of these four intents is unaffected.

Engine-rolled initiative and Surprise (C14 Task 8)

PartyMemberSpec.initiative and EncounterMemberSpec.initiative widen from int to int | None. An explicit int always wins — zero RNG draws, the legacy host-supplied path, byte-identical to v0.5. None opts into an engine-rolled d20 + DEX modifier (_resolve_initiative), drawn from the combat's seeded RNG in spec order — party members first, then encounter members — before any other combat draw (attacks, saves, checks). A new is_surprised: bool = False field on both spec types imposes Disadvantage on that roll (SRD 5.2 §Surprise); a seeded incapacitated-implying active_effects status on the spec does too (SRD 5.2's Incapacitated glossary entry — "Initiative rolls" are inherently pre-combat, so this reads the SEEDED status, not a live condition acquired after combat starts). The host remains responsible for deciding who is surprised — the engine only applies the roll penalty once told. int-specced entities draw zero Initiative dice regardless of is_surprised (the field is only consulted when initiative is None).

Determinism. A combat where every spec supplies an explicit int initiative draws exactly as many dice as v0.5 — none — and its stream is byte-identical. A combat with one or more None-initiative specs draws one additional d20 per such spec (two under Surprise/Incapacitated Disadvantage), consumed BEFORE any other roll in the combat, shifting every subsequent draw. IntentType gained no new members for this task; only the two spec fields are new.

Opportunity attacks go through the shared d20 primitive (C14)

orchestrator.py's two opportunity-attack fire sites (_fire_pc_opportunity_attacks_on_move / _fire_monster_opportunity_attacks_on_move) rolled a raw live.rng.randint(1, 20) and hard-coded AttackRolled(advantage="normal"), bypassing activities/d20.py::roll_d20_test entirely — the only remaining attack-shaped roll in the engine that did not go through the F2 primitive. Both sites now assemble the same typed AdvantageSources the regular Attack activity uses (condition rows via rules/conditions.py::conditions_grant_advantage_on_attack, tagged condition:attacker / condition:target; the Dodge action's disadvantage via _dodge_benefit_active, tagged dodge) and fold SRD 5.2 Exhaustion's flat d20_test_penalty into the modifier, then roll through roll_d20_test. AttackRolled.natural / modifier / sources — previously left at their None / [] defaults on every opportunity attack — are now populated, and advantage reports the real resolved mode instead of an unconditional "normal".

Determinism: a condition-free opportunity attack still draws exactly ONE d20 in normal mode (the same rng.randint(1, 20) call roll_d20_test makes internally), so every pre-existing seeded scenario without an active condition, Dodge, or Exhaustion on the reactor/mover pair is byte-identical. The seeded stream shifts only when advantage or disadvantage is actually active on the opportunity attack — Prone/Grappled/Restrained/etc. on either combatant, or a Dodging mover — which now draws two d20s and keeps the higher/lower, matching how the regular Attack activity has behaved since F2b.

The stale 2014-flavored docstring trigger text ("a hostile creature that you can see moves out of your Reach") on both fire sites was also updated to the verbatim SRD 5.2 wording ("a creature that you can see leaves your reach using its action, its Bonus Action, its Reaction, or one of its speeds"). The "you can see" visibility gate itself is still not modeled on this path — no vision seam is wired here yet — and remains deferred to C16b, same as the regular Attack path's ctx.attacker_unseen_by / ctx.target_unseen rows. Superseded by C16b — see Vision & light (C16b).

Re-pinned fixtures

None. The full engine suite (1215 passed, 48 xfailed) is green with no fixture value re-pinned: no seeded scenario exercises an opportunity attack with an active condition, Dodge, or Exhaustion on the reactor or mover, so every existing AoO scenario's single-draw stream is unchanged.

C15 — attack rules

Seven tasks close the attack-resolution gaps flagged in the 2026-08-26 audit: weapon proficiency, range tiers, thrown weapons, Ranged Attacks in Close Combat, Heavy, versatile grip, damage attribution, the crit-at-0-HP clause, the Loading property, and all eight 2024 weapon masteries. Nothing is removed and no signature changes shape; every new field is optional and every new behaviour is either additive or gated behind a sentinel that reproduces the pre-C15 default when a host does nothing.

Weapon proficiency is a real gate (C15 Task 1)

SRD 5.2 §Weapon Proficiency: "Anyone can wield a weapon… but you must have proficiency with it to add your Proficiency Bonus to an attack roll you make with it" — Proficiency Bonus is omitted, never subtracted, when unproficient. orchestrator.py::_is_proficient_with_weapon now reads Combatant.weapon_proficiencies (widened from list[str] to list[str] | None) and gates whether ctx.is_proficient_attack adds the Proficiency Bonus in activities/attack.py::_attack_bonus.

The R1 sentinel is the load-bearing rule for host compatibility: Combatant.weapon_proficiencies is keyed off whether the field was ever assigned on PartyMemberSpec, via Pydantic's model_fields_set, not off emptiness.

  • A host that never sets PartyMemberSpec.weapon_proficiencies at all projects to Combatant.weapon_proficiencies is None, and the gate assumes proficient — this is the legacy behaviour, and it is what every pre-C15 fixture and every existing host integration does today, so nothing in a seeded stream moves unless a host opts in.
  • A host that sets it — even to an empty list, "proficient with nothing" — projects to a real list[str], and the gate enforces proficiency by the weapon's weapon_category (e.g. "simple_melee") or exact slug.
  • Monsters never carry this field explicitly, so Combatant.weapon_proficiencies stays None for every monster, matching SRD 5.2's "a monster is proficient with any weapon in its stat block."

Same fix, same sentinel, one pre-existing bug closed along the way. Combatant.attack_bonus carried the identical "was it ever explicitly set" ambiguity, but as a plain int = 0 rather than an Optional — so a PartyMemberSpec that never set attack_bonus (the common case for a bare-ability-score PC with no precomputed sheet) was silently pinned to a literal 0 to-hit override, bypassing the real ability-modifier + proficiency-bonus computation entirely. C15 widens Combatant.attack_bonus to int | None = None and activities/attack.py::_attack_bonus already treated None as "no override" (it exists for scroll/item fixed-bonus casts), so an unset PC now correctly falls through to the real computation. A host-supplied value — including every monster's, always threaded as a concrete int — is unaffected; this is byte-identical to every pre-C15 fixture that supplies attack_bonus explicitly. One caveat: the engine's own build_party.py host-party-building helper always sets attack_bonus explicitly (from the pre-computed character sheet it builds), so a party built through that path never hits the None branch either way — this fix does not change its output; that is pre-existing behaviour, unchanged by C15. Type-widening caveat: Combatant.attack_bonus is now int | None — runtime-safe for a host that always passes a concrete int, but a host that reads combatant.attack_bonus back out (e.g. to feed it into a new PartyMemberSpec(attack_bonus=...)) will have downstream mypy flag the int | None → int read; narrow or default it at that read site.

Determinism: proficiency and the attack_bonus fix change only the modifier added to an attack roll's natural die, never whether a die is drawn — draw-free.

Range tiers, thrown weapons, Ranged Attacks in Close Combat, Heavy (C15 Tasks 2–3)

orchestrator.py::_weapon_attack_range_ft now returns a (normal, max) tuple instead of a single reach/range number. An attack beyond normal but within max was previously flatly rejected (AttackFailed(reason= "out_of_range")); it now resolves at disadvantage instead, tagged "range:long" on AttackRolled.sources — a formerly-rejected attack is now legal and draws a fresh disadvantage roll it never drew before. A melee weapon carrying the Thrown property (dagger, handaxe, javelin, …) can now also attack beyond its melee reach using its own thrown range bands (SRD §Thrown) — previously rejected outright as an out-of-reach melee swing, now a legal ranged attack in its own right; the governing ability stays Strength-or-Dexterity-if-finesse even when thrown (SRD: throwing does not change which ability governs a Finesse weapon's attack).

SRD 5.2 "Ranged Attacks in Close Combat": "you have Disadvantage on the roll if you are within 5 feet of an enemy who can see you and doesn't have the Incapacitated condition." orchestrator.py::_hostile_adjacent_to_attacker scans for a living hostile within 5 ft of the attacker (not the target — the SRD says "an enemy", not "an enemy other than your target"), excluding an Incapacitated hostile and one that cannot see the attacker (SpatialTopology.can_see, using the hostile's own senses — the same pre-C16b lit-scene-only visibility predicate the Vision & Light row already documents; see BACKLOG.md). A qualifying hostile appends "ranged_in_melee" disadvantage to any effectively-ranged attack (a true ranged weapon, or a Thrown melee weapon used beyond reach). Superseded by C16b — see Vision & light (C16b).

The Heavy property (SRD 5.2): a wielder with a raw Strength score below 13 rolls a Heavy weapon's attack with disadvantage, appended as "trait" on AttackRolled.sources — the SAME token the Vex and Sap mastery riders below also use, since AdvantageSource is a closed Literal and none of the three warranted their own member. A host reading sources cannot distinguish a Heavy-weapon disadvantage from a Sap mark, or a Vex advantage from a flags.advantage.attack trait grant, without deriving it from other combatant state — flagged for a future cluster if per-cause attribution on "trait" becomes necessary.

Determinism: a new disadvantage state draws a second d20 only when it is newly active on a pre-C15-legal attack; a middle-tier or thrown-at-range shot that was previously rejected draws its own fresh roll where none existed before. Every other in-range, non-Heavy attack is unaffected.

Versatile grip, damage attribution, crit-at-0-HP (C15 Task 4)

SRD 5.2 Versatile: "The weapon deals that damage when used with two hands to make a melee attack." PlayerIntent.two_handed: bool = False is the attacker's pre-declared grip choice for that one attack (no player-facing choice prompt exists at this seam); when True and the weapon carries versatile_damage on an actual melee swing (a two-handed grip declared on a thrown/ranged use of the same weapon is ignored, per the SRD's "to make a melee attack"), activities/attack.py rolls the versatile damage die instead of the one-handed die. Caveat: the grip choice is unguarded against the weapon's Light property — no Light+Versatile weapon exists in the shipped corpus today, so the combination is untested, but a host-authored weapon carrying both properties could declare a two-handed grip on what SRD 5.2 treats as a one-handed weapon; nothing in the engine rejects it.

DamageApplied gains two fields. source_id: str | None attributes a damage event to the weapon slug that dealt it, a synthesized activity id (the legacy-monster-attack path), or "mastery:<slug>" for a mastery proc (e.g. "mastery:graze") — closing the long-standing "damage is not attributed" gap for the weapon-attack path specifically; spell, save, and heal-adjacent damage paths still report source_id=None (a C17+ seam; see BACKLOG.md). Opportunity attacks also carry source_id and is_crit (F2 of the final-review fix wave): an OA always resolves through the reactor's legacy attack_bonus/damage_dice fields, never a typed weapon/activity, so it is attributed "synth:legacy-swing" — the same id _synthesize_attack_from_legacy_fields uses. is_crit: bool = False marks a critical hit. SRD 5.2 §Damage at 0 Hit Points: "if the damage equals or exceeds your Hit Point maximum, you die instantly… [otherwise] if you're already at 0 Hit Points, a Critical Hit counts as two failures" — a critical hit against a combatant already making death saves now records two death-save failures instead of one, reading is_crit off the same damage event.

Determinism: grip selection changes only which damage die is rolled (still one roll); source_id/is_crit are attribution, not new draws — both draw-free. The crit-at-0-HP clause consumes no extra die; it changes how many death-save failures a damage-triggered fold records.

Loading property (C15 Task 5)

SRD 5.2 Loading: "You can fire only one piece of ammunition from a Loading weapon when you use an action, a Bonus Action, or a Reaction to fire it, regardless of the number of attacks you can normally make." The engine reads this as one fire per turn, per actor (not per weapon, matching the SRD's "you" framing) — no PC reaction-attack path exists yet, so the action/Bonus-Action/Reaction distinction collapses to the turn boundary. Combatant.loading_weapon_fired_this_turn: bool = False is set after any resolved main-hand or off-hand swing with a Loading weapon and reset at the actor's own TurnStarted. A second Loading-weapon shot in the same turn is rejected pre-resolution with AttackFailed(reason="weapon_already_fired") — a new reason on the existing closed Literal, so an exhaustive host-side match/if on AttackFailed.reason must add a branch (or a default).

Gate ordering matters. The pre-resolution reject-gate chain in orchestrator.py checks the Loading cap before the Charmed-target gate (SRD 5.2 "You can't attack the charmer…"): a Charmed actor whose Loading weapon already fired this turn now sees "weapon_already_fired", not "target_is_charmer", if the second shot also happens to target their charmer. Both are legitimate rejections of the same intent; a host that branches narration on the specific reason should be aware Loading wins the tie.

Determinism: a rejected shot is a pre-resolution AttackFailed — no d20 is drawn either way, so this is draw-free.

All eight 2024 weapon masteries (C15 Tasks 6–7)

activities/mastery.py resolves all eight SRD 5.2 weapon masteries. Four resolve directly inside the attack activity:

  • Graze — on a miss, deals flat damage equal to the attacker's governing ability modifier (no roll, nothing when the modifier is ≤ 0), of the weapon's damage type, routed through apply_damage so resistance/ immunity/vulnerability still apply.
  • Topple — on a hit, the target makes a Constitution save against 8 + proficiency + governing-ability modifier (through the same roll_save primitive the save activity kind uses, honoring force_save_d20); on a failure the target is knocked Prone, now gated by the shared is_condition_immune helper — a prone-immune target's save still rolls and still emits SaveRolled, but the ConditionApplied is suppressed (previously an ungated emit site: a prone-immune creature could be knocked prone by a Topple hit).
  • Vex and Sap report through ActivityResolutionContext.mastery_procs on any hit that deals damage (Vex) or any hit at all (Sap); the orchestrator folds the proc into live combat state after resolution: Vex grants the attacker Advantage against that same target for their next 2 attack-roll turns (a live.vex_grants[attacker_id][target_id] counter, decremented — one-use, popped on consumption — at the attacker's own turn end, appended to the lifecycle registry as a turn_end hook, engine:vex-expiry, ordered LAST among that hook's registrants); Sap marks the target with Disadvantage on its own attacks until the source attacker's next turn start (live.sap_marks[target_id] = attacker_id, one-use, popped on consumption, cleared at the source's TurnStarted). Both ride the "trait" AdvantageSource token (see the Heavy section above for the token-collision caveat).

The remaining four report through the same mastery_procs channel but resolve outside the single-attack activity:

  • Slow applies a flat, non-stacking −10 ft Speed penalty (live.slow_marks[target_id], a set of contributing attacker ids — multiple Slow hits from different attackers do not compound the penalty past −10 ft) on any hit that deals damage, cleared at the source attacker's own turn start; a "dash" intent's Speed math reads the penalty already applied, so a Slowed creature's Dash still adds its reduced Speed, not the un-Slowed value.
  • Push forces a move of exactly 10 ft straight away from the attacker via push_combatant on any hit — controller ruling: always the full 10 ft, unconditionally. SRD 5.2 gates this at "if it is Large or smaller"; creature size is not a Combatant attribute yet, so the size gate is not modelled and every target is pushed (see BACKLOG.md, joining the existing Grapple/Shove size-gate entry).
  • Cleave chains one additional attack-and-damage roll against a second target, resolved inline in activities/attack.py (the chain needs the full attack machinery — a fresh to-hit roll, its own crit/damage roll — not just a rider). The candidate is deterministic: the nearest living hostile (Chebyshev distance from the attacker) that is within 5 ft of the FIRST target and within the attacker's own weapon reach, ties broken by entity_id; the chained roll uses the candidate's own full per-target geometry (its own cover, dodging, help-advantage, etc. — not the first target's). Gated once per turn (Combatant.cleave_spent_this_turn, reset at the actor's turn start) and never re-procs off its own chained hit. A chained roll against a candidate that is separately holding a Vex grant or a Help advantage from elsewhere consumes it exactly as a normal attack roll against that target would.
  • Nick is pure action economy, no in-attack rider: SRD 5.2 — "When you make the extra attack of the Light property, you can make it as part of the Attack action instead of as a Bonus Action." The off-hand swing with a Nick weapon spends no Bonus Action and does not require one to be available. This is an amendment beyond the strict SRD reading (R1 controller ruling), not a bug: _offhand_window_open — which governs whether a main-hand attack keeps the turn open (C14's R1 rule) — now stays open after ANY Light main-hand swing, even when the Bonus Action has already been spent elsewhere, because the orchestrator cannot know until the next intent arrives whether the host is about to submit a Nick off-hand swing (which needs no Bonus Action) or a non-Nick one (which does, and will be rejected if it's already spent). A host must submit an explicit "pass" intent to end such a turn once it does not intend to make the extra attack — the turn will not auto-close on its own the way a turn with no open Light window does. This is the single most consequential turn-shape change in C15; watch for it if a scripted or automated host submits a fixed intent sequence per turn.

Determinism: Graze/Vex/Sap/Slow/Push/Nick add no new roll to an already- legal attack — draw-free. Topple was already rolling its save pre-C15; only whether the resulting ConditionApplied is gated changed. Cleave is the one new draw: a successful proc consumes one additional attack roll plus its damage roll that did not exist before.

Catalog script repairs (S05, S07)

Two scenarios in tests/e2e/test_c15_attack_rules.py needed script repairs to stay runnable under C14's now-live action economy, applied under the catalog repair protocol (assertions byte-identical, setup/script lines adjusted, flagged for the maintainer):

  • S05 (test_c15_s05_loading_weapon_second_shot_rejected_for_the_right_reason) — the hero spec gained character_level=5, class_slug="fighter" so the turn survives the first shot under C14's real action-economy accounting; without it the second shot's rejection reason was masked by IntentRejectedError("not_actor_turn") rather than exercising the Loading gate the scenario pins. The contextlib.suppress(IntentRejectedError) around the second shot is a vestigial artifact of that pre-repair state — harmless (no assertion depends on it) and left in place rather than removed as an unrelated cleanup.
  • S07 (test_c15_s07_vex_mastery_grants_advantage_on_next_attack) — a "pass" intent was inserted after each of the hero's attacks to close the turn C14's two-weapon-fighting window otherwise leaves open (a Light main-hand swing keeps the turn alive pending an off-hand swing that this script never makes), so the monster's turn advances when the scenario expects it to.

Three C14 two-weapon-fighting unit fixtures were also repaired as a consequence of this same TWF-window interaction: the dagger off-hand swing was swapped for a handaxe (the main-hand weapon was left as-is — shortsword or dagger, per fixture), because the dagger carries the Nick mastery and its off-hand-swing exemption changed which fixtures those tests needed to stay pinned to their original intent (a plain TWF window, not a Nick one).

Re-pinned tests

test_damage_while_at_zero_hp_... (the crit-at-0-HP fixture) was repaired: the attacker moves beyond 5 ft and re-attacks with a shortbow so the scenario exercises an ordinary ranged crit rather than the Paralyzed/Unconscious auto-crit-within-5-ft clause the original fixture accidentally triggered, which would have made the crit-at-0-HP assertion vacuous. The deferred- masteries parametrized test was rewritten from its pre-C15 "N of 8 deferred" shape to an "8 of 8 live" assertion. The turn-lifecycle hook-order pin gained engine:vex-expiry as the last-registered turn_end hook.

C17 — spell slots, rests and upcasting

SRD 5.2 §Spell Slots / §Multiclassing / §Pact Magic / §Ritual Casting land as real engine mechanics: per-class and multiclass slot-table derivation, a second Pact Magic pool, rest-based slot recovery and Exhaustion reduction, upcast target-count scaling (not just dice), and out-of-combat Ritual resolution. Nothing is removed and no existing signature changes shape — every new field/kwarg is additive with a None/{} default that reproduces v0.5 behaviour byte-for-byte. C17 DOES change results for hosts that carry casters, readied Counterspell/Shield reactions, or count-bearing spells (Magic Missile).

Spell slots, rests and upcasting (C17)

1. Magic Missile now emits N DamageApplied per cast, not one. SRD 5.2: "You create three glowing darts of magical force. … The spell creates one more dart for each spell slot level above 1." PlayerIntent.target_ids: tuple[str, ...] | None lets a host aim individual darts; a plain target_id (the pre-C17 single-target shape) still works and fans every dart at that one target. A damage-kind activity whose target.affects.count formula references @item.level (R5 — via the new spellcasting.count_scales_with_cast_level guard) now resolves N separate DamageApplied events sharing ONE rolled damage instance (the dice are rolled once, applied N times) — 3 at slot level 1, +1 per slot level above 1, total damage scales ×N. This changes HP outcomes for every Magic Missile cast in an existing seeded replay from this point on. It also changes the draw count for an UPCAST cast specifically — see Determinism below — while base-level Magic Missile keeps its one draw. Targeting more entities than the resolved count, or an unknown target_ids entry, is rejected before the slot is spent (CastFailed(reason="target_invalid")). This validation (_reject_over_ count_targets) also checks the singular target_id, not just target_ids — so for EVERY count-bearing spell in the corpus (Bless, Bane, Hold Person, Command, Charm Person, and 20+ others whose target.affects.count references @item.level), a cast naming a target_id that is absent from live.initiative now fails with CastFailed(reason="target_invalid") and the slot is preserved; pre-C17 that same cast resolved against an empty target list and SPENT the slot.

2. An armed Counterspell/readied-spell reaction with no slot at the readied level no longer fires. Previously an armed reaction always fired regardless of the reactor's spell-slot state. From C17, _pop_pending_ reaction's new eligible= predicate (R4) SKIPS — leaves queued, spends nothing — an armed reaction whose owner lacks an unexpended slot at the readied level in either pool (Spellcasting or Pact Magic, checked via _slot_available). The reaction stays armed for a later trigger; the reactor's Reaction and slot are both untouched. No existing fixture was in this state (a zero-slot reactor previously wasn't exercised), so no seeded replay moves, but a host that relies on "an armed reaction always resolves" must now also track the reactor's slot pool.

3. Counterspell beyond 60 ft (grid distance with line of sight) no longer fires. The same eligible= predicate additionally gates Counterspell on its own canonical range.value (60 ft) and _in_range_with_los: an out-of-range or LoS-blocked reactor's armed Counterspell is skipped, not consumed. Geometry-free setups (zone topology, or either combatant's zone untracked) are unaffected — "no geometry ⇒ no penalty" is the engine-wide convention, unchanged.

4. SpellCast appears on every PC cast path. A new event, SpellCast(actor_id, spell_id, slot_level, ritual, components, material, material_consumed, material_cost_gp), is emitted after the slot gate (or after ReactionTriggered for a readied cast) on all three PC cast sites: the on-turn cast, a readied-reaction resolve (Shield), and Counterspell's own reaction cast. CombatEvent is a closed discriminated union — a host with an exhaustive match/if chain on event type must add a "spell_cast" case (or a default) to avoid a MatchError/silent drop. Component/material metadata is descriptive only — nothing gates a cast on it (see BACKLOG.md).

5. CastFailedReason gains "ritual_in_combat", appended at the end of the closed Literal. PlayerIntent.as_ritual: bool = False: an in-combat cast with as_ritual=True is rejected before any slot logic (reason "ritual_in_combat") — the turn economy has no 10-minute-cast model. Ritual casting only resolves out-of-combat, through the new pure spellcasting.resolve_ritual_cast(spell, *, prepared, ritual_adept=False) host seam (validates the Ritual tag + prepared/Ritual-Adept gate, returns a RitualCast with the 10-minute tax and slot_consumed=False).

6. build_party_member now fills empty spell_slots/pact_slots from CharacterBuildSpec.classes. Previously build_party_member copied CombatInstance.spell_slots verbatim, including an empty {}. From C17, an EMPTY CombatInstance.spell_slots (falsy dict) falls back to derive_multiclass_slots(build_spec.classes, loader=...); likewise pact_slots falls back to derive_multiclass_pact_slots(...). A host that previously passed an empty spell_slots dict for a caster and RELIED on every subsequent cast rejecting with CastFailed(reason= "no_slot") will now see that caster hydrated with REAL, derived slots and casts that actually resolve. A non-empty spell_slots/pact_slots on the CombatInstance is untouched (still copied verbatim) — this is a fallback for the empty case only.

7. Additive-only new surface:

  • PartyMemberSpec.pact_slots: dict[int, int] = {}, CombatInstance. pact_slots: dict[int, int] = {}, LiveCombatView.pact_slots_by_entity: dict[str, dict[int, int]] — the Pact Magic pool, mirroring the existing spell_slots shape end to end.
  • PlayerIntent.target_ids: tuple[str, ...] | None = None (multi-target aiming, R5) and PlayerIntent.as_ritual: bool = False (R8) — both default to the pre-C17 single-target/non-ritual behaviour.
  • CharacterBuildSpec.classes: dict[str, int] = {} — the multiclass carrier (a {class_slug: level} map). The existing class_slug/level single-class fields are kept as ALIASES: a caller using only class_slug/level (unchanged construction) gets classes populated automatically by a model_validator(mode="before"); a caller using classes gets class_slug (= the first key) and level (= the summed total) back-filled. Caveat: CharacterBuildSpec.model_copy(update= ...) bypasses this before-validator (Pydantic does not re-run mode="before" validators on model_copy), so a model_copy that changes only level or only classes can desync the two — construct a fresh CharacterBuildSpec(...) rather than model_copy when changing either field.
  • Rest kwargs: resolve_short_rest(..., *, pact_slots=None, pact_slot_max= None); resolve_long_rest(..., *, spell_slots=None, spell_slot_max=None, pact_slots=None, pact_slot_max=None, exhaustion_level=None). All five default to None ⇒ byte-identical RestOutcome for an existing caller that supplies none of them. RestOutcome gains spell_slots: dict[int, int] | None, pact_slots: dict[int, int] | None, exhaustion_level: int | None — each populated only when its matching input pair was supplied. A pool given without its _max counterpart raises ValueError (the resolver never guesses a maximum); Short Rest restores ONLY Pact Magic (SRD's only Short-Rest-recovering slot pool) and takes no spell_slots kwarg at all.
  • New public functions/types (also new dnd5e_engine.__all__ / TOP_LEVEL entries): derive_spell_slots, derive_multiclass_slots, derive_pact_slots, resolve_ritual_cast, RitualCast. (derive_ multiclass_pact_slots, multiclass_caster_level, slots_for_ caster_level, effective_caster_level, resolve_target_count, count_ scales_with_cast_level, spell_component_metadata, SPELL_SLOT_TABLE, PACT_SLOT_TABLE are public on their owning modules but not re-exported top-level — see docs/api.md.)

R1 — the level-1 half-caster row. SRD 5.2 §Multiclassing computes each class's contribution with PER-CLASS rounding before summing: half = ceil(level / 2), matching Foundry's computeProgression (half: divisor 2, roundUp: true). A level-1 Paladin therefore contributes ceil(1/2) == 1 caster level and DOES have a slot ({1: 2}) — this is the 2024/Foundry reading this engine pins, not the 2014 table's empty level-1 half-caster row. packages/nat20-bridge/src/nat20_bridge/slots.py still ships its own transcribed table with the 2014 empty-level-1 row and has not yet been migrated onto spellcasting.py — see BACKLOG.md; a bridge-side party derivation and the engine's own derive_spell_slots will disagree on this one row until that migration lands.

Determinism. One pre-existing path changes its draw count: an upcast Magic Missile previously rolled Nd4+1 for its single dart (the empty scaling.mode on the corpus's default scaling {number: 1, mode: ""} was read by activities/dice.py::_scaling_steps as whole-mode scaling); it now rolls 1d4+1 once and applies it to N darts. A seeded replay containing an upcast Magic Missile diverges from that cast onward. Base-level Magic Missile and every other corpus activity are unchanged — the guard's blast radius is exactly the one canonical entry whose target.affects.count contains @item.level on a damage-kind, creature-typed activity. Separately, the Magic Missile fan-out changes the NUMBER OF EVENTS (and therefore HP outcomes) while keeping the (now corrected) draw count identical at every slot level — one shared roll, applied N times. The Counterspell/Shield eligibility gate removes a ReactionTriggered + save draw only when the reactor is newly ineligible (empty pool / out of range) — no pre-existing fixture was in that state, so no seeded replay's draw sequence moves on that path.

Catalog script repair

C06-S04 (R6). Widening Magic Missile's dart count from 1 to N (per the cast's slot level) inflated the shielded-vs-unshielded damage spread the scenario pins. The bound on unshielded_total was widened from 2 <= unshielded_total <= 5 to 6 <= unshielded_total <= 15 — the only pre-existing test assertion this cluster touches, per the C17 plan's R6 ruling. No other assertion in that scenario changed.