0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI VOUCHER_SYSTEM
UTC 00:00:00
◀ RETURN
VOUCHER_SYSTEM.md 1197 words ~5 min read Updated 2026-07-03

Vouchers

Status: Built (compiles; PIE/runtime verification pending). v1 = the three account-unlock kinds. Decided: 2026-06-15 Companion to: CONSUMABLES_SYSTEM.md, QUEST_SYSTEM.md, THEME.md

Scope

A voucher is an inventory item that, when used from the bag, calls one grant API and consumes itself. v1 grants exactly one of: a spellbook, a loadout ("ship"), or a loadout modifier ("pilot"). Lets high-value drops (slayer, bosses) hand out account unlocks as ordinary lootable/tradeable items.

Reward-and-forget. A voucher fires its grant once on redeem and the stack decrements. There is no manifest, no startup reconciliation, no pending queue — unlike the quest Reward Pipeline, which bakes every grant to a FQuestRewardManifest diffed on each launch. Vouchers are deliberately not unified with that pipeline: the delivery vehicle is the item itself, the player already holds it, and there is nothing to re-grant. The two reach the same three grant APIs but through separate, non-shared code (see Why not the quest pipeline).

This mirrors the dispatch shape of Consumables (row-reference marker → trigger tag → GameplayEvent → authority re-validate → decrement → act) but is its own ability and its own definition — a voucher is not a consumable.

Quick Reference

Topic Answer
Marker FItemRow.Voucher (TObjectPtr<UVoucherDefinition>) + Item.Voucher tag; validator pins tag ⇔ ref
Action surface Action.Trigger.Item.RedeemURedeemVoucherAbility (LocalPredicted)
Variants Spellbook / Loadout / LoadoutModifier (enum discriminator)
Grant APIs UnlockBook / GrantLoadoutId / GrantInstance (same APIs quest rewards use; reached independently)
Waste gate Idempotent variants (Spellbook, Loadout) rejected with a toast if already owned; LoadoutModifier never gated
Net policy LocalPredicted; grant + stack decrement are authority-only
Reconciliation None. Reward-and-forget
Default menu verb Redeem, top of the priority cascade for voucher rows

Voucher Definition

UVoucherDefinition is a UDataAsset referenced from a new FItemRow.Voucher field (mirror of FItemRow.Consumable at ItemRow.h:103). Non-null marks the row as a voucher; the Item.Voucher tag is the fast-path hint for the menu builder, with the validator enforcing tag ⇔ reference both directions.

Flat enum-variant struct (same shape as FQuestReward, but a distinct type — no shared code):

UENUM(BlueprintType)
enum class EVoucherKind : uint8 { Spellbook, Loadout, LoadoutModifier };

UCLASS(BlueprintType)
class CRADL_API UVoucherDefinition : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere) EVoucherKind Kind = EVoucherKind::Spellbook;

    // Spellbook
    UPROPERTY(EditAnywhere, meta=(Categories="Spellbook", EditCondition="Kind==EVoucherKind::Spellbook", EditConditionHides))
    FGameplayTag BookTag;

    // Loadout ("ship") — PrimaryAssetType "LoadoutDefinition"
    UPROPERTY(EditAnywhere, meta=(EditCondition="Kind==EVoucherKind::Loadout", EditConditionHides))
    FPrimaryAssetId LoadoutAssetId;

    // LoadoutModifier ("pilot") — PrimaryAssetType "LoadoutModifierDefinition"
    UPROPERTY(EditAnywhere, meta=(EditCondition="Kind==EVoucherKind::LoadoutModifier", EditConditionHides))
    FPrimaryAssetId ModifierAssetId;
};

Hard UDataAsset ref from the row matches the Consumable precedent — redeem is frame-coherent and the def is low-KB metadata.


The Redeem Action

URedeemVoucherAbility triggers on Action.Trigger.Item.Redeem, NetExecutionPolicy = LocalPredicted — same dispatch shape as every other inventory-action ability. Payload mirrors consume: OptionalObject = source IItemContainer, EventMagnitude = packed slot index.

Authority sequence (server side, single tick): 1. Re-validate gates (alive; slot non-empty; row resolves to a voucher with a non-null Voucher ref; waste gate below). 2. Decrement the source stack by 1 — unconditional once gates pass. 3. Dispatch by Kind to the matching grant API on the owning ACradlPlayerState:

Kind Grant API Idempotent?
Spellbook USpellbookComponent::UnlockBook(BookTag) Yes (set membership)
Loadout ULoadoutComponent::GrantLoadoutId(LoadoutAssetId) Yes (appends if absent)
LoadoutModifier ULoadoutModifierComponent::GrantInstance(ResolveDefinition(ModifierAssetId)) No — mints a fresh FGuid per call
  1. Post a success toast to the owning client; optional GameplayCue.Item.Redeem.
  2. EndAbility.

