Skip to content

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 a CombatHandle you 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 a CombatOutcome.

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.Random seeded by start_combat(rng_seed=...). Same seed + same intent sequence reproduces the same combat exactly, independent of global random state.
  • 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_id must 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 IntentRejectedError so 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 no CharacterUnconscious event; PCs at 0 HP surface via CharacterHpChanged(new_hp=0, …) and only escalate to CharacterDied via the death-save outcome path.
  • Residual HP → CharacterHpChanged.
  • Loot → ItemTransferred / ItemCreated per 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 between a and b on 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?

  1. Line of sight (walls / blocked cells) is required for every sense — Blindsight: "you can see anything that isn't behind Total Cover".
  2. Blindsight or Truesight whose range reaches b sees through Darkness and heavy obscurement.
  3. A Heavily Obscured cell (obscurement_cells == "heavy") is opaque to sight; Darkvision does not help (it only re-grades light).
  4. Bright or Dim Light in b is visible ("in a Lightly Obscured area ... you have Disadvantage on Wisdom (Perception) checks" — attacks are unaffected).
  5. Darkness in b needs Darkvision reaching b ("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 with max(|dx|, |dy|) <= radius_cells from origin (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": requires direction (a nonzero grid-offset vector, normalized to one of the 8 unit grid directions); the radius_cells + 1 cells 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 discard origin itself. Behaviour is pinned by tests and is not changing before the 0.7 template rework.
  • "cone": requires direction; 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 exceed forward — a widening 45° triangle from the origin, origin included (same SRD caveat as "line" above: the caller excludes it). See docs/dev/spatial-geometry.md for the full rationale (an engine convention, not literal SRD prose geometry — squares have no single canonical cone rasterization).
  • "cube": requires direction; a face-anchored n x n block (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). See docs/dev/spatial-geometry.md for 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's SPELL_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 own computeProgression also treats artificer as 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 the artificer progression.
  • 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 d1010, 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."), not Foundry's Actor-data dotted paths.

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..bonus — saving-throw bonus (ability lowercase) check..bonus — skill_check / ability_check bonus flags.advantage., flags.disadvantage. — override-mode boolean adv/disadv

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.

CombatEvent = Annotated[RoundStarted | RoundEnded | TurnStarted | TurnEnded | TurnPhase | IntentSubmitted | AttackRolled | SaveRolled | CheckRolled | DamageApplied | HealingApplied | TempHpApplied | EffectApplied | EffectExpired | ConditionApplied | ConditionRemoved | ConcentrationCheck | ConcentrationDropped | Unconscious | DeathSaveStarted | DeathSaveRolled | Stabilized | Death | ZoneTransit | ActorMoved | CombatantMoved | DashTaken | MoveFailed | AttackFailed | CastFailed | SpellCast | ReactionTriggered | CombatEnded, Field(discriminator='type')] module-attribute

IntentType = Literal['attack', 'cast_spell', 'use_item', 'move', 'dash', 'dodge', 'disengage', 'hide', 'help', 'ready', 'reaction', 'move_mark', 'use_feature', 'pass', 'drop_concentration', 'grapple', 'shove', 'stand_up', 'escape_grapple'] module-attribute