DND Engine Registry

class_action

Registers under Class Actions · source: modules/class_action/v1

`action_cost` — see engine.action_economy.ActionCost; which of the two per-round budgets this draws from is Content-authored per ability, never hardcoded to a category (per the doc's explicit rejection of category-specific engine logic). `essence_type_id`/`essence_cost_category` — combat-engine-readme.md → Essence: "Governs how often abilities/effects can be used." Basic Attacks are explicitly exempt ("always free, regardless of Essence state") — Class Actions are exactly the "special abilities/spells" engine.essence.spend()'s docstring says it's for. Both are required, not defaulted: which pool an ability draws from, and how much, are real per-ability choices (typically your class's weapon category's baseEssenceTypeId — see content/weapon_categories/ — though nothing enforces that link; a class could flavor an ability to cost a different pool). See bot/engine_bridge.py's live_duel_class_action for where this actually gets checked/spent. `timedStatModifier` payload: {"category": one of VALID_BONUS_CATEGORIES, "modifierPercent": float, "durationRounds": int, "appliesTo": "self" | "enemy"}. Resolved via gamemaster/engine/ primitives/effects.py's Timed + tick_timed, summed against the category's base value through primitives.bonuses.total_bonus — see bot/engine_bridge.py's live_duel_class_action. `appliesTo` is required, not defaulted — which side an ability affects is a real design choice per ability, not something safe to assume silently. Self/enemy only in this first slice; a real ally-targeted (Help category) version needs the still-unwired Targeting primitive (see primitives/targeting.py) to pick which ally, not just "the enemy" (a 1v1 duel's only other combatant, no picking required). `shield` payload: {"absorbAmount": float, "durationRounds": int, "damageTypeScope": Optional[str]}. Now explicitly a Timed effect (see gamemaster/engine/primitives/effects.py's Shield dataclass + tick_shield) rather than a bare accumulating float: absorbs the caster's own next `absorbAmount` of incoming raw damage before Mitigation (see pipeline.apply_shield), self-only (an ally-targeted version needs Targeting, same gap as timedStatModifier above), and expires on EITHER duration-end OR being fully absorbed, whichever happens first (see tick_shield's own docstring). `damageTypeScope` is optional — omit it for a shield that absorbs any incoming damage type; set it to one DamageType id for a type-scoped ward (a "Fire Ward" absorbing only Fire damage — a real, confirmed feature, not hypothetical). Casting a second shield while one's still up always appends a new, independently-tracked entry (see session["playerShields"]) rather than merging into an existing one — simpler than reconciling two different scopes or durations into one pool, and not something the doc specifies either way. `crowdControl` payload: {"blockedActionTypes": a non-empty subset of VALID_BLOCKED_ACTION_TYPES, or the literal string "all", "durationRounds": int, "blocksEssenceSpend": bool}. Enemy-only in this first slice — there's no consumer for a self-inflicted lockout (nothing currently forces that on the player), so `appliesTo` isn't even accepted here, unlike timedStatModifier where the choice is real either way. Resolved via effects.py's Timed + tick_timed the same way as a stat-modifier effect, checked as a "does the enemy skip their turn entirely" gate in live_duel_end_round (specifically: is "attack" in the effective blockedActionTypes, since a basic Attack is the only thing the enemy AI ever queues) rather than feeding total_bonus. `blocksEssenceSpend` is validated and stored but currently a no-op — the enemy has no Essence pool of its own to be blocked from spending in this first slice (same "wireable now, no-op until its consumer exists" honesty as weaponTierUnlock). `dispel` payload: {"mode": "specific" | "all", "appliesTo": "self" | "enemy", "effectId": required only when mode == "specific"}. The one primitive that removes rather than applies (see effects.py's Dispel dataclass). `appliesTo` picks which side's active-effect stacks to search — reuses the exact same self/enemy choice timedStatModifier already makes, rather than inventing a second targeting scheme for the same question. "specific" strips exactly one active effect whose originating `classActionId` matches `effectId` (every active-effect entry this bot tracks is already keyed by the id of the Class Action that created it — see session["playerActiveEffects"]/ ["enemyActiveEffects"]/["enemyCrowdControl"]/["playerShields"] — so this reuses that existing identifier rather than inventing a separate "effect id" scheme the engine doesn't otherwise produce anywhere); "all" is a full cleanse of every active Timed/CrowdControl/ Shield effect on that side, no partial category filtering. See bot/engine_bridge.py's live_duel_class_action for exactly which session lists each side maps to. `hybridDamageNextHit` payload: {"damageTypeId": str} — combat-engine- readme.md → Essence: "Hybrid damage (a tipped arrow...) costs the 'mana' EssenceType in addition to the weapon's normal Essence cost." Content-authored as a Ranged-flavored ability costing `ammo` (Ranged's specialEssenceTypeId — see weapon_category/v1), representing nocking one special/elemental tipped arrow: the caster's NEXT Attack this fight also deals `damageTypeId` alongside the weapon's normal type(s), resolved via the same N-way hybrid split (pl.mitigate_hybrid) an Artificer-added permanent hybrid type already uses — this is the temporary, per-shot version of that, not a permanent weapon change. See bot/engine_bridge.py's live_duel_class_action/live_duel_attack for how the pending type gets set and consumed. Doc's literal "costs Mana IN ADDITION TO the weapon's normal cost" (a second, always-Mana charge on top of whatever pool the ability's own essence_type_id already draws from) is NOT modeled here — this module only supports one essence_type_id per action, so this is a deliberate, flagged simplification (single cost, not the doc's literal dual-resource charge), not a silent reading of an unambiguous spec — the doc itself calls the exact cost shape "deferred." `instantDamage` payload: {"diceCount": int, "dieSize": int, "relevantStatId": str, "damageTypeId": str} — the sixth and last combat Effect Primitive (Instant, see gamemaster/engine/primitives/ effects.py) finally gets a real consumer (2026-10-19, DND-27 playtest finding: dive_bomb was crowdControl-only — all stun, no damage, despite its own "dealing damage of the landing impact" flavor text). Design call made with the user: always lands, no Hit/Crit roll at all (a Class Action ability isn't a weapon swing); RawDamage = Hitdie(diceCount, dieSize) x StatMod(relevantStatId) — no ProficiencyDamageMod%, CritMultiplier, or gearLevel bonus, since none of those are weapon-attack concepts — then Mitigation against the enemy's Resistance% for damageTypeId, same as any other damage reaching them. `damage_relevant_stat_id` is the core stat id (e.g. "strength") the flat/community-settable path builds effect_payload["relevantStatId"] from — it can be ANY core stat, not necessarily the caster's weapon's own relevant stat; see bot/engine_bridge.py's live_duel_class_action, which reads it off session["attackerStats"] rather than the resolved weapon profile.

