Skip to content

Migrating from 0.2.x to 0.3.0

This is the migration guide for the nat20 v0.3.0 lockstep release — the item charge lifecycle: gate → spend → cast-delegate → upcast → recharge → observe. Both workspace packages (dnd5e-engine and dnd5e-srd-data) version in lockstep from 0.2.x to 0.3.0.

Every change in this release is additive — new optional data fields, a new public engine module function, new CastFailedReason values, and a new LiveCombatView field. No existing field moved, no existing signature changed shape, and an item with no uses pool is completely unaffected (today's fully-ungated use_item behavior is preserved byte-for-byte for such items).

Data (dnd5e-srd-data)

Item.uses (new, additive)

Item gained uses: ItemUses | None = None, carrying a magic item's Foundry system.uses block: max (a literal integer or a @-prefixed roll-data token), spent, auto_destroy, and recovery: list[RecoveryRule] (period / type / formula, mirroring the Feature.uses shape from 0.2.0's Cluster 9). Regenerating the canonical corpus populated uses on 225 of 546 canonical items (the ones whose upstream Foundry source actually carries a charge pool); the rest keep "uses": null. Byte- deterministic (make check-regen-clean); no other field changed.

RecoveryPeriod widens: "dawn" / "dusk" (new, additive)

The RecoveryRule.period literal gained "dawn" and "dusk" alongside the existing "sr" / "lr" / "day" / "recharge" / "initiative" values — several magic items (e.g. wands that recharge "at dawn") recharge on a solar boundary distinct from a rest. Purely an enum widening; no existing period value's meaning changed.

Spell.foundry_uuid + AssetLoader.get_spell_by_uuid (new, additive — protocol member)

Spell gained foundry_uuid: str (the full Compendium.dnd5e.<pack>.Item.<_id> identity string) on all 339 canonical spells. AssetLoader.get_spell_by_uuid(uuid) -> Spell | None is a new required member of the AssetLoader protocol (a lazy uuid → Spell index on BundledAssetLoader), added specifically so the engine's cast activity handler can resolve a magic item's CastActivity.spell.uuid reference back to its full Spell document.

Who is affected — third-party AssetLoader implementers: any host implementing its own AssetLoader (rather than using the bundled one) must add a get_spell_by_uuid method to stay protocol-conformant. Both bundled implementations (BundledAssetLoader and the test loader) already carry it.

Known data gap: rod-of-alertness's cast activity references a legacy Compendium.dnd5e.spells.Item.… uuid — the 2014 "spells" compendium, which the SRD 5.2 corpus does not carry. This is the one confirmed-unresolvable cast uuid in the corpus and is a documented test exception (_KNOWN_UNRESOLVABLE_CAST_UUIDS in dnd5e-srd-data's bundled-loader test); every other item cast uuid resolves.

Engine (dnd5e-engine)

The engine now depends on dnd5e-srd-data>=0.3.0 (the charge-gate and cast-delegation paths read the new Item.uses / Spell.foundry_uuid schema).

Item charge gate: item_use: sidecar, gate-before-budget (new, additive)

use_item now validates and spends consumption.targets[type=itemUses] costs against the item's uses.max pool, tracked the same way Cluster 9's feature_use: counters are — a per-actor custom_counters sidecar entry under item_use:<slug> ({"spent": n}). The gate runs before any action budget is touched (mirroring the feature-use-cap precedent): an exhausted pool rejects with CastFailed(actor_id=..., spell_id="", reason= "no_charges_remaining") and the Action/Bonus Action is never spent. Items with no uses pool (cap is None) are never gated — today's fully-ungated use_item behavior is preserved exactly for them, unless the intent itself carries charges_to_spend (see below), which has nothing to scale against on a pool-less item and rejects invalid_charge_spend.

CastFailedReason (events.py) gained two new values:

  • "no_charges_remaining" — a capped item's remaining pool cannot cover this invocation's cost.
  • "invalid_charge_spend" — a charges_to_spend request is out of bounds (see "Charge upcasting" below).

