API reference
Rendered from source docstrings, covering the public API surface
(dnd5e_engine.__all__). Every symbol below is exported from the top-level
dnd5e_engine package, and that list is pinned by
tests/test_public_api_surface.py — nothing here can change without the test
changing with it.
For what the engine actually resolves behind these signatures, see the capability matrix.
Combat loop
The combat seam — open a combat, drive it turn by turn, close it.
This module owns the engine's stateful combat loop and every piece of runtime state behind it. Four coroutines are the whole public contract:
start_combat— roll initiative, materialize runtime state, return aCombatHandleyou thread through every later call.submit_player_intent— validate and resolve one PC intent.advance_monster_turn— run the built-in monster AI for one turn.end_combat— close the encounter and project aCombatOutcome.
Each call emits typed CombatEvent objects, which
you read with narration_events (streaming) or drain_pending_events
(pull). Live state is readable only through get_live, which returns an
immutable LiveCombatView snapshot — the private
_LiveCombat dataclass is never handed out.
How an intent resolves
submit_player_intent reads the intent's intent_type to pick an asset
reference (weapon_id / spell_id / item_id / feature_id), fetches
that typed entity through get_lib_loader, and
walks its activities via the per-kind resolvers in
activities. Before resolving, it projects the actor's
current active effects and conditions into the resolution context, so passive
attack/damage/save modifiers land uniformly regardless of which game object
triggered the activity.
Scope and constraints worth knowing up front
- Determinism. Every in-combat die is drawn from the
random.Randomseeded bystart_combat(rng_seed=...). Same seed + same intent sequence reproduces the same combat exactly, independent of globalrandomstate. - Movement is one step per intent. A
"move"intent must name an adjacent cell/zone; it does not path-find. Cross a room by submitting several moves. - Reactions are pre-armed. The engine never pauses mid-resolution to ask a
host "do you want to react?". A reactor arms a reaction on its own turn with
a
"ready"intent, and the engine fires it automatically when the trigger occurs. - Effects are combat-scoped. They live in memory for the encounter and are
discarded at
end_combat; persisting anything across combats is the host's job.
See docs/capabilities.md for the per-mechanic matrix of what is and is not
resolved today.
PlayerIntent
Bases: BaseModel
A PC's submitted intent for the current turn.
The seam carries the union of optional asset references the intent-
to-IR resolver consumes. The orchestrator chooses the right slot by
intent_type (e.g. "attack" consumes weapon_id;
"cast_spell" consumes spell_id; "use_item" consumes
item_id); feature_id rides alongside for class-feature
activations the cutover prompt extends the IntentType enum to
surface.
CombatHandle
dataclass
Opaque handle to a running combat (registry key).
start_combat(*, session_id, party, encounter, scene_zones=None, grid_scene=None, rng_seed, scene_location_id='loc:unknown', active_effects=())
async
Open a combat, materialize runtime state, kick off the initiative loop.
Returns a StartCombatResult envelope wrapping the CombatHandle
the caller threads through subsequent seam calls and the events emitted
during open (round-start + first turn-start).
scene_zones is deprecated (0.6.0) and removed in 0.7.0 — use
grid_scene.
submit_player_intent(handle, actor_id, intent)
async
Accept a PC intent for the current turn, validate it, resolve it.
Validation
actor_idmust be in the live combat's initiative order- it must currently be
actor_id's turn - combat must not have ended
On success: emit IntentSubmitted, fetch the typed entity for the
intent from the lib loader, and walk its activities through the per-kind
resolvers under activities, emitting the resulting
CombatEvent stream.
advance_monster_turn(handle)
async
Drive one monster turn through typed selection + the Activity resolver.
Validation mirrors submit_player_intent:
- combat must not have ended
- the current actor must be a non-Character entity (Monster /
NPC); calling on a PC turn raises
IntentRejectedErrorso the WS-side dispatch can branch on it
Selection: select_typed_monster_action
picks an action from the typed Monster.actions (fetched from the lib
loader by monster_template_slug); expand_action_to_activities fans
multiattack out into its sub-attacks. Targeting: lowest-HP alive PC in
initiative order (the legacy gambit's target_priority="lowest_hp"
semantics). Resolution: each returned Activity runs through
resolve_activity against a context
built by build_activity_context — the same typed path as the PC
turn /6 of the Foundry cutover).
On dead monsters, an unresolvable slug, or no usable action (flee
threshold, no attack, no PC targets), the orchestrator records
IntentSubmitted(pass) and advances the turn without resolving any
activity — the safe no-op the legacy dispatch also produced.
end_combat(handle)
async
Close the combat and return the projected outcome.
Idempotent: calling twice returns the same outcome (with an empty
events list on subsequent calls — the close events were only
emitted once on the first invocation), no re-emission of events, no
double-removal from the registry.
narration_events(handle)
async
Stream the combat's events to the narrator.
The iterator terminates when end_combat is called — the closer
drains a sentinel None onto the queue and we stop iteration on
receiving it.
get_actor_active_effects(handle, entity_id)
Read-only snapshot of one combatant's active effects.
Public API for host-side resolvers (e.g. a host's FLEE dispatch path,
_handle_consult_codex_dispatch) that run alongside the engine's own
dispatch and need to see the same active_effects the engine resolvers
consume internally. The engine is the single source of truth for in-
combat effect state; this accessor lets the host fold it into a
DispatchContext without re-implementing the registry.
Returns an empty tuple if the handle has no live combat (caller should treat as out-of-combat — per spec, no effects apply).
Results and outcome
Named result envelopes for state-mutating library entry points.
Per the dnd5e-engine extraction spec — start_combat and end_combat return envelopes rather than tuples so fields are named, IDE introspection works, and adding new return data later is non-breaking.
CombatHandle is defined in dnd5e_engine.orchestrator (moved in ; this module imports it via TYPE_CHECKING + model_rebuild() to avoid a hard import cycle.
StartCombatResult
Bases: BaseModel
Returned by dnd5e_engine.start_combat.
EndCombatResult
Bases: BaseModel
Returned by dnd5e_engine.end_combat.
final_active_effects is the engine's authoritative snapshot of
effects still live at end_combat — excludes effects whose source
died, durations ticked to zero, or concentration broken. Log-only
in ; persisted in [effects-cross-combat].
CombatOutcome data classes — the pure-data projection of closed combat state.
These models are the typed payload the public combat seam returns from
end_combat. Translation into a host's AnyWorldEvent discriminated
union and persistence via the event pipeline live host-side in
a host-side outcome record; this module is host-free.
Event-type mapping (host-side projection):
- Deaths →
CharacterDied(PCs) /NpcDied(NPCs) /MonsterDied(monsters). There is noCharacterUnconsciousevent; PCs at 0 HP surface viaCharacterHpChanged(new_hp=0, …)and only escalate toCharacterDiedvia the death-save outcome path. - Residual HP →
CharacterHpChanged. - Loot →
ItemTransferred/ItemCreatedper the loot source. - XP →
CharacterXpAwarded.
end-of-combat condition carryover is retired; the authoritative
end-of-combat effect snapshot lives on
EndCombatResult.final_active_effects (Foundry-aligned
ActiveEffect rows). callers log-and-discard; persistence is
[effects-cross-combat].
CombatOutcome
Bases: BaseModel
Complete projection of closed combat state into world mutations.
Every field is the "what should change in the world graph" payload
for one mutation category; project_outcome_to_events (host-side)
translates them to typed WorldEvent instances and apply_outcome
flows them through persist_and_apply.
DeathRecord
Bases: BaseModel
LootDrop
Bases: BaseModel
Content loading
The engine ships no rules data. It reads typed content through a process-wide
AssetLoader, which defaults to the bundled SRD 5.2 corpus. Install your own to
drive the engine from a different corpus.
The asset-loader seam — where the engine gets its rules content.
The engine ships no rules data. Every typed entity it resolves is fetched
through the process-wide AssetLoader returned by get_lib_loader, which
defaults to the bundled SRD 5.2 corpus (BundledAssetLoader).
The engine is edition-agnostic: it resolves whatever typed content it is handed.
To drive it from a different corpus, implement the AssetLoader protocol and
install it once at startup with configure_lib_loader; pass None to
revert to the bundled corpus. Install it before opening any combat.
get_lib_loader()
configure_lib_loader(loader)
Public host seam: install a custom AssetLoader (e.g. a homebrew
overlay). None reverts to the lazy bundled default. Hosts must
call this before start_combat; swapping mid-combat is unsupported.
Checks
Standalone check resolver — resolve_check.
Resolves a skill check, ability check, or saving throw with no combat handle
required (out-of-combat skill prompts, poison ticks, environmental hazards).
Each check honours the active_effects you pass, so Bless / Guidance / Bane
land uniformly here and in combat.
Determinism
Set rng to a seeded random.Random for a reproducible
result. If you leave it None, the d20 is drawn from the process-global
random module and the check is reproducible only if you seed that yourself.
In-combat rolls are unaffected: they always use the generator threaded from
start_combat(rng_seed=...).
CheckKind = Literal['skill', 'ability', 'saving_throw']
module-attribute
CheckSpec
dataclass
All inputs needed to resolve a standalone check.
Self-contained: the resolver makes zero I/O calls and reads nothing
outside the spec. active_effects are pre-filtered by the caller —
the engine does not decide which of a creature's effects apply to this
check. The resolver folds Foundry-shaped
ActiveEffectChange entries whose key matches the check's bucket
(check.bonus for skill+ability, save.bonus for saving_throw).
Override-mode changes on flags.advantage.* / flags.disadvantage.*
keys surface in effect_breakdown for narrator visibility but do NOT
toggle the roll mechanic — pass advantage=True on the spec for that.
CheckResult
dataclass
Result fragment for a standalone check.
A plain value type: everything a host needs to render or adjudicate the roll, with no engine state attached.
resolve_check(spec)
Resolve a standalone skill / ability / saving-throw check.
Active effects are pre-filtered by the caller. The resolver folds
Foundry-shaped
ActiveEffectChange entries whose key matches the kind's bucket
(check.bonus for skill+ability, save.bonus for saving_throw).
Override-mode changes on flags.advantage.* / flags.disadvantage.*
contribute to effect_breakdown only; the roll mechanic's advantage
is set via spec.advantage.
Zero I/O. Pass spec.rng for a reproducible roll — see the module
docstring for the determinism contract.
Scene and grid specs
Combat boundary spec types.
Value-typed payloads the host adapter passes into start_combat. Owned by the library so a standalone consumer can construct combat-ready inputs without depending on the host-specific session/world types.
PartyMemberSpec
Bases: BaseModel
One PC entering combat.
The seam takes the projected wire-level shape; building a real
Combatant from this happens in start_combat. Combat stats
(hp_max, ac, attack_bonus, …) are looked up from the session/world
layer by the cutover prompt — the seam keeps them on this spec so
the additive surface can be exercised standalone.
EncounterMemberSpec
Bases: BaseModel
One hostile (monster or NPC) entering combat.
ZoneEdge
Bases: BaseModel
One undirected connection between two zones in a SceneTopology.
a and b are zone ids; distance_ft is the cost of traversing
between them, used for range, reach and movement-budget checks. Edges are
undirected — declare each connection once.
SceneTopology
Bases: BaseModel
Wire-level shape for the zone graph the engine resolves over.
The orchestrator converts this to a concrete ZoneTopology (the
Protocol the scaffold's RuntimeContext requires) at
start_combat time. Per
the original scaffold
keeps ZoneTopology as a structural Protocol; concrete graph
implementations belong here at the seam.
WallSegment
Bases: BaseModel
One wall edge, grid-CORNER endpoints (mirrors Foundry's Wall.c
four-coordinate convention).
Coordinates are grid-corner units, not cell-center units: a wall running
along the boundary between column 2 and column 3 (spanning the full grid
height) is WallSegment(x1=2, y1=0, x2=2, y2=<height>), not
x1=2.5. GridTopology.has_line_of_sight tests the straight segment
between two cells' CENTER points (col+0.5, row+0.5) against every
wall segment for a proper intersection — see
docs/dev/spatial-geometry.md.
GridScene
Bases: BaseModel
Wire-level shape for a 2-D grid battlefield.
The grid backend resolves combat over Chebyshev (8-direction, one cell =
cell_size_ft) distance. Combatant positions reuse the existing
zone_id string on the party/encounter specs, encoded as "col,row"
(see dnd5e_engine.spatial.cell_id). blocked_cells are impassable
squares (movement may not enter them). wall_segments block line of
sight (SRD 5.2 §Areas of Effect); cover_cells grant half / three-
quarters / total cover (SRD 5.2 §Cover); difficult_terrain_cells
double the movement cost of entering them (SRD 5.2 §Difficult Terrain).
See docs/dev/spatial-geometry.md for the full geometry design note.
Spatial backends for combat resolution.
The engine resolves all positional reasoning through the SpatialTopology
Protocol — a combatant's position is an opaque string handle, and the backend
answers adjacency / distance / range / pathing over it. Two backends exist:
the zone graph (_ZoneGraph in orchestrator.py) and the grid
(GridTopology here). Call sites never branch on which backend is live.
GridTopology
Chebyshev (8-direction, one cell = cell_size_ft) grid backend.
Position handles are "col,row" cell ids. blocked_cells are
impassable squares — movement may not enter them and paths route around
them. wall_segments block line of sight, cover_cells grant
half/three-quarters/total cover, and difficult_terrain_cells double
entry cost — see docs/dev/spatial-geometry.md for the geometry design.
cell_size_ft
property
Feet per cell — the scale every *_ft argument is divided by.
edge_distance(a, b)
One step's movement cost, in feet, entering b from adjacent a;
None when the step is illegal (not adjacent, b blocked, a wall
crosses the step, or a diagonal cuts a blocked corner — SRD 5.2
§Playing on a Grid "Corners"). SRD 5.2 §Difficult Terrain: entering a
difficult-terrain cell costs double (keyed on the cell ENTERED).
distance_ft(a, b)
Straight-line Chebyshev distance in feet (None when either cell is
out of bounds). SRD 5.2 grid rule: a diagonal step is one square.
has_line_of_sight(a, b)
SRD 5.2 §Point of Origin — "To block a line, an obstruction must provide Total Cover." Two obstruction sources share one walk:
wall_segments— the straight segment between the two cells' CENTER points is tested against every wall edge (grid-corner endpoints); any intersection blocks.blocked_cells— a terrain feature that fills its space is Total Cover: any cell strictly betweenaandbon the Bresenham line that is blocked blocks sight. Endpoints never count.
No walls and no blocked cells ⇒ always True (unchanged behaviour).
cover_between(a, b, occupied_cells=())
SRD 5.2 §Cover — the highest cover degree an obstruction on the
straight line between a and b grants (none < half <
three_quarters < total). Three sources, one Bresenham walk over the
cells strictly between the endpoints:
cover_cells— host-authored degree per cell. The TARGET's own cell counts (an object in its space covers it); the ORIGIN cell never does. Ruling shared with C22 Task 6 — keep at merge;blocked_cells— "an object that covers the whole target" ⇒total;occupied_cells— "another creature … that covers at least half of the target" ⇒half. The caller passes the cells of every OTHER live combatant (never the attacker's or the target's own cell — those are skipped here defensively as well). Ally or enemy makes no difference (rule card: creature cover ignores alignment).
Empty geometry and no occupants ⇒ "none" (unchanged behaviour).
cover_on_cell(cell)
SRD 5.2 §Cover — the cover_cells degree tagged directly on
cell itself, with NO line walk to another point.
cover_between always excludes its own a endpoint (an
obstacle in the point of origin's own space never shields anyone
else from that origin), so when an area of effect's point of origin
happens to coincide with the one creature it affects (a small
sphere centred on a lone target), the ordinary cover_between(a,
b) walk degenerates to a single excluded point and can never see a
tag on that shared cell. But §Cover ("an object that covers at
least half of the target") still shields a creature standing in or
behind an obstruction in its OWN space — this reads that tag
directly, independent of any origin/endpoint exclusion rule. A
blocked (impassable) cell counts as "total", matching
cover_between's treatment of blocked_cells.
obscurement_on_cell(cell)
SRD 5.2 §Vision and Light — the obscurement_cells degree tagged
directly on cell itself, with NO line walk to another point.
Mirrors cover_on_cell exactly (same "read the tag on this one
cell" shape); introduced for the Hide action's gate (SRD 5.2 Hide:
"while you're Heavily Obscured or behind Three-Quarters Cover or
Total Cover"). Out-of-bounds cells carry no obscurement tag.
light_on_cell(cell)
SRD 5.2 §Vision and Light — the light level tagged on cell.
"An area of Darkness is Heavily Obscured." Out-of-bounds cells carry
no tag and fall back to the scene's default_lighting, same as an
untagged in-bounds cell.
can_see(a, b, senses=None)
SRD 5.2 §Vision and Light — can a viewer in a with senses see
a creature standing in b?
- Line of sight (walls / blocked cells) is required for every sense — Blindsight: "you can see anything that isn't behind Total Cover".
- Blindsight or Truesight whose range reaches
bsees through Darkness and heavy obscurement. - A Heavily Obscured cell (
obscurement_cells == "heavy") is opaque to sight; Darkvision does not help (it only re-grades light). - Bright or Dim Light in
bis visible ("in a Lightly Obscured area ... you have Disadvantage on Wisdom (Perception) checks" — attacks are unaffected). - Darkness in
bneeds Darkvision reachingb("in Darkness within that range as if it were Dim Light").
Tremorsense is deliberately not consulted — "it doesn't count as a
form of sight" (SRD 5.2 glossary, Tremorsense). Conditions (Blinded)
are the caller's concern (rules/conditions.py).
is_valid_cell(cid)
True iff cid is in bounds and not impassable — a legal occupancy.
cells_in_template(origin, shape, size_ft, *, direction=None)
SRD 5.2 §Areas of Effect — the in-bounds cell set for a template.
Chebyshev metric throughout (maintainer decision, catalog —
settled, not relitigated): radius_cells = size_ft // cell_size_ft.
"sphere": every cell withmax(|dx|, |dy|) <= radius_cellsfromorigin(origin included — SRD: "a Sphere's point of origin is included in the Sphere's area of effect")."cylinder": the same cell set as"sphere"— SRD: "a Cylinder's point of origin is included in the area of effect"; the height dimension has no 2-D meaning on a grid template."line": requiresdirection(a nonzero grid-offset vector, normalized to one of the 8 unit grid directions); theradius_cells + 1cells stepping from the origin along that direction, origin included. NOTE: SRD 5.2 says a Line's (and a Cone's) point of origin "isn't included in the area of effect unless its creator decides otherwise", so this primitive is deliberately INCLUSIVE and the caller drops the origin. The only in-engine caller,orchestrator._expand_aoe_target_list, does exactly that via the typed_AoeTemplate.include_origin, so no shipped behaviour is off-SRD; a host calling this directly must discardoriginitself. Behaviour is pinned by tests and is not changing before the 0.7 template rework."cone": requiresdirection; a cell at offset(dx, dy)from the origin is included iff its projection onto the direction (forward) is in[0, radius_cells]and its perpendicular offset (lateral) does not exceedforward— a widening 45° triangle from the origin, origin included (same SRD caveat as"line"above: the caller excludes it). Seedocs/dev/spatial-geometry.mdfor the full rationale (an engine convention, not literal SRD prose geometry — squares have no single canonical cone rasterization)."cube": requiresdirection; a face-anchoredn x nblock (n = radius_cells) whose near face touches the origin cell — SRD: "A Cube's point of origin isn't included in the area of effect unless its creator decides otherwise" (origin excluded). Seedocs/dev/spatial-geometry.mdfor the placement convention.
See docs/dev/spatial-geometry.md. Not part of the
SpatialTopology Protocol — grid-only (the zone-graph backend has
no cell coordinates to enumerate a template over).
push_path(origin, target, distance_ft, *, occupied_cells=())
Forced movement "straight away from" origin: the cells a creature
at target crosses when pushed distance_ft (SRD 5.2 Thunderwave
"pushed 10 feet away from you", Push mastery "straight away from
yourself"). Direction is the sign of target - origin per axis (one
of the 8 grid directions). The walk stops early at the grid edge, a
blocked cell, a wall, a corner cut (edge_distance is None) or an
occupied cell — the creature is moved as far as it can go. Grid-only;
not part of SpatialTopology.
shortest_path(a, b, *, avoid=())
Fewest-cells path from a to b over LEGAL steps (BFS, 8
neighbours in fixed order — the tie-break is part of the seeded
contract). avoid cells are never entered (occupied-by-enemy cells,
SRD 5.2 §Moving Around Other Creatures); b in avoid ⇒ [].
Route cost is NOT minimised — callers charge each leg's
edge_distance against the budget.
SpatialTopology
Bases: Protocol
The positional seam every combat resolves over.
Position handles are opaque strings (zone ids for the graph backend,
"col,row" cell ids for the grid backend).
cell_id(col, row)
Encode a grid coordinate as the opaque position handle "col,row".
parse_cell(cid)
Decode a "col,row" handle. Raises ValueError on malformed input.
Character building
The build-spec contract: the typed input that resolves into a complete PC.
A 7c test/seed factory produces these now; the char-creation build-core (CharacterDraft, spec-only today) becomes a second producer of the identical contract later. Resolution (build_party_member) is pure; selection (who fills the build-spec) is the producer's job.
CharacterBuildSpec
Bases: BaseModel
The typed input that resolves into a complete PC.
C17: classes is the multiclass carrier (spec §3) — a {class_slug: level}
map. class_slug (= the FIRST key, the primary class) and level (= the SUM
of class levels) are kept as single-class aliases and always populated, so
existing single-class callers (CharacterBuildSpec(class_slug=..., level=...))
keep working exactly as before.
Caveat: model_copy(update=...) bypasses the mode="before"
validator below (Pydantic does not re-run before-validators on
model_copy), so a model_copy that changes only level or only
classes can desync the two fields. Construct a fresh
CharacterBuildSpec(...) instead of model_copy when changing
either field.
AbilityScores
Bases: BaseModel
CombatInstance
Bases: BaseModel
Combat-instance values that are NOT character-derived.
Entity identity (entity_id/name) + rolled/looked-up combat stats.
make_build_spec(*, species_slug, class_slug=None, level=None, classes=None, subclass_slug=None, ability_scores=None, equipment=(), selected_choices=())
derive_multiclass_slots(classes, *, loader=None)
Multiclass Spellcasting-feature slots for a {class_slug: level} map.
Reads each class's spellcasting.progression through loader (default:
the configured lib loader), applies the SRD per-class rounding (R2) and looks
the total up in the Multiclass Spellcaster table. A single-class map returns
exactly derive_spell_slots(...) for that class.
derive_multiclass_pact_slots(classes, *, loader=None)
Pact Magic pool for the pact-progression class levels in classes ({} if none).
Spellcasting
Pure SRD 5.2 spell-slot tables and derivations (C17) — per-class Spellcasting slots, Pact Magic, multiclass slot-table lookups, upcast target-count scaling, and out-of-combat Ritual resolution. Zero I/O, zero host imports.
Pure SRD 5.2 spellcasting tables and derivations (C17).
Zero I/O, zero host imports. Loader access (class slug -> spellcasting.progression)
stays with build_spec.derive_multiclass_slots; this module reads only the
value-typed inputs it is handed.
SRD 5.2 ground truth (content24/):
- §Spell Slots — "For example, a level 3 Wizard has four level 1 spell slots and
two level 2 slots." The per-level table is the Multiclass Spellcaster table
(
chapter-2/character-creation.yml:784), numerically identical to Foundry'sSPELL_SLOT_TABLE(module/config.mjs:3027). - §Multiclassing — "All your levels in the Bard, Cleric, Druid, Sorcerer, and
Wizard classes; Half your levels (round up) in the Paladin and Ranger classes."
Foundry parity:
half= divisor 2 round UP,third= divisor 3 round DOWN, rounding applied PER CLASS before summing (computeProgression). A single half-caster therefore has slots at level 1 (ceil(1/2) == 1) — the 2014 table's empty level-1 row is NOT what this repo pins. Foundry's owncomputeProgressionalso treatsartificeras a half-caster (divisor 2, round up) rather than the 0-contribution the plan prose originally assumed; that Foundry-parity behaviour is kept here even though it is corpus-inert — no SRD class in this repo's dataset carries theartificerprogression. - Pact Magic — "You regain all expended Pact Magic spell slots when you finish a
Short or Long Rest. … when you're a level 5 Warlock, you have two level 3 spell
slots." Foundry
pactCastingProgression(config.mjs:3053); Pact levels never enter the multiclass total.
SPELL_SLOT_TABLE = ((2,), (3,), (4, 2), (4, 3), (4, 3, 2), (4, 3, 3), (4, 3, 3, 1), (4, 3, 3, 2), (4, 3, 3, 3, 1), (4, 3, 3, 3, 2), (4, 3, 3, 3, 2, 1), (4, 3, 3, 3, 2, 1), (4, 3, 3, 3, 2, 1, 1), (4, 3, 3, 3, 2, 1, 1), (4, 3, 3, 3, 2, 1, 1, 1), (4, 3, 3, 3, 2, 1, 1, 1), (4, 3, 3, 3, 2, 1, 1, 1, 1), (4, 3, 3, 3, 3, 1, 1, 1, 1), (4, 3, 3, 3, 3, 2, 1, 1, 1), (4, 3, 3, 3, 3, 2, 2, 1, 1))
module-attribute
PACT_SLOT_TABLE = ((1, 1), (1, 2), (2, 2), (2, 2), (3, 2), (3, 2), (4, 2), (4, 2), (5, 2), (5, 2), (5, 3), (5, 3), (5, 3), (5, 3), (5, 3), (5, 3), (5, 4), (5, 4), (5, 4), (5, 4))
module-attribute
RitualCast
dataclass
Out-of-combat resolution of a Ritual-tagged spell (C17 R8). SRD 5.2 §Rituals: "The Ritual version of a spell takes 10 minutes longer to cast than normal, but it doesn't expend a spell slot. To cast a spell as a Ritual, a spellcaster must have it prepared."
derive_spell_slots(class_slug, progression, level)
Single-class Spellcasting-feature slots {slot_level: count}.
class_slug is carried for error messages / provenance only — the table
is keyed by progression, which the caller reads off
Class.spellcasting.progression. pact/none yield {}.
derive_pact_slots(level)
Pact Magic pool for a Warlock level — all slots share ONE level.
multiclass_caster_level(classes)
SRD §Multiclassing Spell Slots — per-class rounded contributions, summed (R2).
slots_for_caster_level(caster_level)
Multiclass Spellcaster table row for a (possibly multiclass) caster level; {} at 0.
effective_caster_level(progression, level)
Levels a single class contributes to the Spellcasting-feature total.
full 1:1; half ceil(level / 2); third floor(level / 3);
artificer is also a half-caster (ceil(level / 2), Foundry parity —
corpus-inert, no SRD class here carries it); pact / none contribute
0 (Pact Magic is its own pool).
resolve_target_count(count_formula, *, cast_level)
Foundry target.affects.count roll-data → int, with @item.level = the
cast's slot level (R5) — SRD 5.2 Magic Missile: "You create three glowing darts
of magical force. … The spell creates one more dart for each spell slot level
above 1." (target.affects.count == "2 + @item.level"). Supports integer
literals, + - * and parentheses; any other @ token or AST node raises
ValueError (loud — never a silent eval/exec, which bandit forbids
here anyway). Blank/whitespace-only ⇒ None (no count semantics). Floors
at 1 (a spell never targets fewer than one creature via this path).
count_scales_with_cast_level(count_formula)
True when a Foundry target.affects.count formula genuinely encodes the
R5 upcast mechanic — i.e. it references @item.level (the cast's slot
level). A blank formula, or a FIXED marker like "1" (the schema default
that a plain single-target damage/save/utility activity carries — Hex,
Hunter's Mark, Revivify, Wall of Fire's per-creature save, ...), is NOT an
upcast mechanic: it must not engage the R5 count-expansion machinery (target
fan-out, damage dice-scaling suppression) at all. The single source of truth
both orchestrator._find_count_activity and activities/damage.py's
dice-scaling guard consult.
resolve_ritual_cast(spell, *, prepared, ritual_adept=False)
Resolve a Ritual-tagged spell cast outside the turn economy (R8).
SRD 5.2 §Rituals: "To cast a spell as a Ritual, a spellcaster must have
it prepared." §Ritual Adept (feat): "You needn't have the spell
prepared." Raises ValueError when spell carries no Ritual tag, or
when neither prepared nor ritual_adept is set. The 10-minute
Ritual tax is additive on top of the spell's own casting time (minute
unit adds its value in minutes; hour adds value * 60; any
other unit — e.g. action — contributes 0 base minutes).
Pure resolution of a CharacterBuildSpec into a complete PartyMemberSpec.
Character-derived fields (abilities, class/subclass/level, base_speed) come from the build-spec + the library; combat-instance fields (hp/ac/initiative/zone/...) come from CombatInstance. Feature activities (piece 4) and senses/resistances (piece 5) layer on later via the same seam.
build_party_member(build_spec, instance, *, loader)
Live combat view
Public read-model for live combat state.
get_live returns a LiveCombatView — a point-in-time snapshot
projection of the engine's private _LiveCombat. Host-side resolvers
that run alongside the engine's dispatch consume this stable surface,
never the private dataclass. Container fields are copied (outer + inner)
so the view does not observe later engine mutations; the Combatant and
CombatOutcome items are shared references (the host reads, never
mutates them).
LiveCombatView
dataclass
Snapshot projection of live combat state for host consumers.
Rest & recovery
Library-side standalone rest resolvers — Short Rest, Long Rest, feature recovery.
Public surface for SRD 5.2 rest & recovery, mirroring check's
standalone / no-combat-handle pattern. A rest has no resolvable seam inside a
live combat: PlayerIntent.intent_type structurally cannot express "short_rest"
(see events.py::IntentType), and SRD 5.2 §Short Rest / §Long Rest list "Rolling
Initiative" as one of a rest's own interruptions — a rest occurring inside a combat's
turn loop (which begins with Rolling Initiative) would be definitionally self-
contradicting. Hosts therefore call these pure functions between combats.
Purity: zero I/O, zero host imports. Loader access (e.g. resolving a class's
hit_die to hit_die_size) stays with the caller — the resolver reads only the
value-typed inputs it is handed, exactly like check.resolve_check.
SRD 5.2 ground truth (2024 ruleset, content24/):
- §Short Rest — "For each Hit Point Die you spend in this way, roll the die and add
your Constitution modifier to it. You regain Hit Points equal to the total (minimum
of 1 Hit Point)." Foundry parity (
actor.mjs::rollHitDie,max(1, 1d<HD> + @abilities.con.mod)) floors EACH die's individual roll+CON at 1 under the 2024 ("modern") ruleset — the floor applies per die, not to the sum. - §Long Rest — "Regain All HP. You regain all lost Hit Points and all spent Hit Point Dice." FULL recovery of both. (The 2014 sibling pack in this repo's raw sources reads a HALF-hit-dice rule; that edition is NOT what this repo pins — do not encode it.) Exhaustion reduction is modelled as a pure level input/output (the caller passes the creature's current Exhaustion level in, the resolver returns it decreased by 1, floored at 0); HP-maximum / ability-score restoration still has no producer anywhere in the engine to restore.
RecoveryPeriod = Literal['sr', 'lr', 'dawn', 'day', 'dusk']
module-attribute
HitDicePool
dataclass
A creature's Hit Point Dice pool at a single die size.
hit_die_size is the die's face count (an L5 Fighter's d10 → 10,
sourced by the caller from loader.get_class(...).hit_die). dice_remaining
are the unspent dice available to spend on a Short Rest; dice_total is the
full pool a Long Rest restores to.
RestOutcome
dataclass
Result fragment for a resolved rest.
healed / dice_spent / dice_remaining / rolls describe a Short
Rest's hit-dice spend (rolls is empty for a Long Rest, whose recovery draws
no dice). hp_current and pool are populated by resolve_long_rest
(the caller reads the post-rest HP and the fully-restored pool from them) and
left None by resolve_short_rest, which only mutates the dice pool.
spell_slots / pact_slots are populated only when the matching resolver
call was handed that pool's _max (a fresh, fully-restored dict);
exhaustion_level is populated only when resolve_long_rest was handed one
(the reduced level, floored at 0). Each stays None when the caller supplied
no such input — see resolve_short_rest / resolve_long_rest for which
pools each rest type restores.
resolve_short_rest(pool, dice_to_spend, con_modifier, *, rng, pact_slots=None, pact_slot_max=None)
SRD 5.2 §Short Rest — spend dice_to_spend Hit Point Dice to heal.
Each spent die heals max(1, 1d<hit_die_size> + con_modifier) — the 2024
Foundry-parity per-die floor (the minimum applies to EACH die's own roll+CON,
never to the summed total). Pure: every draw flows through the passed-in rng.
Rejects an overspend (dice_to_spend exceeding pool.dice_remaining) and a
negative spend with ValueError; the resolver never silently clamps.
Pact Magic — "You regain all expended Pact Magic spell slots when you finish a
Short or Long Rest." — is the ONLY slot pool a Short Rest recovers; pass
pact_slots/pact_slot_max to restore it (outcome.pact_slots is a fresh
dict set to the max). Regular Spellcasting slots are never touched here —
resolve_short_rest deliberately takes no spell_slots parameter.
resolve_long_rest(pool, hp_current, hp_max, *, spell_slots=None, spell_slot_max=None, pact_slots=None, pact_slot_max=None, exhaustion_level=None)
SRD 5.2 §Long Rest — restore ALL HP and ALL spent Hit Point Dice.
Full recovery per the 2024 ruleset (content24/): hp_current returns to
hp_max and the Hit Dice pool refills to pool.dice_total — NOT the 2014
half-hit-dice rule. Pure: draws no dice.
"Finishing a Long Rest restores any expended spell slots." — pass
spell_slots/spell_slot_max (regular Spellcasting) and/or
pact_slots/pact_slot_max (Pact Magic) to restore either pool; each
stays None when its inputs are omitted. "Exhaustion Reduced. If you have
the Exhaustion condition, its level decreases by 1." — pass exhaustion_level
to get back max(0, exhaustion_level - 1); omit it (None) to leave
Exhaustion untouched. HP-maximum / ability-score restoration still has no
producer anywhere in the engine to restore.
recover_feature_uses(counters, period, recovery=None, *, rng=None)
Apply a rest's feature-use recovery to a caster's custom_counters sidecar.
Tracks feature_use:<slug> keys; see _recover_uses for the shared
recovery-rule contract. rng, when supplied, lets a non-literal formula
rule roll as dice instead of being preserved unchanged.
recover_item_uses(counters, period, recovery=None, *, rng=None)
Apply a recharge period to item_use:<slug> charge pools.
Same contract as recover_feature_uses; recovery maps item slug
→ its Item.uses.recovery rules. Dice formulas ("1d6 + 1", the dominant
wand recharge) roll through rng; without an rng they preserve the
counter unchanged.
Effects
D&D 5e Active Effects — Foundry VTT dnd5e-aligned schema.
of the dnd5e-engine extraction: the prior effect_id /
source_entity_id / rounds_remaining / modifiers: list[EffectModifier]
shape is replaced by the Foundry-aligned model. EffectModifier and
EffectRef retire.
Reference: /tmp/foundry-dnd5e/module/documents/active-effect.mjs and
/tmp/foundry-dnd5e/module/data/active-effect/. Foundry's structural
choices (statuses-set replaces bridge-conditions, structured duration,
changes[] with mode/value/priority, origin UUID) carry over verbatim
where applicable. The changes[].key vocabulary uses a host's flat
namespace ("attack.roll.bonus", "save.wisdom.bonus",
"flags.advantage.
ActiveEffect
Bases: BaseModel
Foundry-aligned ActiveEffect document model.
id is the Foundry _id analog (template id, e.g. "effect:bless").
origin collapses prior source_effect_id + source_id into a single
UUID-style string ("cast:bless:1", "item:sword+1:abc12"). target_id
is the parent Actor analog — combatant id. statuses is the set of
condition slugs the effect imposes (REPLACES the prior
bridge_conditions derivation). flags is a free-form dict for
extensibility; uses {"concentration": bool,
"applicable_action_types": list[str]} until those fields warrant
promotion. One flag is a duration, not a modifier:
{"until_end_of_next_turn_of": "<entity_id>"} expresses SRD's "until
the end of your next turn" — the engine expires the effect at that
actor's next turn end, granting a one-turn grace when the effect was
applied during that actor's own turn.
Pure Pydantic model. Zero I/O. Engine-owned during combat; the host does not persist instances of this class between combats (combat-only scope per spec).
ActiveEffectChange
Bases: BaseModel
One mechanical delta. Foundry CONST.ACTIVE_EFFECT_MODES for mode.
Key vocabulary (the host namespace):
attack.roll.bonus — +N or formula on attack rolls
damage.bonus — +N or formula on damage rolls
ac.bonus, ac.override — AC modifications
save.
Value polymorphism: int for scalar add/multiply; str for dice
formulas ("1d4", "1d4+2"); bool for advantage flags via override.
ActiveEffectDuration
Bases: BaseModel
Structured duration. All three counters tick in combat (F3b).
SRD 5.2 §Duration puts a round at about 6 seconds, so the engine reads the
three fields as follows (orchestrator._tick_durations_at_turn_end for
rounds, orchestrator._expire_timed_effects_at_turn_end for the
rest; both are turn_end hooks on turn_lifecycle):
rounds
Decremented once per round at the caster's turn end (parsed from
the effect origin; item/environment origins fall back to the
target's turn end). Reaching zero emits
EffectExpired(reason="duration").
turns
Decremented at the target's own turn end — durations counted in
the subject's turns rather than the caster's. Independent of
rounds: whichever counter hits zero first expires the effect.
seconds
Narrative-time duration, read in combat as ceil(seconds / 6)
rounds and ticked exactly like rounds (caster-keyed). The derived
count is materialised once into rounds (and decremented in the
same pass, so seconds=12 is indistinguishable from rounds=2);
seconds itself is never mutated. If an effect carries both
rounds and seconds, rounds wins — the seconds branch only
fires when rounds is None. Foundry packs routinely ship both
(Bless: rounds=10, seconds=60).
start_round / start_turn are carried for host bookkeeping and are
not read by the engine. Concentration-flagged effects are exempt from
every branch above: the concentration cascade and the per-turn repeat save
own their lifetime, and the packs' counters on them are display-only
(Hunter's Mark ships seconds=600).
Dice
Active-effect resolver helpers — Foundry-shaped changes vocabulary.
retires apply_effect_modifiers / derive_applicable_action_types / derive_condition_scope / filter_stacking / get_bridged_conditions. Replacements: - apply_changes_to_check folds add-mode and override-mode changes into a check bucket's running total. - filter_changes_by_bucket selects ActiveEffectChange entries whose key matches a target bucket. - dedupe_by_identity dedupes effects by (target_id, id, origin) — the Foundry-shaped identity tuple.
Pure functions, zero I/O.
roll_dice_str(expr, rng=None)
Roll a plain "NdM" / "NdM+K" / "NdM-K" expression.
Pass a seeded random.Random as rng for a reproducible result. With
rng=None the dice are drawn from the process-global random module,
which is only reproducible if the caller seeds it. In-combat rolls never
come through here — they use the seeded generator threaded from
start_combat(rng_seed=...).
Events and intent types
Combat evaluator event union.
Exhaustively defined here so per-effect implementers + scenario authors do NOT extend the union at runtime; any new event type lands as a scaffold-extension PR that updates this module first.
Typed-semantics rule (CLAUDE.md): every field over a closed set is a
Literal[...] or dedicated enum, never bare str.