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"— acharges_to_spendrequest 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.uuid → Spell 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 withscaling.allowedand 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 honestinvalid_charge_spendreject 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, ormin(...)/max(...)wrapping it with a literal (e.g. a wand'smin(@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 ontoPartyMemberSpec.custom_counters(the same fieldfeature_use:seeding already uses) when building the party forstart_combat— the engine has no independent memory of an item's charge history across combats. - Mirror
LiveCombatView.custom_counters_by_entityper turn. Read it viaget_live(handle)alongsidespell_slots_by_entity, and persist theitem_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_entityalso carriesfeature_use:entries in the same map; a host filtering for items specifically should key on theitem_use:prefix (dnd5e_engine.rest.ITEM_USE_COUNTER_PREFIX). - Call
recover_item_usesat rest/dawn boundaries. Between combats, at whatever cadence the host resolves short rests, long rests, or the dawn/dusk boundary, callrecover_item_uses(counters, period, recovery, rng=...)on the persisted counter state (mirroring the existingrecover_feature_usesbetween-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 PCuse_itemintent 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 resolvecastactivities against an empty spell book — a monster or a pre-armed reaction referencing acastactivity still no-ops (cast_spell_unresolved). cast_level>9rejection is log-only — charges are still spent. An out-of-bounds cast level (not (spell.level <= cast_level <= 9)) logscast_invalid_leveland returns without resolving the spell's activities, but the charge gate has already deducted theitem_use:cost before the cast handler runs — an invalid upcast still burns charges for no effect.charges_to_spendis ignored on non-use_itemintents. The field only has meaning wired into theuse_itemgate; setting it on any otherintent_typeis silently inert (no validation, no effect).@item.uses.valueand bare*are unsupported inDAMAGEformulas — the closed scaling grammar coversconsumption.scaling.maxonly. A formula that uses the same token inside aDAMAGEroll expression (thestaff-of-the-magi-class case: damage scaled by remaining charges) is a separate, unimplemented resolution path — the roll evaluator has no@item.uses.valuebinding outside the scaling-ceiling evaluator.CastFailedcarries no item identifier. Both new reasons (no_charges_remaining,invalid_charge_spend) are emitted withspell_id=""— a host cannot recover which item triggered the rejection from the event alone; it must correlate against thePlayerIntent.item_idit just submitted.rod-of-alertnesscarries 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.