CRADL Relic System
Companion to ARCHITECTURE.md, PILOT_SYSTEM.md, QUEST_SYSTEM.md, and VOUCHER_SYSTEM.md. This document is the contract the relic system must satisfy — its persistence shape, the two reward pathways that grant it, the single-active selection surface, the per-cockpit display resolution, and its deliberate non-participation in readiness/block gating and the stat pipeline. Implementation patterns, balance numbers, and per-relic authoring briefs live elsewhere; what's here does not change without a deliberate edit to this file.
North Star
A relic is a permanent, account-scoped cosmetic collectible. Its only job is to appear inside the local player's pilot picture-in-picture (PIP) — an authored "scene" actor staged next to the cockpit and pilot in the off-screen pocket world (PILOT_SYSTEM.md "Scene Composition"). It is a display trophy, in the OSRS lineage of mounted, earned-and-shown collectibles, not a gameplay modifier: a relic carries no GameplayEffect, no stats, and no in-world manifestation.
Relics reuse every unlock-axis and cosmetic-rig pattern the loadout ("ship") and loadout-modifier ("pilot") systems already established — replicated owner-only unlock set, authority-only grant APIs, a save-profile round-trip, a UPrimaryDataAsset + soft-class authorable actor, a terminal-driven eager-apply swap ability, and a subsystem-owned local rig spawned into the pocket world. It adds a display axis, not a foundation. Where it diverges from its siblings, it diverges deliberately — narrower replication (HUD-local, never broadcast to peers), no stat-pipeline participation, and no PIP readiness/block gating on swap — and each divergence is called out below.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Theme term | Relic — generic C++ surface; content DisplayNames carry the motif (per THEME.md) |
Definition & Authorable Actor |
| Definition | URelicDefinition : UPrimaryDataAsset (new), PrimaryAssetType "RelicDefinition" |
Definition & Authorable Actor |
| Authorable scene | ARelicVisualsActor : AVisualsRigActor (new), soft-class on the definition |
Definition & Authorable Actor |
| Persistent state | URelicComponent (new) on ACradlPlayerState: UnlockedRelicIds + single ActiveRelicId |
Persistent State & Replication |
| Save | UCradlPlayerProfile.UnlockedRelicIds + .ActiveRelicId (new); bump CRADL_SAVE_VERSION 14 → 15 |
Persistent State & Replication |
| Replication | Both fields Replicated COND_OwnerOnly — narrower than ActiveLoadoutId (no in-world rig → peers never read it) |
Persistent State & Replication |
| Grant API | URelicComponent::GrantRelic(FPrimaryAssetId) (new) — idempotent set membership |
Persistent State & Replication |
| Quest reward | EQuestRewardType::Relic (new) + FQuestReward::RelicAssetId (new); DeliverReward switch case |
Reward Pathway — Quests |
| Voucher | EVoucherKind::Relic (new) + UVoucherDefinition::RelicAssetId (new); waste-gated like Loadout |
Reward Pathway — Vouchers |
| Selection UI | URelicSlotPanel (new) BindWidget on ULoadoutSwapWidget; sentinel "no relic" row; eager-apply |
Selection UI & Swap Ability |
| Swap | URelicSwapAbility (new) (LocalPredicted), trigger Action.Trigger.RelicSwap (new), gated by Action.Modal.LoadoutSwap |
Selection UI & Swap Ability |
| Display location | IRelicDisplayProvider (new) on the cockpit rig → Cradl::Visuals::ResolveRelicAnchor (new) |
PIP Display & Cockpit Anchor |
| Swap feel | Relic swap is silent — no EnterNotReady, no SetVisualBlocked, no pilot-view eject |
PIP Display & Cockpit Anchor |
| Validators | CradlRelicDefinitionValidator (new); extend CradlVoucherDefinitionValidator (+ quest reward variant) |
Validators |
Definition & Authorable Actor
Rule. A relic is authored as URelicDefinition : UPrimaryDataAsset (new), sibling to ULoadoutDefinition and ULoadoutModifierDefinition, living under Source/CRADL/Loadout/ for proximity to its selection siblings. It overrides GetPrimaryAssetId() to return type "RelicDefinition" (mirroring ULoadoutModifierDefinition::GetPrimaryAssetId). Fields:
DisplayName(FText) andIcon(TSoftObjectPtr<UTexture2D>) — presentation, mirroring the modifier definition.VisualsActor(TSoftClassPtr<ARelicVisualsActor>) — required (a relic with no scene is meaningless; the validator flags null). The authorable scene actor, soft-loaded async and spawned local-only — exactly the roleULoadoutModifierDefinition::VisualsActorplays for the pilot rig.SkillRequirements(TArray<FSkillRequirement>) andPrerequisiteQuests(TArray<FName>) — optional selection gates, mirroring the modifier definition's axes (empty = no gate). Enforced on the swap-to-active path only and save-restore exempt (see Selection). Carried for forward parity; v1 content typically leaves both empty.
ARelicVisualsActor : AVisualsRigActor (new) is a thin subclass mirroring ALoadoutPreviewVisualsActor — a stable parent type for content BPs that compose the relic "scene" (StaticMesh, Niagara, etc.) WYSIWYG in the BP viewport. It sets bReplicates = false in its constructor; it is never attached to the gameplay pawn and never streamed from a placed level. Theme-agnostic: the class name and C++ surface stay generic; the relic motif lives in BP subclass names and content under /Game/.
Why. Relics need a soft-loadable authorable actor class plus stable presentation metadata — that is precisely what a UPrimaryDataAsset with a TSoftClassPtr field already gives loadouts and modifiers. The user's prompt fixed this shape, and grounding confirmed no cheaper container fits (a DataTable row can't carry a per-relic soft actor class the asset manager streams independently). The "RelicDefinition" PrimaryAssetType is the stable identity used on the wire for swaps and as the payload of both reward pathways.
Implementation surface.
- Files: Source/CRADL/Loadout/RelicDefinition.h (new), Source/CRADL/Loadout/RelicVisualsActor.h/.cpp (new).
- Classes: URelicDefinition (new), ARelicVisualsActor (new).
- Base: AVisualsRigActor (existing).
Footguns.
- Relics apply zero GameplayEffects. Do not give URelicDefinition a StatsEffect/InnateStatsEffect field — that is the single sharpest line between a relic and a modifier. Consequently the entire STAT_PIPELINE.md surface and the Effect.Persistent respawn allowlist (project_persistent_effect_allowlist) are N/A to relics; there is no GE to route through MakePersistentEffectSpec and nothing for the death pipeline to strip.
- ARelicVisualsActor is simple by design — no VSC reaction firehose (XP/level/combat/health), unlike AModifierVisualsActor. A reaction surface is an explicit Open Question, not an oversight.
Related. ARCHITECTURE.md decision #12 (presentation flows as structs, never raw ids to BP); THEME.md (generic C++ surface).
Persistent State & Replication
Rule. A new URelicComponent (new) on ACradlPlayerState owns relic state, sibling to ULoadoutComponent and ULoadoutModifierComponent:
UnlockedRelicIds(TArray<FPrimaryAssetId>) — the unlock set, mirroringULoadoutComponent'sUnlockedLoadouts.Replicated,COND_OwnerOnly, with anOnRep_UnlockedRelicIdsthat broadcasts anOnUnlocksChangeddelegate (UI binds it).ActiveRelicId(FPrimaryAssetId) — the single active selection (invalid id = "no relic shown").Replicated,COND_OwnerOnly, with anOnRep_ActiveRelicIdthat broadcasts anOnRelicChangeddelegate carrying the new id (the PIP subsystem and UI bind it).
Authority-only mutation APIs, mirroring the loadout/modifier components:
- GrantRelic(FPrimaryAssetId) — idempotent; appends to UnlockedRelicIds only if absent, then broadcasts. Mirrors ULoadoutComponent::GrantLoadoutId.
- IsRelicUnlocked(FPrimaryAssetId) const — read for the voucher waste gate and the UI.
- SetActiveRelic(FPrimaryAssetId, bool bCheckRequirements = true) — validates the relic is unlocked (and, when bCheckRequirements, the definition's SkillRequirements/PrerequisiteQuests via USkillsComponent::MeetsRequirements + CradlQuestGate::MeetsPrerequisites); sets ActiveRelicId and broadcasts OnRelicChanged. An invalid id is the explicit "clear / no relic" path and skips the unlock check.
- Restore + capture helpers for the save round-trip (bCheckRequirements = false on restore).
UCradlPlayerProfile (new fields): UnlockedRelicIds (TArray<FPrimaryAssetId>) and ActiveRelicId (FPrimaryAssetId), placed beside the existing UnlockedLoadoutIds/ActiveLoadoutId/UnlockedModifierInstances/ActiveModifierAssignments. Bump CRADL_SAVE_VERSION 14 → 15; additive — older saves load both empty (no relic), exactly as a fresh character does. UCradlSaveSubsystem captures both on save and restores them on load — unlock set before active selection, mirroring the loadout ordering, and alongside the modifier restore step.
Why. Loadout and pilot each got a dedicated per-axis component; a third cosmetic axis follows the same convention rather than overloading either sibling. Folding relics into ULoadoutModifierComponent would graft a GE-less, slot-less, single-active axis onto a multi-slot GE-bearing one; folding into ULoadoutComponent would couple cosmetic selection to bag/equipment reshape. A clean component keeps each axis's authority and replication answer independent.
Replication audit (mandatory, P2P).
- UnlockedRelicIds — server-authoritative (grants run on authority via the quest/voucher abilities). COND_OwnerOnly: only the owning client's selection UI and the owner-predicted redeem/swap abilities ever read it; no non-owning peer needs it. Matches UnlockedLoadouts.
- ActiveRelicId — server-authoritative + persisted, but replicated COND_OwnerOnly, which is deliberately narrower than ULoadoutComponent::ActiveLoadoutId (replicated to all because it also drives the in-world ship rig observed by peers). A relic has no in-world manifestation — it renders only in the owning player's local HUD PIP — so a remote peer can never observe it and must not receive it. This threads CLAUDE.md's "never replicate cosmetic-only state (local UI is client-side only)" correctly: the relic actor is local-only (bReplicates = false, spawned client-side); only the authoritative selection key replicates, and only to the one client that renders it (the server is the authority and persistence source, so the key cannot be pure client-local without a bespoke save-sync path).
- The COND_OwnerOnly "empty component on a non-owning peer" footgun (feedback_cond_owneronly_peer_fallback_wrong) does not bite here: relic state is read only on the authority and the owning client (swap is LocalPredicted owner-run; waste gate runs owner + authority). No gate is ever evaluated against a relic component on a non-owning peer.
Footguns.
- Restore must run with bCheckRequirements = false and never strand a saved selection — a relic granted before a requirement was added stays selectable, matching the loadout/modifier save-restore exemption.
- Guest/host-seeded players: per project_guest_progression_v1_deferred, cover both LoadPlayer outcomes — a fresh-seeded guest simply lands with empty UnlockedRelicIds/invalid ActiveRelicId (no relic), which is the correct default.
Related. Reward Pathway — Quests, Reward Pathway — Vouchers; UCradlPlayerProfile.
Reward Pathway — Quests
Rule. Add a Relic variant to the quest reward pipeline, mirroring the existing idempotent Loadout/SpellbookUnlock kinds (not the non-idempotent LoadoutModifier):
EQuestRewardType::Relic(new) on the enum inFQuestReward.FQuestReward::RelicAssetId(new) (FPrimaryAssetId), gated bymeta=(EditCondition="RewardType==EQuestRewardType::Relic", EditConditionHides)per the existing flat-discriminator convention.FQuestRewardManifestEntry::RelicAssetId(new) in Source/CRADL/Quests/QuestPersistentTypes.h, and aReliccase inQuestRewardIdentityMatchescomparingRelicAssetId(binary identity, no value delta — same as Loadout).- A
case EQuestRewardType::Relic:inUQuestComponent::DeliverRewarddispatching toURelicComponent::GrantRelic(RelicAssetId)and recording the entry.
Why. The reward manifest is sized for multiple idempotent grant kinds; Loadout already demonstrates the exact shape (one enum value, one FPrimaryAssetId field, one idempotent grant API, manifest identity = the id). Reconciliation-by-diff on launch then works automatically: a relic reward newly added to a UQuestDefinition grants on the next startup; an existing one is a no-op; a removed one leaves the player's relic alone (the manifest is a ledger of what was applied, never reclaimed).
Implementation surface.
- Files: Source/CRADL/Quests/QuestReward.h, Source/CRADL/Quests/QuestPersistentTypes.h, Source/CRADL/Quests/QuestComponent.cpp.
- Symbols: EQuestRewardType::Relic (new), FQuestReward::RelicAssetId (new), FQuestRewardManifestEntry::RelicAssetId (new), QuestRewardIdentityMatches (extend), UQuestComponent::DeliverReward (extend).
Footguns.
- Relics, like Spellbook/Loadout, have no overflow surface — they never enter the item PendingRewards ladder (SeatItemViaLadder). A missing URelicComponent is an init error (log at Warning per feedback_log_level_warning_for_diagnostics), not a deferral.
- Idempotency lives at the grant API (GrantRelic appends only if absent) and the manifest diff — do not add a second dedup layer at the UI.
Related. QUEST_SYSTEM.md "Reward Pipeline"; Persistent State & Replication.
Reward Pathway — Vouchers
Rule. Add a 4th EVoucherKind variant, mirroring the Loadout variant exactly (idempotent, waste-gated):
EVoucherKind::Relic(new) on the enum inUVoucherDefinition.UVoucherDefinition::RelicAssetId(new) (FPrimaryAssetId), gated bymeta=(EditCondition="Kind==EVoucherKind::Relic", EditConditionHides).- In
URedeemVoucherAbility::ActivateAbility: aReliccase in the waste-gate switch (reject with a player-facing toast ifURelicComponent::IsRelicUnlocked(RelicAssetId)— relics are idempotent, so already-owned is a waste case, exactly like Loadout/Spellbook) and aReliccase in the grant-dispatch switch (URelicComponent::GrantRelic(RelicAssetId)after the unconditional decrement).
No new tags or item-menu changes: a relic voucher is an ordinary voucher item, marked by the existing Item.Voucher tag ⇔ FItemRow::Voucher reference and redeemed by the existing Action.Trigger.Item.Redeem → URedeemVoucherAbility path (VOUCHER_SYSTEM.md "Context Menu").
Why. The voucher dispatch is a closed enum-variant loop that already scales to a 4th kind by literal extension (grounding confirmed no abstraction refactor is warranted while the new kind mirrors an existing one). Relic matches Loadout's contract — FPrimaryAssetId payload, idempotent grant, binary waste gate — so it slots into both switches and the validator with no structural change.
Implementation surface.
- Files: Source/CRADL/Inventory/VoucherDefinition.h, Source/CRADL/Abilities/RedeemVoucherAbility.cpp.
- Symbols: EVoucherKind::Relic (new), UVoucherDefinition::RelicAssetId (new), URedeemVoucherAbility::ActivateAbility (extend both switches).
Footguns.
- Vouchers are deliberately not unified with the quest pipeline (VOUCHER_SYSTEM.md "Why not the quest pipeline") — the two reach GrantRelic independently. Do not extract a shared relic-grant orchestrator; add the case to each.
- The Consumable/Voucher mutual-exclusion invariant in CradlItemTableValidator is unchanged — a relic voucher is a voucher, never also a consumable.
Related. VOUCHER_SYSTEM.md; Validators.
Selection UI & Swap Ability
Rule. The player picks the single active relic at the existing loadout terminal, through a new URelicSlotPanel (new) BindWidget on ULoadoutSwapWidget, mirroring the pilot panel (ULoadoutModifierSlotPanel):
URelicSlotPanelis aUUserWidgetwith aUCommonButtonGroupBase+bSuppressSelectionDispatcheager-apply list, a leading sentinel "no relic" row (invalid id → clears the selection), and rows of classURelicEntryListItem(new) fedFRelicPresentationData(new). Selecting a row dispatches immediately; there is no Apply button.FRelicPresentationDatais built C++-side with pre-resolvedDisplayName,Icon, requirement arrays, andbIsActive— the WBP never resolves anFPrimaryAssetId(ARCHITECTURE.md #12;feedback_presentation_struct_resolve_tags).ULoadoutSwapWidgetdeclaresUPROPERTY(meta=(BindWidgetOptional)) TObjectPtr<URelicSlotPanel> RelicPanel;, configures it inRefreshRelicPanel(), and binds its applied-toast delegate inNativeConstruct/ unbinds inNativeDestruct(feedback_bind_child_delegate_in_nativeconstruct— modal pages are reused). Unlike the modifier panel, the relic panel is not loadout-gated (relics are independent of the active loadout); it is shown whenever the player has ≥1 unlocked relic, collapsed otherwise.- Row selection calls
ASC->HandleGameplayEvent(Action.Trigger.RelicSwap, payload)with aURelicSwapRequest(new) carrying the terminal + relic id — mirroringULoadoutModifierSlotPanel::DispatchModifierSwapandULoadoutModifierSwapRequest. URelicSwapAbility(new) (NetExecutionPolicy = LocalPredicted,ActivationRequiredTags=Action.Modal.LoadoutSwap) mirrorsULoadoutModifierSwapAbility: authority re-validates (alive; unlocked; optional requirements), then branches — valid id →SetActiveRelic(id), invalid id →SetActiveRelic(invalid)(clear).
Why. The pilot panel is the proven template for a single-active, sentinel-led, eager-apply selection of an unlocked cosmetic, hosted in the same terminal modal. Reusing its shape (button group churn suppression, sentinel-clear, presentation-struct hand-off) keeps the relic surface consistent and avoids re-deriving the CommonUI selection mechanics. Reusing the modal gate (Action.Modal.LoadoutSwap) means relic dispatch fails cleanly outside the terminal, identical to the loadout/pilot verbs.
Implementation surface.
- Files: Source/CRADL/UI/RelicSlotPanel.h/.cpp (new), Source/CRADL/UI/RelicEntryListItem.h (new), Source/CRADL/UI/RelicPresentation.h (new), Source/CRADL/UI/LoadoutSwapWidget.h (extend), Source/CRADL/Abilities/RelicSwapAbility.h/.cpp (new).
- Symbols: URelicSlotPanel, URelicEntryListItem, FRelicPresentationData, URelicSwapAbility, URelicSwapRequest — all (new); ULoadoutSwapWidget::RelicPanel + RefreshRelicPanel() (new).
Replication audit. The swap is LocalPredicted but the SetActiveRelic mutation is authority-only (HasAuthority-gated); the result flows back via ActiveRelicId's COND_OwnerOnly OnRep. Prediction is vestigial (the only client-side payoff is the local relic respawn, which the OnRep would drive anyway) but keeps the dispatch path identical to the sibling verbs.
Footguns.
- Avoid Slot as a local/parameter name anywhere in URelicSlotPanel (C4458 shadows UWidget::Slot — feedback_slot_shadows_uwidget); use RelicSlot/SlotData.
- New panel UPROPERTYs the designer tunes (ListItemClass, list container) are EditAnywhere, not EditDefaultsOnly (feedback_umg_widget_uproperty_edit_specifier).
- If a relic row is ever a CommonUI toggle rather than a list button, bind OnIsSelectedChanged, not OnClicked (reference_commonui_toggle_bind_selectedchanged).
Related. PIP Display & Cockpit Anchor; ALoadoutTerminal.
PIP Display & Cockpit Anchor
Rule. The relic renders as a local-only actor in the pilot PIP, owned and staged by ULoadoutPreviewSubsystem as a third rig pipeline alongside the cockpit and pilot, with two deliberate divergences from those siblings: a per-cockpit attach anchor, and no readiness/block gating on swap.
Display location resolution. Each cockpit BP optionally authors where its relic sits via IRelicDisplayProvider (new) — a preview-only UInterface modeled exactly on IPreviewCameraProvider:
IRelicDisplayProvider::GetRelicAnchor() const(BlueprintNativeEvent, defaultnullptr) returns theUSceneComponentthe relic attaches to.Cradl::Visuals::ResolveRelicAnchor(AActor* Cockpit)(new) dispatches: interface override if implemented and non-null → else the firstUSceneComponenton the cockpit carrying theRelicAnchorcomponent tag (the "drop a component, tag it, done" convenience that mirrors the camera provider's "drop a Camera and it's picked up") → elsenullptr.- A
nullptranchor means the relic is not shown for that cockpit (a cockpit that authors no anchor simply displays no relic). The author is responsible for the anchor playing nice with their scene (per the prompt).
The relic actor is spawned via the existing SpawnRigIntoStaging contract (RF_Transient, visible, never replicated, at GPreviewStagingSpawnPoint), then AttachToComponent(Anchor, KeepRelativeTransform) and AddActorToAllowlist so the scene capture renders it. Its anim tick pauses with PIP visibility via the existing SetRigTicksEnabled path.
Driver key + spawn pipeline. A new pipeline mirrors the cockpit two-stage async load + supersede guard:
- Bind URelicComponent::OnRelicChanged in BindDriverKeys → HandleRelicChanged(FPrimaryAssetId) → RequestRelic (resolve URelicDefinition, async if not resident) → OnRelicClassResolved (resolve URelicDefinition::VisualsActor soft class) → SpawnRelicRig → ClearRelicRig on swap/clear.
- New members mirror the cockpit set: TWeakObjectPtr<ARelicVisualsActor> RelicRig, TSharedPtr<FStreamableHandle> RelicLoadHandle, FPrimaryAssetId ActiveRelicIdLocal (supersede + same-key short-circuit), TSoftClassPtr<ARelicVisualsActor> PendingRelicClass, FDelegateHandle RelicChangedHandle.
Cockpit dependency / re-anchor. The relic anchors against the current cockpit rig, so the relic refresh is driven from two places: a relic-key change (HandleRelicChanged), and cockpit (re)spawn — when the cockpit changes on a loadout swap, the relic must re-anchor to the new cockpit. SpawnCockpitRig completion therefore kicks a relic refresh against the freshly-spawned anchor, and HandleRelicChanged defers if no cockpit rig is present yet (the cockpit-spawn completion picks up the active relic), mirroring the subsystem's existing seed-vs-broadcast ordering.
Silent swap. HandleRelicChanged and ClearRelicRig never call EnterNotReady, MarkCockpitSettled/MarkPilotSettled, or SetVisualBlocked. A relic swap inside an already-ready scene is a quiet despawn/respawn: the old relic actor is destroyed (its dead weak ref compacts out of the allowlist on the next CaptureNow), the new one spawns and attaches, and the PIP keeps rendering with no placeholder and no pilot-view eject. The readiness/block gates remain reserved for cockpit/pilot rebuilds, which do tear down the framed subject.
Why. The subsystem already spawns two local rigs from replicated driver keys with two-stage async loads and a supersede guard; a third pipeline fits without reshaping the core (grounding found no structural refactor needed). IPreviewCameraProvider already established the precedent of a preview-specific sibling to the general in-world IVisualsAnchorProvider — so the relic socket, which lives on a preview cockpit, gets its own preview-specific interface rather than overloading the in-world anchor surface (whose driver-mesh fallback and pawn implementers do not map to an off-screen cockpit). The silent-swap divergence is the user's explicit requirement and is cheap precisely because readiness gating is opt-in per handler, not global.
Implementation surface.
- Files: Source/CRADL/Visuals/RelicDisplayProvider.h/.cpp (new), Source/CRADL/Visuals/LoadoutPreviewSubsystem.h/.cpp (extend).
- Symbols: IRelicDisplayProvider / URelicDisplayProvider (new), Cradl::Visuals::ResolveRelicAnchor (new), the relic pipeline functions + members on ULoadoutPreviewSubsystem (new), the RelicAnchor component-tag FName convention (new — an FName component tag, not a gameplay tag).
Footguns.
- Don't reach for IVisualsAnchorProvider for the relic socket — see Why. It is the in-world rig-attach surface (implemented on ACradlCharacter) with a driver-mesh fallback; reusing it would conflate two roles.
- Spawn-order trap (project_camera_bounce_respawn): the relic depends on the cockpit anchor existing. Anchoring before the cockpit settles yields a null anchor → no relic; the cockpit-spawn-completion refresh is the load-bearing fix, not an afterthought.
- Pocket-world hygiene (project_pocketworlds_vendoring): the relic actor is RF_Transient + bReplicates = false and left visible (the capture's show-only list renders allowlisted actors; SetActorHiddenInGame would hide it from the capture too).
- Do not broadcast or networked-spawn the relic actor — it is per-local-player, rebuilt from the COND_OwnerOnly key (see replication audit).
Related. PILOT_SYSTEM.md "Scene Composition", "Initialization & Swap Gating", "External Block / Feed-Offline"; IPreviewCameraProvider.
Tag Taxonomy
Per feedback_gameplay_tag_decl_minimal, the .ini (Config/DefaultGameplayTags.ini) is authoritative; only tags referenced by name in C++ get a UE_DECLARE_GAMEPLAY_TAG_EXTERN in Source/CRADL/CradlGameplayTags.h.
New (C++-declared, referenced by name):
- Action.Trigger.RelicSwap — GameplayEvent trigger for URelicSwapAbility, fired by URelicSlotPanel. Mirrors Action.Trigger.LoadoutModifierSwap.
Reused (no new tag):
- Action.Modal.LoadoutSwap — ActivationRequiredTags gate on URelicSwapAbility (the relic panel lives in the same terminal modal).
- Action.Trigger.Item.Redeem, Item.Voucher, GameplayCue.Item.Redeem — the relic voucher rides the existing voucher path unchanged.
Not a gameplay tag: the RelicAnchor cockpit attach point uses an FName component tag, resolved by Cradl::Visuals::ResolveRelicAnchor's fallback — distinct from the gameplay-tag namespace above.
Forward Code References
Validators
Per CLAUDE.md "validators shadow the runtime structs":
CradlRelicDefinitionValidator(new) under Source/CRADLEditor/Validators/, mirroringCradlLoadoutModifierDefinitionValidator:DisplayNamenon-empty;Iconset/resolves;VisualsActornon-null and resolves (required, unlike the optional loadout/modifier rig);SkillRequirementsentries resolve.- Extend
CradlVoucherDefinitionValidator: aReliccase requiringRelicAssetIdvalid andPrimaryAssetType == "RelicDefinition"(the string-literal pattern theLoadout/LoadoutModifiercases already use). Resolve any tags viaRequestGameplayTag(string), notCradlTags::X— native tag symbols don't cross-module link into CRADLEditor (reference_native_tag_no_cross_module_export). - Quest reward validation: if/where
FQuestRewardvariants are validated (a deeper quest-reward validator beyondCradlQuestDefinitionValidator's current task checks), add theReliccase (RelicAssetIdvalid +PrimaryAssetType == "RelicDefinition") in lockstep. See Open Questions #3.
Open Questions
ARelicVisualsActorreaction surface. v1 is a thin display actor with no reaction BIEs. Should it gain a minimalNotifyActivated()(an intro flourish on spawn/re-anchor), or the fullerAModifierVisualsActorreaction set (XP/level/combat/health)? Default: thin; addNotifyActivated()only if content needs an entrance.- Relic panel visibility when zero unlocked. Collapsed until ≥1 relic is unlocked (the proposed rule), or always shown with only the "no relic" sentinel for discoverability? A UX call.
- Quest reward-variant validator coverage. Does a validator currently check
FQuestRewardper-variant required fields, or onlyUQuestDefinitiontasks? If reward-variant validation doesn't exist yet, theReliccase is deferred with the rest rather than added now — confirm during phasing. - Relic selection requirements in v1.
URelicDefinitioncarries optionalSkillRequirements/PrerequisiteQuestsfor forward parity, enforced on the swap path. Whether v1 content actually uses any gate (vs. all relics being ungated-once-unlocked) is a content decision, not a code one.
Contract drafted at RELIC_SYSTEM.md. Review and tell me when aligned, or run /design-system Relic again with iteration feedback. Run /design-system --phase=derive-implementation Relic to derive the phased implementation doc from it.