Waste gate (re-checked on authority before decrement, mirrors the consume full-HP gate): for the idempotent variants only, reject the redeem if the player already owns the target — Spellbook already in USpellbookComponent's unlocked set, or Loadout already in ULoadoutComponent::UnlockedLoadouts. Posts a player-facing message ("You already know this spellbook." / "You already own this loadout."); no decrement, no grant. LoadoutModifier is never gated — each redeem deliberately mints another pilot instance, so there is no waste case.

Net policy: grant and stack decrement are authority-only (HasAuthority-gated); the resulting replicated state flows back through the standard component channels. Prediction is vestigial here — the only client-side payoff is the redeem cue/toast — but LocalPredicted keeps the dispatch path identical to every other item action.


Context Menu

The default action for a voucher row is Redeem. Wiring lives entirely in UInventoryComponent::GatherSlotActionsUItemSlotWidget dispatches whatever entry has bDefault set, with no per-verb hardcoding.

Redeem enters as the top of the priority cascade, preserving the single-bDefault / single-bChordPrimaryOne invariant:

bVoucher    → Redeem    (bDefault=true,                       chord=true)
bConsumable → Consume   (bDefault=!bVoucher,                  chord=!bVoucher)
bEquippable → Equip     (bDefault=!bVoucher && !bConsumable,  chord=likewise)
              Drop / Drop-X / Drop-All  (never default)
              Examine   (bDefault = !bVoucher && !bConsumable && !bEquippable)
  • bVoucher = Row && Row->Tags.HasTag(CradlTags::Item_Voucher) — tag fast-path, same as bConsumable.
  • Added only in the non-modal branch, so Redeem is hidden while banking (deposit-only) and shopping (sell-only) — the same fat-finger guard that hides Consume. Close the modal to redeem.
  • Drop / Drop-X / Drop-All remain — vouchers are ordinary droppable/tradeable items.
  • In practice a voucher is a distinct item kind, so its menu is Redeem · Drop · Drop-X · Drop-All · Examine.

Validator

New UVoucherDefinition validator under Source/CRADLEditor/Validators/ (per CLAUDE.md "validators shadow the runtime structs"): - Items DataTable: Item.Voucher tag ⇔ non-null Voucher ref, both directions. - Per-variant required field is set: BookTag valid (Spellbook), LoadoutAssetId valid + PrimaryAssetType == "LoadoutDefinition" (Loadout), ModifierAssetId valid + PrimaryAssetType == "LoadoutModifierDefinition" (LoadoutModifier). - Resolve tags via RequestGameplayTag(string), not CradlTags::X — native tag symbols don't cross-module link into CRADLEditor.


Tags

New: - Action.Trigger.Item.Redeem — GameplayEvent trigger for URedeemVoucherAbility. - Action.Item.Redeem — activation-owned tag. - Item.Voucher — row marker / menu fast-path. - GameplayCue.Item.Redeem — optional cosmetic on redeem.

Per CLAUDE.md's tag-declaration rule, only tags referenced by name in C++ get a UE_DECLARE_GAMEPLAY_TAG_EXTERN; the rest live in the .ini only.


Why not the quest pipeline

A previous attempt to unify item-driven grants with the quest reward pipeline produced more problems than it solved. The boundary is deliberate:

  • Quest rewards are owed contractually by a definition the player completed — the manifest exists so authoring changes (XP bumped, item added) reach players who already finished the quest, and so overflow can be deferred and replayed. They are delta-reconciled on every startup.
  • Vouchers are owed by an item the player is holding. Redeeming consumes it. There is no future obligation to reconcile, no overflow surface (the grant either lands or the waste gate refused it), and no authoring-drift case (the voucher is the source of truth, and it's gone after use).

They share the three grant component APIs and nothing else. No FVoucherRewardFQuestReward extraction, no shared orchestrator. If a fourth grant type is ever wanted in both, it is added to each independently.

Out of Scope

  • XP and plain-item voucher kinds — v1 is the three account-unlock kinds only (XP/items have no "already owned" semantics and are better as direct drops anyway).
  • Rolled magnitudes on modifier vouchers — GrantInstance is called with empty RolledMagnitudes (same as quest-granted modifiers at Phase 1).
  • Multi-grant vouchers (one item granting several unlocks) — one voucher, one grant.
  • UI for previewing what a voucher contains before redeeming (examine text covers v1).

Decision Owners

A, R, J — decided 2026-06-15.