Example Usage

# Migrated from content/class_actions/arcane_barrier.py (2026-10-14
# cleanup pass) -- effect_payload={"absorbAmount": 20.0, "durationRounds": 3}
# becomes the flat absorb_amount/duration_rounds fields modules/class_action/v1
# builds the same payload from (see that module's own _PAYLOAD_BUILDERS).

resource "arcane_barrier" {
  source: modules/class_action/v1
  id: "arcane_barrier"
  class_id: "wizard"
  action_cost: "bonusAction"
  essence_type_id: "mana"
  essence_cost_category: "medium"
  effect_kind: "shield"
  absorb_amount: 20.0
  duration_rounds: 3
  description: "A shimmering ward absorbs the next 20 damage before it touches you."
}

Arguments Reference

The following arguments are supported:

  • id - (Required) The unique id this record is registered and referenced under — what a `.resource` file's own filename-independent identity is, and what any other content's foreign-key-style field (a dungeon node's `next`, a skill node's `parent_id`, and so on) points at. community
  • class_id - (Required) Currently registered: ranger, skylancer, warrior and wizard. community
  • action_cost - (Required) `action_cost` — see engine.action_economy.ActionCost; which of the two per-round budgets this draws from is Content-authored per ability, never hardcoded to a category (per the doc's explicit rejection of category-specific engine logic). Valid options are action and bonusAction. community
  • essence_type_id - (Required) `essence_type_id`/`essence_cost_category` — combat-engine-readme.md → Essence: "Governs how often abilities/effects can be used." Basic Attacks are explicitly exempt ("always free, regardless of Essence state") — Class Actions are exactly the "special abilities/spells" engine.essence.spend()'s docstring says it's for. Currently registered: ammo, mana and stamina. community
  • essence_cost_category - (Required) Valid options are heavy, light and medium. community
  • effect_kind - (Required) Valid options are crowdControl, dispel, hybridDamageNextHit, instantDamage, shield and timedStatModifier. community
  • effect_payload - (Optional) Defaults to None. developer
  • description - (Optional) Flavor text shown to players — pure display data with no effect on game mechanics. community
  • bonus_category - (Optional) Valid options are evasivenessPercent and toHitPercent. community
  • modifier_percent - (Optional) Defaults to 0.0. community
  • duration_rounds - (Optional) Defaults to 0. community
  • applies_to - (Optional) Valid options are enemy and self. community
  • absorb_amount - (Optional) Defaults to 0.0. community
  • damage_type_scope - (Optional) Currently registered: air, blunt, earth, fire, necrotic, piercing, poison, psychic, radiant, slashing and water. community
  • blocked_action_types - (Optional) Defaults to None. community
  • blocks_essence_spend - (Optional) Defaults to False. community
  • dispel_mode - (Optional) Valid options are all and specific. community
  • dispel_effect_id - (Optional) Currently registered: arcane_barrier, battle_cry, dive_bomb and hobbling_shot. community
  • hybrid_damage_type_id - (Optional) Currently registered: air, blunt, earth, fire, necrotic, piercing, poison, psychic, radiant, slashing and water. community
  • damage_dice_count - (Optional) Defaults to 0. community
  • damage_die_size - (Optional) Defaults to 0. community
  • damage_relevant_stat_id - (Optional) `damage_relevant_stat_id` is the core stat id (e.g. "strength") the flat/community-settable path builds effect_payload["relevantStatId"] from — it can be ANY core stat, not necessarily the caster's weapon's own relevant stat; see bot/engine_bridge.py's live_duel_class_action, which reads it off session["attackerStats"] rather than the resolved weapon profile. community
  • damage_type_id - (Optional) Currently registered: air, blunt, earth, fire, necrotic, piercing, poison, psychic, radiant, slashing and water. community