CRADL Consumables Implementation
Companion to CONSUMABLES.md (the contract), COMBAT_SYSTEM.md, and STAT_PIPELINE.md. This doc tracks the build order for consumables: phased delivery, per-phase rationale, task checklists, and verification gates. The contract doc says what consumables are; this doc says what we build first, what depends on what, and how we know each step works.
Consumables are greenfield on the gameplay side — the inventory UI already dispatches Action.Trigger.Item.Consume but no ability consumes the trigger. Combat MVP and the Damage meta heal path are already in place, so phase 1 lands on existing rails.
Conventions
- Phase status legend:
[ ]not started ·[~]in progress ·[x]done ·[!]blocked / deferred. - Verification gate: every phase ends with a runnable demo / observable behavior. If a phase can't be verified end-to-end, it's split.
- Cheat commands: test fixtures land under
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. - Per CLAUDE.md "validators in lockstep": the phase that adds
FItemRow.Consumableand authorsUConsumableDefinitionupdates the matching validator under Source/CRADLEditor/Validators/ in the same change. - Per CLAUDE.md "no UBT here": Claude does not build. After C++ edits, the user compiles and reports back.
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag & data scaffolding | [x] |
All later phases |
| 1 | Consume action + healing (food) | [x] |
2; closes the "eat a food" loop |
| 2 | Stat-boost potions | [x] |
(additive on top of 1) |
| 3 | Cross-references & doc surfacing | [x] |
(closes the documentation loop) |
Phase 0 — Tag & Data Scaffolding
Goal. All consumable tags declared, FItemRow extended with the Consumable reference, UConsumableDefinition asset class authored, settings extended, validator updated. No behavior yet. This phase produces a compiling, no-op-extended codebase that unblocks phases 1 and 2.
Rationale. Phases 1 and 2 both reference the asset shape, the tag namespace, and the row field. Landing them in a single low-risk PR avoids cross-phase merge churn and lets content authors stub a food/potion definition while gameplay code is still being written.
Tasks.
- [x] Consumable tags —
Source/CRADL/CradlGameplayTags.{h,cpp}+Config/DefaultGameplayTags.ini: - [x]
Action.Item.Consume(C++;ActivationOwnedTagsonUConsumeItemAbility.Action.Trigger.Item.ConsumeandItem.Consumablealready exist — no new declaration.) - [x]
Cooldown.Item.Consume.{Food, Potion}(.ini only — looked up via the consumable asset, not by symbolic reference in C++. Per CLAUDE.md "Declare tags in C++ only when referenced by name.") - [x]
GameplayCue.Item.Consume.{Food, Potion}(.ini only — same rationale; cue routing is by-tag, not by symbol. Must live underGameplayCue.*soUGameplayCueManagerindexes the subtree.) - [x]
SetByCaller.Item.Consume.Heal(.ini only for phase 0; will be promoted to C++ in phase 1 when the heal GE references it by name.) - [x]
UConsumableDefinitionasset — newUDataAssetat Source/CRADL/Inventory/ConsumableDefinition.h (sibling toItemRow.h): - [x]
EConsumableCategory { Food, Potion }enum (v1 leaves; new leaves are additive) - [x]
Categoryfield - [x]
TArray<FConsumableEffect> EffectswhereFConsumableEffect = { TSubclassOf<UGameplayEffect> Effect; float Magnitude; FGameplayTag SetByCallerTag; } - [x]
FGameplayTag CooldownTagOverride(optional — derive default fromCategory) - [x]
TSoftObjectPtr<UAnimMontage> ConsumeMontage(cosmetic — soft ref keeps non-consumable rows from pulling montages into memory) - [x]
FGameplayTag ConsumeCueOverride(optional cosmetic) - [x]
FItemRowextension (Source/CRADL/Inventory/ItemRow.h): - [x]
TObjectPtr<UConsumableDefinition> Consumable(nullable; non-null marks the item as consumable. Hard ref matches the precedent ofEquipEffectscarrying hard GE class refs — equip/consume is frame-coherent and defs are low-KB metadata.) - [x]
UCradlConsumableSettings— newUDeveloperSettingsat Source/CRADL/Inventory/CradlConsumableSettings.h (Project Settings → CRADL → Consumables): - [x]
float FoodCooldownSeconds = 1.8f(OSRS reference) - [x]
float PotionCooldownSeconds = 0.6f - [x]
TSoftClassPtr<UGameplayEffect> FoodCooldownEffect(default points atUGE_Cooldown_Item_Consume_Foodonce phase 1 lands) - [x]
TSoftClassPtr<UGameplayEffect> PotionCooldownEffect(default points atUGE_Cooldown_Item_Consume_Potiononce phase 2 lands) - [x] Validator update — extend the items DataTable validator at Source/CRADLEditor/Validators/CradlItemTableValidator.cpp:
- [x] Row tagged
Item.Consumable⇒Consumablereference non-null. Inverse direction too: non-nullConsumable⇒ tag present. - [x] If
CooldownTagOverrideis set, it must be a leaf underCooldown.Item.Consume.*(parent-level tags rejected — see contract footgun). (Enforced on the asset itself byUCradlConsumableDefinitionValidator; row-side enforcement is implicit via the asset's own validation.) - [x] Every
Effects[i].Effectnon-null; everyEffects[i].SetByCallerTagnon-empty. (Same — asset-level rule onUCradlConsumableDefinitionValidator.) - [x] New
UConsumableDefinitionValidatorat Source/CRADLEditor/Validators/CradlConsumableDefinitionValidator.cpp — mirrors the existing definition-validator pattern (USkillDefinition,URecipeDefinition, etc.). Validates per-asset: category set, effects array well-formed, cooldown override (if any) is a leaf, cue override (if any) lives underGameplayCue.Item.Consume.*. - [x] Append CLAUDE.md "Currently validated" list — add
UConsumableDefinitionto the bullet enumerating validated asset types.
Verification.
- Compile clean (user-side).
- Editor: create a new
UConsumableDefinitionasset, leaveEffectsempty, save. Validator fails with a clear message. - Editor: edit an item row, tick
Item.Consumabletag but leaveConsumablenull, save. Items DataTable validator fails. - Editor: Project Settings → CRADL → Consumables shows the new section with the four defaults.
Exits. Phase 1 has the asset shape, the row field, the cooldown settings, and the validator wired to enforce its invariants.
Phase 1 — Consume Action + Healing (Food)
Goal. A player can right-click "Consume" on a food item in inventory; the stack decrements by 1, HP restores by the food's authored amount, the food cooldown gate engages, and the cosmetic cue fires. Tested via a canonical food row authored end-to-end.
Rationale. Healing is the simplest of the four consumable channels and exercises every piece of the pipeline: ability triggering, server-side stack decrement, GE application via SetByCaller, the CheckCooldown gate, and the cosmetic surface. Once this loop runs cleanly, phase 2 (potions) is mostly content + per-stat GE classes — the architecture is already proven.
File layout note. GE classes for consumables live in a single shared header
Source/CRADL/Inventory/ConsumableGameplayEffects.h/.cpp, matching the existing
CombatGameplayEffects.h pattern (one file per
domain, multiple GE classes per file) — not per-class files under an Effects/
subfolder. Deviates from the original task list below but matches the codebase
convention.
Tasks.
- [x]
UConsumeItemAbility— newUCradlGameplayAbilitysubclass at Source/CRADL/Abilities/ConsumeItemAbility.{h,cpp}: - [x]
Trigger.TriggerTag = Action.Trigger.Item.Consumeset in constructor - [x]
NetExecutionPolicy = LocalPredicted - [x]
ActivationOwnedTagsincludesAction.Item.Consume - [x]
AbilityTagsincludesAction.Item.Consume(matchesUAbilitySystemComponent::CancelAbilities) - [x]
CancelOnTagsAddeddoes not includeAction.Combat.AutoAttack(per contract — consume and combat run free) - [x] Payload contract documented in header:
FGameplayEventData::OptionalObject= sourceIItemContainer;EventMagnitude= slot index. Mirror ofUEquipItemAbility. - [x]
CanActivateAbilityre-validates gates from the contract (alive, slot non-empty, row consumable, def non-null, cooldown clear). Implementation note:Status.Deadlives inActivationBlockedTags(stock GAS gate); slot/row/cooldown gates live insideActivateAbilitybecauseCanActivateAbility's signature doesn't carryTriggerEventData. - [x]
ActivateAbilityauthority path: decrement stack → iterateEffectsand apply each viaMakeOutgoingGameplayEffectSpec+SetSetByCallerMagnitude→ apply cooldown GE (resolved from category default; override deferred — see footgun) → execute cosmetic cue →EndAbility - [x] Stack decrement is unconditional once gates pass (OSRS-faithful; food is consumed even at full HP)
- [x]
UGE_Item_Heal— new instant-duration GE class in Source/CRADL/Inventory/ConsumableGameplayEffects.h: - [x] Single
FGameplayModifierInfotargetingUCradlAttributeSet::GetDamageAttribute() - [x]
ModifierOp = Additive - [x]
ModifierMagnitude = SetByCallerkeyed bySetByCaller.Item.Consume.Heal - [x] Authoring convention: pass the heal as a positive number on the consumable definition.
UConsumeItemAbility::ActivateAbilitynegates when filling SetByCaller for entries keyed bySetByCaller.Item.Consume.Heal(other entries pass through unchanged so Phase 2 boost magnitudes are not double-negated). ExistingPostGameplayEffectExecuteclamp + short-circuit atDamageDelta <= 0does the rest. - [x]
UGE_Cooldown_Item_Consume_Food— new duration GE in Source/CRADL/Inventory/ConsumableGameplayEffects.h: - [x] Duration =
UCradlConsumableSettings::FoodCooldownSeconds(resolved by the ability and written viaSetByCallerMagnitudekeyed byCooldown.Item.Consume.Food— same gate-tag-doubles-as-SetByCaller-key pattern asUGE_SpellCast_Cooldown/Cooldown.Combat.Cast). - [x] Granted tag
Cooldown.Item.Consume.FoodviaUTargetTagsGameplayEffectComponent - [x] No period
- [x]
UConsumeItemAbilityqueriesCooldown.Item.Consume.Fooddirectly on the owner ASC inActivateAbility(NOT via standardCheckCooldown, which can't differentiate by category from a fixedCooldownTagsset). The per-category gate replaces the standard path. - [x] Cooldown resolution helper —
ResolveCooldown(const UConsumableDefinition&)at ConsumeItemAbility.cpp (anon namespace), returns{GE class, granted tag, default duration}perCategory.CooldownTagOverrideis intentionally deferred — the GE class grants a hard-coded tag, so honoring an override would gate-check a different tag than the one granted. Validator already warns when set. - [ ] Canonical food row (editor authoring — outside what Claude can write):
- [ ] New
UConsumableDefinitionasset (e.g.,DA_Consumable_BasicFood) withCategory = Food, oneEffectsentry pointing atUGE_Item_Healwith magnitude10andSetByCallerTag = SetByCaller.Item.Consume.Heal - [ ] New row in the items DataTable referencing the asset, tagged
Item.Consumable - [ ] Validator passes on both the asset and the row
- [ ] Cosmetic cue notify (food) (editor authoring) —
UGameplayCueNotify_Static(or BP) forGameplayCue.Item.Consume.Food. Minimal SFX/VFX is fine; cue infrastructure exists, this is per-leaf authoring. - [x] Footgun guard —
UAutoAttackAbility::CancelOnTagsAddedconfirmed to excludeAction.Item.Consume(CradlAutoAttackAbilityBase.cpp's ctor lists onlyStatus.Stunned,Status.Dead,Action.Modal). The mirror exclusion lives inUConsumeItemAbility's constructor with a contract comment.
Verification.
- Launch game with a fresh player.
Cheat_SelfDamage 5 Slashto drop HP below max.- Right-click the food item in inventory → "Consume" appears as the default action.
- Click "Consume": stack decrements by 1, HP rises by 10 (clamped to MaxHealth), cosmetic cue fires, the food cooldown tag appears on the player's ASC.
- Click "Consume" again within the cooldown window: ability fails to activate (
CheckCooldownblocks); stack unchanged. - Wait for the cooldown to expire; consume succeeds again.
- Take 0 damage (already at max HP); consume: stack still decrements (OSRS-faithful); HP unchanged; no XP grant (verify via Hitpoints XP readout).
- Start an auto-attack engagement, consume mid-fight: engagement continues; swing rhythm unaffected; heal lands.
Exits. The full consume → heal → cooldown loop runs end-to-end. Phase 2 has the trigger ability, the cooldown infrastructure, and the asset shape it needs to add stat-boost variants.
Footguns.
- Server-authority order: decrement stack before applying GEs. If a GE class is null (authoring error caught by validator, but defensive), the stack still consumed — same authority-order discipline
UEquipItemAbilityuses for slot transitions. - Predicted client should not optimistically reduce the local stack —
UInventoryComponentreplicates slot state, double-counting would briefly desync. Predict only cosmetic side; let state replicate. CheckCooldownreads the ability's cooldown tags. IfCooldown.Item.Consume.Foodis missing fromUConsumeItemAbility::CooldownTags, the gate silently never engages (no compile-time signal). Verify in PIE that a second consume within 1.8s actually fails.
Phase 2 — Stat-Boost Potions
Goal. A player can consume a potion that emits a temporary buff GE into a Bucket-1 attribute (per STAT_PIPELINE.md), e.g., "+5 Strength for 60s." Stacks compose with gear/loadout contributions through standard GAS aggregation. Tested via a canonical potion row.
Rationale. Architecture is already proven by phase 1 — this phase is mostly per-stat GE class plumbing plus content authoring. Lands as a separate phase only so the food smoke test can ship without potion content getting in the way.
Tasks.
- [x]
UGE_Cooldown_Item_Consume_Potion— duration GE in Source/CRADL/Inventory/ConsumableGameplayEffects.h (shared file with Food cooldown): - [x] Duration =
UCradlConsumableSettings::PotionCooldownSeconds(SetByCaller-driven, same shape as Food cooldown) - [x] Granted tag
Cooldown.Item.Consume.Potion - [x] No period
- [x] Resolution helper in ConsumeItemAbility.cpp routes
Category::Potionto this class + tag + duration. - [x] Per-attribute boost GE classes in Source/CRADL/Inventory/ConsumableGameplayEffects.h:
- [x]
UGE_Buff_Strength—HasDuration(default 60s),Strengthmodifier (Additive), SetByCaller magnitude keyed bySetByCaller.Item.Consume.Boost.Strength - [x]
UGE_Buff_RangedStrength(Additive onRangedStrength) - [x]
UGE_Buff_MagicDamage(Additive onMagicDamage— magnitude is a fraction, e.g. 0.10 = +10%) - [x]
UGE_Buff_Defense(Additive onDefense) - [x]
UGE_Buff_AttackSpeed—MultiplicitiveModifierOp onAttackSpeed(default 1.0). Authoring magnitude 0.8 = "swing interval becomes 80% of baseline" (20% faster). Documented in the header. - [x] Duration is fixed per-class via
ScalableFloat(60s default). Different families = BP child classes overriding the duration on Default class settings. Decision: not addingFConsumableEffect.Durationfield; SetByCaller is reserved for magnitude only. - [x]
SetByCaller.Item.Consume.Boost.*tags — declared in C++ (CradlGameplayTags.h) because each boost GE class references its matching leaf by name inFSetByCallerFloat::DataTag. Five leaves underSetByCaller.Item.Consume.Boost, one per Bucket-1 attribute. - [ ] Canonical potion row (editor authoring):
- [ ]
DA_Consumable_StrengthPotionwithCategory = Potion, oneEffectsentry pointing atUGE_Buff_Strength(or BP child for a non-default duration), magnitude+5, SetByCallerTag =SetByCaller.Item.Consume.Boost.Strength - [ ] New row in the items DataTable referencing the asset, tagged
Item.Consumable - [ ] Cosmetic cue notify (potion) (editor authoring) —
UGameplayCueNotify_Static(or BP) forGameplayCue.Item.Consume.Potion. Different SFX/VFX from food so the audio surface communicates the difference. - [x] Validator extension (CradlConsumableDefinitionValidator.cpp):
- [x] Recognizes
EConsumableCategory::Potion(no new rule; phase 0's well-formedness checks already category-agnostic.) - [x] New rule (phase-2 footgun): any
Effects[i].Effectwhose CDO modifies a Bucket-1 attribute (Strength/RangedStrength/MagicDamage/Defense/AttackSpeed) must haveDurationPolicy ≠ Instant. Instant on a non-meta attribute writes BaseValue (permanent). Heal entries (instant on the Damage meta) are correctly excluded — Damage isn't in the Bucket-1 list.
Verification.
- Launch game; equip a melee weapon with a known
StrengthBonus. - Open
showdebug abilitysystem(or the combat stats overlay) and noteStrengthattribute value. - Consume the strength potion:
Strengthrises by 5; the buff GE appears on the ASC with a 60s duration; the cue plays. - Start an auto-attack engagement: the next damage roll reflects the buffed
Strength(sample several swings; max-hit reflects the +5). - Wait for the duration to expire:
Strengthreturns to base; subsequent swings reflect the un-buffed value. - Consume potion + food in the same window: each respects its own cooldown (food blocks food, potion blocks potion; they don't block each other).
- Consume a second potion while the first is active: per standard GAS aggregation, both contribute (verify the stacked magnitude).
Exits. Both v1 consumable categories ship. Phase 3 closes the documentation loop.
Footguns.
- Boost GE must be
HasDurationorInfinite(with explicit removal). Instant-duration writes toBaseValueand becomes permanent — silent contract break. Validator should rejectEffectsentries pointing at instant-duration boost GEs (consider adding this rule during phase 2). - Don't author a potion that writes into
AttackBonus/DefenseBonusmatrices (Bucket 2 — gear-only per STAT_PIPELINE.md). Potions touch the universal scalars; per-type defense debuffs are deferred. - Boost decay shape (linear-by-time vs stepwise hold-then-drop) is deferred. The first canonical potion uses a single duration GE (stepwise: hold the boost for the duration, drop at expiry). If/when a linear-decay potion is needed, author it as a chain of short-duration GEs — no contract change.
Phase 3 — Cross-References & Doc Surfacing
Goal. The consumables contract and implementation docs are discoverable from the docs that depend on them. No code; documentation only.
Rationale. Contracts only earn their keep if the next engineer touching the area finds them. Cross-linking is small but easy to forget; carving it out as a phase makes it explicit.
Tasks.
- [x] Cross-link from COMBAT_SYSTEM.md — "Out-of-Combat Recovery" rewritten to name food as a shipped channel pointing at this doc; "Health & Damage Application" footguns and Related section link to CONSUMABLES.md's "Healing Channel".
- [x] Cross-link from STAT_PIPELINE.md — "The Four Contributor Surfaces" fourth row now links CONSUMABLES.md; "Bucket 1 — Buffable scalars" paragraph names the boost channel as the buffs/potions implementation.
- [x] Update CLAUDE.md — "Reference architecture decisions when needed" bullet now names CONSUMABLES.md alongside ARCHITECTURE.md and STAT_PIPELINE.md.
- [x] Update COMBAT_IMPLEMENTATION.md "Deferred" section — "Out-of-combat HP recovery" bullet now names food as shipped and scopes the deferred work to rest stations only. "Healing cue fork" bullet removed from open-question status (settled: negative-Damage is the path).
Verification.
- Open each linked doc, click the new cross-references — all resolve.
- A reader landing on STAT_PIPELINE.md's Bucket-1 description can navigate to CONSUMABLES.md in one click.
Exits. Consumables v1 is fully landed and discoverable. Future work (decay shapes, overheal foods, throwables, combo foods) is additive per the contract's "Future (only if needed)" section and does not require new contract or implementation phases.
Deferred (out of v1 scope)
Mirrors the contract doc's Future (only if needed) and Out of Scope sections — implementation phases for these don't exist yet, but their absence is deliberate, not missed.
- OSRS-style eat-during-combat swing delay. Additive:
Status.Eatingtag pushed byUConsumeItemAbilityfor the cooldown window;UAutoAttackAbility::HandleSwingFinishedobserves it and reschedules itsWaitDelay. No contract change. - Overheal foods (anglers, brews). Additive:
Status.OverHealedtag + aMaxHealthduration GE that lifts the ceiling. Existing PostGameplayEffectExecute clamp + Bucket-1 aggregation handle the rest. - Boost decay shape (linear-by-time vs stepwise). Per-potion-family choice; no contract change.
- Combo foods (OSRS karambwan-style fast-eat). New
EConsumableCategoryleaf with its own cooldown tag, or aCooldownTagOverrideon the existing food category. - Throwables / targeted consumables. Payload extension; consume becomes a two-step (pick target → apply). Contract notes this is out of v1.
- Flasks / multi-use containers. Stack semantics change — currently single-use, decrement-on-consume.
- AI / NPC consumption. Forward-compatible (same ability triggered by an AI controller); explicit AI doc when AI lands.
- UI surfacing of cooldown progress and boost timers. Cue side is in scope; HUD widgets for "1.4s remaining on food cooldown" or "0:47 on Strength potion" are their own pass.
- Crafted consumable recipes. The recipe side (a
URecipeDefinitionthat produces a food row) is already covered by the existing recipe contract; no consumables-side work needed. - Balance numbers. Heal amounts, boost magnitudes, durations are tunable per-asset and not part of this build order.
Cross-cutting verification
End-state of consumables v1 is a single demo:
- Spawn the test map with a fresh player; ensure inventory has one food and one potion.
Cheat_SelfDamage 8 Slash→ HP drops;Status.InCombatengages.- Right-click the food → "Consume" → HP restores; cooldown tag engages; stack decrements.
- Immediately right-click the food again → ability fails (cooldown gate); stack unchanged.
- Right-click the potion → "Consume" → Strength rises; potion cooldown engages; food cooldown is unaffected; both can run simultaneously.
- Click a target dummy → auto-attack engages; consume the food mid-swing → engagement continues; swing rhythm unchanged; heal lands.
- Wait for the potion buff to expire → Strength returns to base; next swing reflects the un-buffed value.
- Open the bank → "Consume" option disappears from the right-click menu (inventory-action gating already excludes Consume while banking — inherited, not re-implemented).