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:
- New event types to tolerate.
TurnPhase(turn-boundary marker) andConcentrationChecknow appear in the stream. A renderer that switches onevent.typemust have a default branch that skips unknown types rather than printing or raising — the engine will keep adding marker events. - New optional fields to ignore or render.
natural,modifier,sourcesandadvantageonAttackRolled/SaveRolled/CheckRolled/ConcentrationCheck;source_idandis_critonDamageApplied(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. - 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_proficienciesis 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: settingmonster_template_slughydrates 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 == 10is treated as unset whenmonster_template_slugresolves (the template's DEX wins); pass any other value to override. Retype toint | Noneis tracked in BACKLOG follow-ups. - 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; seedocs/capabilities.md.) C14 changes turn shape: a main-hand attack now keeps the turn open whileattacks_remainingor a two-weapon-fighting window is live, Dodge/Help/ Hide/Grapple/Shove/stand_up/escape_grappleare real, andinitiative: int | Noneon the party/encounter specs can opt into an engine-rolled Initiative draw. NewIntentTypemembers:"grapple","shove","stand_up","escape_grapple"— an exhaustive host-sidematch/ifchain onintent_typemust 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. - Zone graph deprecated.
start_combat(scene_zones=...)still works but now raises aDeprecationWarning; the backend is removed in 0.7.0. Build aGridSceneinstead — a one-row grid is a valid stopgap:GridScene(width=len(zones), height=1)withzone_id = cell_id(i, 0). - New spatial behaviour on the grid — walls block movement, AoE spells hit
every creature in the template, creatures grant cover, one
ActorMovedpermoveintent,CombatantMovedappears after Thunderwave, andunseenadvantage/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
ConcentrationCheckand skipSaveRolledevents immediately preceded by one for the same target. - Simplest: keep consuming
SaveRolledand ignoreConcentrationCheckuntil 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_modifiersprojects all six abilities (str/dex/con/int/wis/cha) intoentry["saves"], so the IR-levelsaveactivity handler sees real modifiers for every ability.- The concentration check in
_emit_apply_damageadds 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_savesadds 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={}); window3..18→6..36— source:flag(flags.advantage.attackonchar: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 ⇒ norogue.sneak-attackscale value ⇒ no rider) — source:flag(flags.advantage.attackonchar:rogue), which now makes the advantage run consume two draws so the advantage-less run is no longer stream-comparable. The crit-aware6..36/3..18window is unchanged; the test additionally now asserts the two runs share a stream (identicalroll_totalandis_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={}); windows3..18→6..36— source: sameflagonchar: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:
- The save lands inside its phase. Its
SaveRollednow falls betweenTurnPhase(actor, "turn_end")andTurnEnded(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. - 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 throughroll_d20_test— one extrarng.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:
seconds→ceil(seconds / 6)rounds, ticked exactly likerounds: 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 intoduration.roundsand decremented in the same pass, soseconds=12is indistinguishable fromrounds=2; from the next turn the pre-existing round tick owns it.secondsitself is never mutated.rounds+secondstogether →roundswins. The seconds branch only fires whenrounds 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 ofrounds: 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 recentTurnStartedfor that actor vs. the effect'sEffectApplied), so no new state crosses the boundary and seeded effects — which emit noEffectApplied— 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_distancereturnsNonefor a step through aWallSegment, and a diagonal step may no longer cut a wall's corner. A route that v0.5 allowed can now fail withMoveFailed(reason="blocked_path"). blocked_cellsblock line of sight and grant Total Cover. In v0.5 they blocked movement only;has_line_of_sightignored 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_cellstag 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 newMoveFailed.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 newSpatialTopology.light_on_cell(cell) -> LightLevelmethod 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'sunseenAdvantageSourcerow — "mover can't see reactor" — reads raw scene vision (SpatialTopology.can_see) rather than the composite, exactly as_target_visibility_mapsdoes for the regular Attack path, so a Blinded or Invisible mover is not double-tagged with bothunseenand its owncondition:*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 residualnot_adjacentcase is pinned bytest_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 acover_cellstag 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 (wasTrue). SRD 5.2 replaced the 2014 Level-1 effect with the numeric-2 x levelpenalty.["frightened"]now reports disadvantage (wasFalse). 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_totalnow carries the penalised total rather than the natural die. Identical at exhaustion 0, but a host that reconstructed the die fromroll_totalwill see the difference on an exhausted Character.ConditionApplied/ConditionRemovednow materialise onCombatant.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
checkactivity 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— theexhaustionparameter was dropped from the disadvantage list (SRD 5.2 Exhaustion is the numeric-2 × levelD20 Test penalty, not disadvantage);test_exhaustion_no_longer_projects_check_disadvantagepins the new behaviour explicitly.tests/activities/test_resolve_check_activity.py::test_condition_disadvantage_merges_with_the_projected_modifiers[exhaustion-…]— expectation flippedTrue→False, 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/demoSHOWCASE_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_caster
→ test_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)tocell(9, 9)(out of the fighter's reach), and achar:fighter"pass"intent plus anadvance_monster_turncall 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 tocell(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_available ⇒ IntentRejectedError("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_upsequence was unreachable. Swapped torng_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_proficienciesat all projects toCombatant.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'sweapon_category(e.g."simple_melee") or exactslug. - Monsters never carry this field explicitly, so
Combatant.weapon_proficienciesstaysNonefor 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_damageso resistance/ immunity/vulnerability still apply. - Topple — on a hit, the target makes a Constitution save against
8 + proficiency + governing-ability modifier(through the sameroll_saveprimitive thesaveactivity kind uses, honoringforce_save_d20); on a failure the target is knocked Prone, now gated by the sharedis_condition_immunehelper — a prone-immune target's save still rolls and still emitsSaveRolled, but theConditionAppliedis 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_procson 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 (alive.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 aturn_endhook,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'sTurnStarted). Both ride the"trait"AdvantageSourcetoken (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_combatanton 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 aCombatantattribute yet, so the size gate is not modelled and every target is pushed (seeBACKLOG.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 byentity_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 gainedcharacter_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 byIntentRejectedError("not_actor_turn")rather than exercising the Loading gate the scenario pins. Thecontextlib.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 existingspell_slotsshape end to end.PlayerIntent.target_ids: tuple[str, ...] | None = None(multi-target aiming, R5) andPlayerIntent.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 existingclass_slug/levelsingle-class fields are kept as ALIASES: a caller using onlyclass_slug/level(unchanged construction) getsclassespopulated automatically by amodel_validator(mode="before"); a caller usingclassesgetsclass_slug(= the first key) andlevel(= the summed total) back-filled. Caveat:CharacterBuildSpec.model_copy(update= ...)bypasses this before-validator (Pydantic does not re-runmode="before"validators onmodel_copy), so amodel_copythat changes onlylevelor onlyclassescan desync the two — construct a freshCharacterBuildSpec(...)rather thanmodel_copywhen 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 toNone⇒ byte-identicalRestOutcomefor an existing caller that supplies none of them.RestOutcomegainsspell_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_maxcounterpart raisesValueError(the resolver never guesses a maximum); Short Rest restores ONLY Pact Magic (SRD's only Short-Rest-recovering slot pool) and takes nospell_slotskwarg at all. - New public functions/types (also new
dnd5e_engine.__all__/TOP_LEVELentries):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_TABLEare public on their owning modules but not re-exported top-level — seedocs/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.