Who is affected: any host match-ing exhaustively on CastFailedReason must add both new arms. A host whose combats include a magic item carrying a uses pool now sees use_item rejections it previously never got (or previously got silently over-spent).

One-invocation cost model + activity_id selection (behavioral clarification)

A single use_item invocation spends the cost of exactly ONE activity on the item — activity_id (from Cluster 7's repertoire-selection field, extended to items) disambiguates which one when an item exposes more than one (staff-of-striking's multiple strike tiers, rope-of-climbing's utility activity alongside a cast). This closes a real bug: a multi-activity item was previously priced by an unstable "first activity" pick that could brick a legitimate invocation of a non-first activity. Omitting activity_id on a single-activity item is unaffected.

recover_item_uses (new, additive — top-level __all__ extended)

dnd5e_engine.rest gains recover_item_uses(counters, period, recovery=None, *, rng=None) -> dict[str, int], mirroring recover_feature_uses exactly but over item_use:<slug> pools. ITEM_USE_COUNTER_PREFIX = "item_use:" is exported from the dnd5e_engine.rest submodule only (not the top-level dnd5e_engine.__all__), same convention as FEATURE_USE_COUNTER_PREFIX:

from dnd5e_engine.rest import ITEM_USE_COUNTER_PREFIX

Both recover_feature_uses and recover_item_uses gained a new optional keyword-only rng: random.Random | None = None argument: a formula recovery rule that is a real dice expression (a wand's "1d6 + 1" recharge, not just a literal integer like Second Wind's "1") is now rolled through it. Passing no rng preserves the prior literal-formula-only behavior — a dice formula recovery rule without an rng leaves the counter unchanged, exactly like an unhandled non-literal formula did in 0.2.0.

Cast delegation on the PC path (new, additive — closes the wand-casts-its-spell gap)

A used item's CastActivity (activity.kind == "cast") now resolves its referenced spell — activity.spell.uuidSpell via dnd5e-srd-data's new get_spell_by_uuid — and re-enters activity resolution against the spell's own activities, on the PC use_item intent path (_build_cast_spell_book populates ActivityResolutionContext.spell_book for that path only). Previously cast activities always resolved against an empty spell_book={} and any uuid reference logged cast_spell_unresolved as a no-op — a wand never actually cast its spell.

Item DC / attack inheritance: a scroll/wand with a fixed save DC or attack bonus (activity.spell.challenge with override=True — 42 canonical cast activities carry one) threads that fixed value into the delegated cast's save_dc_override / attack_bonus_override, used verbatim instead of the wielder's own spellcasting stats. A non-override cast still clears the field for a NESTED cast (a grandchild spell never inherits a parent scroll's DC), but the TOP-LEVEL item wrapper itself falls back to the item path's own blanket flat DC/attack override rather than clearing it (a top-level wrapper has no spellcasting_ability of its own to compute a fallback from).

Who is affected: hosts whose combats include a magic item with a cast activity (wands, staves, scrolls). Using such an item now actually resolves the referenced spell's effects (damage, save, condition, …) instead of silently no-oping.

Charge upcasting: charges_to_spend + the closed scaling grammar (new, additive)

PlayerIntent gains charges_to_spend: int | None = None (ge=1). When set on a use_item intent, the requested cost is validated against the charged activity's consumption.scaling contract (SRD §Casting a Spell at a Higher Level, applied to a variable-charge item):

  • Scaling is only honored on a kind == "cast" activity with scaling.allowed and a positive base cost — scaling on a non-cast activity (a staff's damage activity, Ring of the Ram's attack) would need "extra dice per charge" semantics the resolver doesn't implement, so it's an honest invalid_charge_spend reject rather than silently charging N and delivering the base effect.
  • The evaluated ceiling supports a small closed formula grammar over @item.uses.value (the item's remaining charge count) — a bare token, or min(...) / max(...) wrapping it with a literal (e.g. a wand's min(@item.uses.value, 3)). Any other symbolic expression is unsupported (see "Known gaps" below).
  • A request below the activity's base cost or above the evaluated ceiling rejects invalid_charge_spend.

A valid upcast forces the delegated cast's level: base_level + (requested - base_cost), where base_level is the item wrapper's OWN spell.level override (never the referenced spell's base level — a scroll/wand casts AT its own printed level by default), threaded via the new ActivityResolutionContext.cast_level_override: int | None = None. A nested (nested-inside-a-cast) child activity never inherits the override — only the top-level item wrapper's own CastActivity consumes it.

LiveCombatView.custom_counters_by_entity (new, additive — read path)

LiveCombatView (dnd5e_engine.views) gains custom_counters_by_entity: dict[str, dict[str, dict[str, int]]] — a three-level snapshot copy of the live combat's per-actor counter sidecar (entity id → counter key → {"spent": n}), the same read-model pattern spell_slots_by_entity already uses. This is the host's ONLY read path for item_use:<slug> / feature_use:<slug> pool state — hosts mirror it per turn the same way they already mirror spell_slots_by_entity.

Host integration checklist

  • Seed item_use: counters at combat start. If a host tracks per-item-instance charges persistently (outside combat), it must project that persistent state onto PartyMemberSpec.custom_counters (the same field feature_use: seeding already uses) when building the party for start_combat — the engine has no independent memory of an item's charge history across combats.
  • Mirror LiveCombatView.custom_counters_by_entity per turn. Read it via get_live(handle) alongside spell_slots_by_entity, and persist the item_use:<slug> entries back to the host's own item/inventory model — this is the only supported read path for in-combat charge spend. custom_counters_by_entity also carries feature_use: entries in the same map; a host filtering for items specifically should key on the item_use: prefix (dnd5e_engine.rest.ITEM_USE_COUNTER_PREFIX).
  • Call recover_item_uses at rest/dawn boundaries. Between combats, at whatever cadence the host resolves short rests, long rests, or the dawn/dusk boundary, call recover_item_uses(counters, period, recovery, rng=...) on the persisted counter state (mirroring the existing recover_feature_uses between-combats call) and write the result back before the item is usable again in a subsequent combat.

Known gaps / follow-ups

  • Monster and reaction cast paths keep spell_book={}. Cast delegation is wired on the PC use_item intent path only (_build_cast_spell_book); the monster-turn path (advance_monster_turn) and the reaction paths (_resolve_readied_spell_cast, _drain_counterspell_reaction) still resolve cast activities against an empty spell book — a monster or a pre-armed reaction referencing a cast activity still no-ops (cast_spell_unresolved).
  • cast_level>9 rejection is log-only — charges are still spent. An out-of-bounds cast level (not (spell.level <= cast_level <= 9)) logs cast_invalid_level and returns without resolving the spell's activities, but the charge gate has already deducted the item_use: cost before the cast handler runs — an invalid upcast still burns charges for no effect.
  • charges_to_spend is ignored on non-use_item intents. The field only has meaning wired into the use_item gate; setting it on any other intent_type is silently inert (no validation, no effect).
  • @item.uses.value and bare * are unsupported in DAMAGE formulas — the closed scaling grammar covers consumption.scaling.max only. A formula that uses the same token inside a DAMAGE roll expression (the staff-of-the-magi-class case: damage scaled by remaining charges) is a separate, unimplemented resolution path — the roll evaluator has no @item.uses.value binding outside the scaling-ceiling evaluator.
  • CastFailed carries no item identifier. Both new reasons (no_charges_remaining, invalid_charge_spend) are emitted with spell_id="" — a host cannot recover which item triggered the rejection from the event alone; it must correlate against the PlayerIntent.item_id it just submitted.
  • rod-of-alertness carries a legacy, unresolvable cast uuid — see the data section above. Documented test exception, not expected to be fixed without an upstream data source for the 2014 "spells" compendium.