0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI QUEST_SYSTEM
UTC 00:00:00
◀ RETURN
QUEST_SYSTEM.md 12157 words ~55 min read Updated 2026-07-03

CRADL Quest System

Companion to ARCHITECTURE.md (the foundation), COMBAT_SYSTEM.md, PROGRESSION_RECIPES.md, DIALOGUE_SYSTEM.md (the in-world accept/turn-in interaction surface), and the existing skill/inventory/spellbook contracts. This document is the contract the Quest subsystem must satisfy — its data shape, identity model, lifecycle, task taxonomy, reward delivery, reconciliation guarantees, and replication/persistence story. Implementation patterns, per-quest authoring, and phase ordering live in QUEST_IMPLEMENTATION.md; what's here does not change without a deliberate edit to this file.

Unreal ships no canonical quest framework — UGameplayTasks and ASC GenericGameplayEventCallbacks are the closest Epic-blessed primitives, but they are event plumbing, not a quest system. This contract picks patterns from established CRADL precedents (registry + UPrimaryDataAsset + validator, snapshot save/load, ASC event multicast, IInteractable dispatch) and falls back to OSRS shape where the engineering decision is otherwise arbitrary.

North Star

Quests are OSRS-derived: a static, authored catalogue of monotonic task chains that the player progresses through linearly. No branching, no failure states, no per-step backtracking in v1. A quest is eligible when its requirements (skill levels, completed prerequisites) are met; active when started; completed when its final task is fulfilled. Rewards are granted exactly once on completion, recorded as a manifest, and reconciled-by-diff on every load so authoring changes during development reach players who already finished the quest.

Three rules carry the weight:

  1. Quests reuse foundation; they do not build it. Task progress rides on existing ASC GenericGameplayEventCallbacks (the same multicast that powers cosmetic feedback). Rewards route through the same grant APIs gathering/crafting already use (USkillsComponent::GrantXP, UInventoryComponent::AddItems, USpellbookComponent::UnlockBook). Per-player state lives on ACradlPlayerState with the same COND_OwnerOnly + snapshot pattern as skills/inventory/spellbook. The Quest system adds verbs and bookkeeping, not a new authority model.
  2. Identity is content-asset-grain, not tag-grain. A UQuestDefinition is a UPrimaryDataAsset; identity is the asset FName (matches USpellDefinition / UEnemyDefinition). Tag taxonomy under Quest.* is reserved for hierarchical categorization (Main / Side / Daily), not per-quest leaves. New quests cost an asset; they do not cost a tag.
  3. Rewards are a manifest, not a fire-and-forget grant. On completion, the server records a FQuestRewardManifest of what was applied. On load, the manifest is diffed against the current definition's Rewards[] and the delta is granted. This is how a quest's rewards can change in development and still reach players who finished the quest under the old definition. Item grants are conservative (additive, never removed); spell-book unlocks are idempotent; skill XP is delta-only (additive grant of the difference). Removing a reward from the definition does not reclaim it from the player.

Quick Reference

Topic Answer Section
Definition asset UQuestDefinition : UPrimaryDataAsset — FName identity, matches USpellDefinition pattern Quest Definition
Registry UQuestRegistry : UGameInstanceSubsystem, lazy-built per ARCH #14 Quest Registry
Per-player state UQuestComponent on ACradlPlayerState, COND_OwnerOnly per ARCH #1 Per-Player State
Lifecycle Locked → Unlocked → Active → Completed (one-way, no abandon/fail in v1) Lifecycle
Tasks Monotonic chain; one FQuestTask advances at a time; no branching Task Chain
Task types Interact, TurnIn, Gather, Craft, Eliminate — five v1 variants Task Type Surfaces
Progress events ASC GenericGameplayEventCallbacksEvent.Skill.Succeeded, new Combat.Event.Kill, new Event.Interact (generic), new Action.Trigger.Quest.TurnIn Task Type Surfaces
Kill task ↔ Slayer Both consume new Combat.Event.Kill (attacker-side, attribution-resolved) + Enemy.Family.* via ICombatStatsProvider::GetClassificationTags Eliminate Task
Rewards — XP USkillsComponent::GrantXP(SkillTag, Amount) — server-only entry, replicates COND_OwnerOnly Reward Pipeline
Rewards — Items UInventoryComponent::AddItems(ItemId, Count) — server-only, returns leftover; overflow → bank → pending+toast Reward Pipeline
Rewards — Spells USpellbookComponent::UnlockBook(BookTag) — server-only, idempotent Reward Pipeline
Rewards — Loadout ULoadoutComponent::GrantLoadoutId(FPrimaryAssetId) — server-only, idempotent (the "ship" axis per THEME.md) Reward Pipeline
Rewards — Modifier ULoadoutModifierComponent::GrantInstance(Def) → returns FGuid InstanceId — server-only, not idempotent (each call mints a new instance; manifest carries the granted FGuid) Reward Pipeline
Reconciliation FQuestRewardManifest persisted per completed quest; diffed once per game startup (latched on UCradlSaveSubsystem, a UGameInstanceSubsystem), delta granted Reconciliation
Pending rewards Overflow-deferred grants persist as TArray<FPendingQuestReward> on profile; drained on next startup reconciliation pass Reward Pipeline, Persistence
Requirements FQuestRequirements { Skills, PrerequisiteQuests, RequiredItems, RequiredEquippedTags } — reserves struct slots for future gate types Requirements
Interactable identity IInteractable::GetInteractableTag() (new virtual, default invalid) — every interactable opts in by returning a stable Interactable.* tag; quest tasks key off this single surface Interact Task, Quest Giver Actor
Quest givers AQuestGiverActor : AActor, IInteractable, IQuestGiver — new actor; identity via IInteractable::GetInteractableTag() (runtime task matching); quest enumeration via IQuestGiver::GetOfferedQuests(Player, Out) (UI surface — "what quests does this entity have for me right now") Quest Giver Actor
Turn-in surface Action.Trigger.Quest.TurnIn (new) — fired by AQuestGiverActor when a TurnIn task is active and player interacts (distinct from the generic Event.Interact channel); also dispatched from a dialogue TurnIn node per DIALOGUE_SYSTEM.md Quest Giver Actor
Accept / start (in-world) Action.Trigger.Quest.Accept (new) → UAcceptQuestAbility — the player-facing start path. Dispatched by the dialogue widget; server re-validates eligibility, then UnlockQuestStartQuest. Governed by DIALOGUE_SYSTEM.md Lifecycle
Authority Server mutates state; client RPC forwards (mirrors USkillsComponent::GrantXP) Authority & Replication
Persistence Extends UCradlPlayerProfile (bump CRADL_SAVE_VERSION); applied in UCradlSaveSubsystem::LoadPlayer Persistence
Validator New UCradlQuestDefinitionValidator mirroring CradlSpellDefinitionValidator Validator
Tag namespaces Quest.* (categorization), Combat.Event.Kill (new), Action.Trigger.Quest.* (new), Interactable.* (new — generic interactable identity), Event.Interact (new), Event.Quest.* (new) Tag Taxonomy
Open questions Abandon/replay/repeatable quests, read-only quest journal UI, world markers, active-quest definition-drift policy Open Questions

Quest Definition

Rule: A quest is authored as a single UQuestDefinition : UPrimaryDataAsset instance. Identity is the asset's FName, resolved via FPrimaryAssetId("QuestDefinition", GetFName()) — the same pattern USpellDefinition and UEnemyDefinition use. The definition is immutable at runtime; it is read by the registry once and referenced by player state as FName QuestId.

Why: Per the registry-pattern grounding, four existing systems use FName/PrimaryAssetId identity (Spell, Enemy, DropTable, Item-via-DataTable) and three use FGameplayTag (Skill, Spellbook, Recipe). FGameplayTag earns its keep when the identity is small, stable, hierarchically referenced from C++, and unlikely to grow unbounded (skill leaves are a closed set). Quests do not fit that shape: they grow with content, no C++ code references individual quest leaves, and every new quest would otherwise cost a .ini edit plus a tag-uniqueness validator entry. FName matches the cost profile.

Implementation surface: - File: Source/CRADL/Quests/QuestDefinition.{h,cpp} (new). - Class: UQuestDefinition : UPrimaryDataAsset. Overrides GetPrimaryAssetId() to return FPrimaryAssetId(TEXT("QuestDefinition"), GetFName()). - Fields (the contract; type names sketch the shape, not the verbatim signatures): - FText DisplayName, FText Description — UI strings (per CLAUDE.md, present-tense American English). - TSoftObjectPtr<UTexture2D> Icon — quest-log icon. - FGameplayTagContainer CategoryTagscategorization only, under Quest.* (Quest.MainStory, Quest.Side, etc.). Not identity. - FQuestRequirements Requirements — eligibility gate (see Requirements). - TArray<FQuestTask> Tasks — ordered chain, executed monotonically. - TArray<FQuestReward> Rewards — granted exactly once on completion (manifest recorded; see Reconciliation). - +PrimaryAssetTypesToScan entry in DefaultGame.ini — directory + base class. Mirrors existing entries for spell/enemy/recipe.

Footguns: - Don't reach for FGameplayTag identity for quests. Per the fork rationale: tag taxonomy is for hierarchical categorization (Quest.MainStory.*), and identity is the asset FName. Mixing the two — declaring per-quest Quest.MyQuestName leaves and using them as keys — pays the tag-bookkeeping tax without buying anything FName doesn't already provide. - The CDO is shared. Treat the definition as read-only at runtime; do not mutate any UQuestDefinition* instance from gameplay code (mirrors the UEnemyDefinition rule in ENEMY_SYSTEM.md "Pawn Shape").

Related: Source/CRADL/Skills/SkillDefinition.h:24, Source/CRADL/Combat/SpellDefinition.h:31, Source/CRADL/Enemy/EnemyDefinition.h:157.


Quest Registry

Rule: UQuestRegistry : UGameInstanceSubsystem indexes every UQuestDefinition by FName. Lazy-built on first use via EnsureBuilt() per ARCH #14. Provides GetDefinition(FName), IsKnownQuest(FName), and an enumerator for editor/UI surfaces.

Why: Every existing registry in CRADL follows this shape — USkillRegistry, USpellRegistry, USpellbookRegistry, UEnemyRegistry, UCradlRecipeRegistry, UDropTableRegistry. The lazy-build is non-negotiable: cold-start PIE races UAssetManager's scan against UGameInstanceSubsystem::Initialize, which silently returns empty registries on the first launch and breaks reconciliation on every load thereafter until the asset registry catches up.

Implementation surface: - File: Source/CRADL/Quests/QuestRegistry.{h,cpp} (new). - Class: UQuestRegistry : UGameInstanceSubsystem with mutable bool bBuilt = false latch + EnsureBuilt() per Source/CRADL/Combat/SpellRegistry.cpp:20-42. - Indexing: TMap<FName, TObjectPtr<UQuestDefinition>> keyed by asset FName. - Type-name constant: constexpr const TCHAR* QuestPrimaryAssetTypeName = TEXT("QuestDefinition"); in the .cpp.

Footguns: - Don't eager-load in Initialize(). The PIE-race footgun is well-documented (ARCH #14). Mirror USpellRegistry::EnsureBuilt verbatim — bBuilt is set before the scan call to guard reentrancy. - IsKnownQuest is the reconciliation gate. Saved player state may reference a quest FName whose asset was deleted in dev. The registry's IsKnownQuest(FName) is what UQuestComponent::ApplyPersistedState checks before reapplying state — mirrors USpellbookComponent::ApplyPersistedState's registry filter (Source/CRADL/Combat/SpellbookComponent.cpp:154-237). Orphaned quests drop silently (with a warning log).

Related: ARCH #14, Source/CRADL/Combat/SpellRegistry.h:21, Source/CRADL/Enemy/EnemyRegistry.h:28.


Per-Player State

Rule: UQuestComponent : UActorComponent lives on ACradlPlayerState (not PlayerController, not GameInstance). It owns three replicated containers: - TArray<FName> UnlockedQuests — eligible-but-not-started. - TArray<FActiveQuest> ActiveQuests — at most one quest can be "current" by UI convention, but the data model is a set (supports parallel quests). - TArray<FCompletedQuest> CompletedQuests — terminal records, each carrying a FQuestRewardManifest (see Reconciliation).

All three replicate COND_OwnerOnly per ARCH #1.

Why: This mirrors the canonical CRADL per-player progression shape (USkillsComponent, USpellbookComponent, ULoadoutComponent). PlayerState survives pawn possession; COND_OwnerOnly keeps quest state private to its owner; the three-bucket split mirrors OSRS's quest-log states (Not Started / In Progress / Completed).

Implementation surface: - File: Source/CRADL/Quests/QuestComponent.{h,cpp} (new). - Class: UQuestComponent : UActorComponent. Constructor sets SetIsReplicatedByDefault(true). Owned by ACradlPlayerState (new TObjectPtr<UQuestComponent> field on the player state, instantiated in its constructor alongside skills/inventory/spellbook). - Replicated fields, each with ReplicatedUsing=OnRep_*: - UnlockedQuestsOnRep_UnlockedQuests broadcasts OnQuestUnlocked delegate per added entry. - ActiveQuestsOnRep_ActiveQuests broadcasts OnQuestProgressChanged for added/changed entries. - CompletedQuestsOnRep_CompletedQuests broadcasts OnQuestCompleted per added entry. - Authority entry points (server-only mutation; client calls forward via Server_* RPCs, mirroring USkillsComponent::GrantXP): - UnlockQuest(FName QuestId) — adds to UnlockedQuests if eligible. - StartQuest(FName QuestId) — promotes from UnlockedQuests to ActiveQuests. - AdvanceTask(FName QuestId, int32 TaskIndex, int32 Delta) — increments progress on the active task (internal — fired by task-type subscribers, not by direct API). - CompleteQuest(FName QuestId) — fires reward pipeline, moves entry to CompletedQuests with manifest. - Persistence: read-only getters + ApplyPersistedState(...) — mirrors USpellbookComponent (Source/CRADL/Combat/SpellbookComponent.h:158-160). (Implemented Phase 1: the component exposes GetUnlockedQuests / GetActiveQuests / GetCompletedQuests / GetPendingRewards and an ApplyPersistedState taking the four runtime arrays; UCradlSaveSubsystem owns the snapshot↔runtime conversion, so the component never includes the savegame header. There is no CaptureSnapshot() method and no FQuestComponentSnapshot bundle struct — the earlier sketch of those is superseded.) - Task-event subscriptions: registered in BeginPlay against the owning ASC's GenericGameplayEventCallbacks — see Task Type Surfaces.

P2P Replication Audit (per feedback_p2p_replication_audit):

Field Replication Rationale
UnlockedQuests COND_OwnerOnly + RepNotify Only the owner needs to see their own unlocks (private progression).
ActiveQuests COND_OwnerOnly + RepNotify Private; spectators do not need to see another player's quest progress in v1.
CompletedQuests (incl. manifest) COND_OwnerOnly + RepNotify Private; the manifest is large-ish and irrelevant to anyone but the owner.
Public "currently on quest" cosmetic (deferred — see Open Questions) If spectator UI later needs to show "Player X is on Cook's Assistant", add a small FActivityDescriptor-style field to PlayerState per ARCH #1, not a replication-condition change on the above.

Footguns: - Three replicated TArrays read together. Per feedback_p2p_replication_audit, paired replicated fields without paired rep notifies can diverge on arrival order. Resolution: a consumer that needs all three (the quest log UI) reads them only inside a single OnRep_* handler that flushes deferred work, or accepts that the bind is eventually consistent. Default: UI reads through getters that defensively skip unknown FNames. - Don't reseed UnlockedQuests from "did this player qualify" on every tick. Unlock is a one-time event; the entry persists once added. Re-running eligibility on each tick can re-trigger OnQuestUnlocked delegates and double-fire UI toasts. Mirrors feedback_no_speculative_defense_in_depth. - Listen-server has no OnRep for the owning player. Authority broadcasts the delegate directly after mutation (same pattern as USkillsComponent::ApplyXPGrant at Source/CRADL/Skills/SkillsComponent.cpp:75-116).

Related: ARCH #1, Source/CRADL/Skills/SkillsComponent.h:39, Source/CRADL/Combat/SpellbookComponent.h:55.


Lifecycle

Rule: A quest moves through exactly four states, in one direction:

Locked  →  Unlocked  →  Active  →  Completed
  • Locked — default. UQuestComponent holds no record. Requirements not yet checked or not met.
  • Unlocked — eligibility met; the player can start. Listed in quest log under "Available". Recorded in UnlockedQuests.
  • Active — player started; first task is the current target. Recorded in ActiveQuests with CurrentTaskIndex + per-task progress counters.
  • Completed — final task fulfilled; rewards granted; manifest recorded. Moved to CompletedQuests. Reconciliation can re-fire reward delivery if the definition's Rewards[] changes (see Reconciliation).

There is no Abandon, no Failed, no Reset in v1.

Unlock triggers (any one of): 1. Manual server call — debug cheats, scripted story beats (UQuestComponent::UnlockQuest(QuestId) on authority). 2. Prerequisite-completion sweep — every CompleteQuest call runs a sweep over the registry: any locked quest whose Requirements.PrerequisiteQuests is now satisfied gets unlocked. Cheap because requirements are a small list and prerequisite-keyed indexing can be lazy. 3. Skill-level threshold sweep — when USkillsComponent::OnLevelUp fires (the existing delegate at Source/CRADL/Skills/SkillsComponent.h), UQuestComponent runs the same eligibility sweep over locked quests whose Requirements.Skills is now satisfied. This is the OSRS analog of "unlock the quest when you hit Cooking 10." 4. Initial post-load sweepUQuestComponent::RunInitialUnlockSweep, invoked by UCradlSaveSubsystem::LoadPlayer at the tail of both load outcomes (the real-load path and the seed-defaults path). It runs the same eligibility sweep once, after state is restored, so a quest whose Requirements are already satisfied at login surfaces under "Available" immediately — without the player having to cross a level threshold or finish a prerequisite first. Without this, triggers #2/#3 are purely edge-triggered: a fresh character with a met-from-the-start quest would never see it until some unrelated level-up re-ran the sweep. Runs on authority for every player. Multiplayer note: the host loads only its own local player's profile; a remote guest is currently passed PLATFORMUSERID_NONE and seeded fresh on the host (LoadPlayer's defaults path), so a guest's initial sweep evaluates the new-character set. Carrying a guest's real progression into a host session is in scope for V1 but deferred; when that transport lands it will flow through the same load seam this sweep already tails, so no rework is required here.

Start trigger (the player-facing path): the unlock states above make a quest available; the player commits to it by accepting it in dialogue. Talking to a quest giver opens the dialogue modal (DIALOGUE_SYSTEM.md); an Offer-role node's response dispatches Action.Trigger.Quest.Accept, activating UAcceptQuestAbility, which server-side re-validates Requirements.IsSatisfiedBy and then calls UnlockQuestStartQuest. This is the canonical in-world entry point; the four sweeps above are eligibility/auto-unlock, not the commit. (Pre-dialogue, the only start path was the debug cheats — that gap is what DIALOGUE_SYSTEM.md closes.)

Footguns: - Unlock is not idempotent at the UI layer. Adding the same FName to UnlockedQuests twice fires OnQuestUnlocked twice and would double the "new quest!" toast. The component dedupes at the write site, not the read site. - Sweep is O(N) over the catalogue. Acceptable for v1 (quest catalogue is small). If the catalogue grows past ~200 quests this becomes a tick cost on every level-up; the resolution is an index keyed by prerequisite/skill, not on-tick. - No quest can re-enter Locked. The data model does not support it. Removing a quest from UnlockedQuests without promoting to Active is a bug — the component asserts on this in debug builds.


Task Chain

Rule: A quest holds an ordered TArray<FQuestTask> Tasks. Exactly one task is current (CurrentTaskIndex on the active record). The task is monotonic — progress on its counter only ever increases, and on hitting the task's target count the chain advances. There is no fail state, no rollback, no in-flight task swap.

FQuestTask shape (the contract; field names sketch intent): - FGameplayTag TaskType — discriminator under Quest.TaskType.* taxonomy (Interact, TurnIn, Gather, Craft, Eliminate). - int32 TargetCount — how many times the task must fire before advancing. - FText Description — UI string. - Type-specific identity field (one of, by TaskType): - InteractFGameplayTag InteractableTag (single field, under Interactable.* — any tagged IInteractable matches; quest givers, world objects, and opted-in terminals all live on the same surface) + optional FName DialogueKey (when the host is a dialogue giver, the stable key a dialogue Advance option dispatches to advance this step — disambiguates multiple talk-to steps at one giver; see Interact Task). DialogueKey is an FName, not a tag: it's per-step content identity like ItemId/RecipeId, not a taxonomy, so it stays out of the gameplay-tag registry. - TurnInFGameplayTag InteractableTag + FName ItemId + int32 ItemCount (items consumed on turn-in; same identity surface as Interact — the host is just an IInteractable that publishes a TurnIn ContextAction). - GatherFName ItemId (the rolled drop; not the node — see Gather Task). - CraftFName RecipeId (URecipeDefinition asset FName). - EliminateFGameplayTag EnemyFamilyTag (an Enemy.Family.* leaf; the kill filter).

The contract is the discriminator + content-asset identity, not a particular C++ shape. The implementation models this as a single FQuestTask struct with a FGameplayTag TaskType discriminator and a union-by-convention set of optional fields, and a strict editor validator enforcing field/type fit. (A polymorphic FInstancedStruct was considered for its per-type authoring dropdown but rejected: FInstancedStruct/StructUtils is used nowhere else in CRADL — every existing definition struct is flat-with-optional-fields — and the reward sibling must persist in the save-game manifest and be diffed field-by-field on reconciliation, which is trivial with a flat struct and heavier with an instanced one.)

Why monotonic-only: OSRS quests overwhelmingly follow this shape (collect 5 fish; talk to NPC; advance). Branching, failure, and rollback introduce state-machine complexity that the v1 surface does not need and that would cost validator complexity, save-game versioning, and UI affordances. Designs that genuinely need branching (the OSRS minority — "Recipe for Disaster" subquests, etc.) can be modeled as separate quests with prerequisite chains.

Footguns: - Don't let progress underflow. Task counters are server-mutated and replicated; a delta arriving from a stale RPC could write a negative count. Clamp at zero on every write site. - Advancing past the last task triggers completion. The state transition (ActiveCompleted) is owned by UQuestComponent::AdvanceTask, not by the task-event subscribers. Subscribers only know "I observed an event; here's a delta" — they do not promote the quest. This keeps the lifecycle machine in one place. - AdvanceTask only advances the current task (implemented Phase 1). It rejects any call where TaskIndex != CurrentTaskIndex (a stale/out-of-order event must not mutate a non-current counter). Phase 5 subscribers must therefore read the active record's CurrentTaskIndex and pass that — not an arbitrary index. (The method is conceptually internal but is public so the Phase-1 Debug_AdvanceTask cheat can drive it; it still routes through Server_AdvanceTask off-authority.)


Task Type Surfaces

Every task type subscribes to an existing or new ASC GenericGameplayEventCallbacks channel on the owning player's UCradlAbilitySystemComponent. The subscriber filters the event payload against the active task's identity, increments the counter on match, and (when the counter reaches TargetCount) calls UQuestComponent::AdvanceTask. All mutation is server-side; events from FireReplicatedGameplayEvent already fire on authority by contract.

Interact Task

Rule: A task with TaskType = Quest.TaskType.Interact advances when the player completes a primary interact (the default ContextAction, i.e. the action with bDefault = true) against any IInteractable whose GetInteractableTag() matches the task's InteractableTag. Subscribes to a single ASC event channel: Event.Interact. Identity is a uniform surface — every IInteractable matches through the same GetInteractableTag(), NPC or world-object alike. The progression trigger, however, splits by interactable kind, and both halves are deliberate first-class authoring channels: - A non-dialogue interactable (a lever, a search point, an examined statue — something interacting with does not open a modal) advances its Interact task on the bare Event.Interact. Interacting is the whole interaction. - A dialogue giver (interacting opens a conversation) advances its "talk to" step from inside the tree via Action.Trigger.Quest.Advance (see DIALOGUE_SYSTEM.md), never on the bare Event.Interact. You can't bare-interact-advance a giver, because its one interact outcome is "open dialogue."

Dialogue-advanced steps carry a DialogueKey. Every "talk to" step at one giver shares that giver's InteractableTag, so the coarse "you're mid-talk-to here" signal can't distinguish a quest's first talk-to step from its third — both would surface the same dialogue option, and a multi-beat conversation can't progress. Each dialogue-advanced Interact task therefore carries an FName DialogueKey: the dialogue Advance option carries the same key and dispatches it, and the step advances only when the active quest's current task's DialogueKey equals the dispatched key. Give every dialogue-advanced talk-to step a key (even a single-beat one, e.g. talk) so a keyed response can target it — there is no "match any talk-to" mode; matching is always exact. A quest with two or more talk-to steps at one giver must give each a distinct key, or the conversation can't tell the beats apart (validator-warned). (A non-dialogue Interact task — a lever, a search point — needs no DialogueKey; it advances on the bare Event.Interact.) FName, not a tag — per-step content identity, not a taxonomy, so it never touches the tag registry.

Why one channel, one identity: Per feedback_interface_rule_reading, the right cross-system surface is the interface that already crosses systems — IInteractable, not a new bespoke marker. Bifurcating identity into QuestGiverTag (for NPCs) and InteractableTag (for world objects) would force every later consumer (dialog system, quest log "who needs talking to" filter, world markers) to redo the same per-axis routing the quest component would. One identity field on FQuestTask, one event channel on the ASC, one virtual method on the interface — the rest is opt-in by override.

Why the channel publishes from the dispatch site: UInteractionComponent::DispatchContextAction (Source/CRADL/Interaction/InteractionComponent.cpp) is the single funnel every primary interact already flows through (right-click → default action, primary-key press → default action). Firing Event.Interact from there means we never depend on each IInteractable implementer to remember to fire it — the bridge sits on the boundary, not on the implementers.

Implementation surface: - New virtual method on the existing interface: virtual FGameplayTag IInteractable::GetInteractableTag() const { return FGameplayTag(); } in Source/CRADL/Interaction/InteractableInterface.h. Default invalid; implementers override to return a stable Interactable.* tag when they want to be quest-trackable. - New ASC event tag: Event.Interact (see Tag Taxonomy). - Fire site: UInteractionComponent::DispatchContextAction (Source/CRADL/Interaction/InteractionComponent.cpp) — after the action's own Payload.EventTag event fires, if Action.bDefault == true and the source actor's IInteractable::GetInteractableTag() returns a valid tag, fire a second FGameplayEventData on the player's ASC with EventTag = Event.Interact, Target = source actor, InstigatorTags = { interactable tag }. - Subscribe at BeginPlay: ASC->GenericGameplayEventCallbacks.FindOrAdd(Event.Interact).AddUObject(this, &UQuestComponent::HandleInteractEvent). - On event: read the interactable tag from EventData.InstigatorTags; match against the active Interact task's InteractableTag (hierarchical — Interactable.NPC.CooksAssistant matches a task tagged Interactable.NPC). Skip the task if it carries a DialogueKey — a keyed Interact task is a dialogue talk-to step that advances only via Action.Trigger.Quest.Advance (UAdvanceQuestTaskAbility), never on the bare Event.Interact that opening the conversation fires. Only un-keyed Interact tasks (non-dialogue world objects) ride this channel. Increment counter; advance on threshold.

Footguns: - Event.Interact only fires for the primary action. Non-default ContextActions (Turn In, Examine, Prospect) have their own ActionTag channels and do not double-fire as an Interact event. A quest that wants "the player examined the well" should model that as a distinct task type (or extend the interact channel with an action-tag filter — Open Question). - Existing interactables default to invalid. AStoreTerminal, ABankTerminal, ALoadoutTerminal, AGatheringNode, ACraftingStation do not override GetInteractableTag() until their author opts in by adding a UPROPERTY FGameplayTag InteractableTag and returning it. v1 ships AQuestGiverActor as the canonical opt-in; any other interactable becomes quest-trackable with one override + one new tag in Config/DefaultGameplayTags.ini. This resolves the previous "extending identity to terminals is an Open Question" — the surface is now uniform; whether a terminal opts in is per-instance authoring, not contract churn. - Action.Trigger.Quest.TalkTo is removed — never a subscription channel. The channel is Event.Interact. TalkTo was a v1 leftover, deleted as a root concept (per DIALOGUE_SYSTEM.md). - Quest givers are dialogue actors — don't author Interact tasks against their tags. A quest giver's bDefault action opens the dialogue modal (Action.Trigger.Modal.Dialogue), and the dispatch funnel still fires Event.Interact orthogonally on that bare interact. But quest-giver "talk to" progression is dialogue-exclusive — it rides a dialogue Advance option (Action.Trigger.Quest.Advance, keyed by the task's DialogueKey), not the bare Event.Interact (see DIALOGUE_SYSTEM.md "Advance Quest Task Ability"). Authoring such an Interact task still advances only through dialogue; the bare interact just opens the conversation. Quest.TaskType.Interact advanced via bare Event.Interact is for non-dialogue world objects (a lever, a search point, an examined statue): things you interact with where there is no conversation and so no DialogueKey. NPC talk-to steps are dialogue-advanced and keyed. - Hierarchical matching is intentional. Interactable.NPC.Goblin.Smith matches a task on Interactable.NPC.Goblin. Designers wanting strict-leaf must author the leaf as the task tag (same rule as Enemy.Family.* in Eliminate).

TurnIn Task

Rule: A task with TaskType = Quest.TaskType.TurnIn advances when the player interacts with the matching IInteractable host (whose GetInteractableTag() matches the task's InteractableTag), the player's inventory contains ItemId × ItemCount, and the host's offered Action.Trigger.Quest.TurnIn ContextAction is invoked. The action consumes the items server-side and increments the counter (typically by ItemCount, but the data model supports partial turn-ins — TargetCount is the total accumulated; one TurnIn invocation contributes ItemCount).

Implementation surface: - New Action.Trigger.Quest.TurnIn tag (see Tag Taxonomy). This is a non-default ContextAction and is not observed via Event.Interact — it has its own action-tag channel because turn-in is an explicit, distinct verb (the player picks "Turn In" in the menu rather than "Talk To"), and the consumption ability is bound to this specific tag. - AQuestGiverActor::GatherActions (the IInteractable method per Source/CRADL/Interaction/InteractableInterface.h) inspects the local player's active TurnIn task; if its InteractableTag matches this actor's GetInteractableTag() and inventory has the items, appends a FContextAction with ActionTag = Action.Trigger.Quest.TurnIn. It surfaces a single TurnIn entry (the first satisfiable turn-in task) — see the re-derivation note below. - New UTurnInQuestItemAbility (LocalPredicted) triggered by the event. Mutation is server-authoritative (guarded by ActorInfo->IsNetAuthority()): the client predicts activation but does not mutate, because UQuestComponent::AdvanceTask self-routes through a Server_ RPC and a predicted client call would double-advance. Server-side it re-derives the target task from EventData.Target's IInteractable::GetInteractableTag() + the player's active TurnIn tasks + inventory (rather than trusting a carried payload — IInteractable::GetActionPayload is keyed on ActionTag alone and has no pawn, so a per-task payload can't flow through the standard context dispatch). It then consumes items via UInventoryComponent::RemoveItems and calls UQuestComponent::AdvanceTask. This re-derivation is the server re-validation.

Footguns: - Item consumption is authoritative. Re-derive + re-check on the server (the client may have predicted use of the item via consume or drop between context-menu open and action dispatch). Mirror the UTransactItemAbility server-side-validation-before-mutation pattern. - Partial turn-ins are allowed. TargetCount = 10, ItemCount = 5 means two TurnIn invocations complete the task. Don't gate the action on Inventory.HasCount >= TargetCount; gate on Inventory.HasCount >= ItemCount. - Single entry, first-match (v1 limitation). Because the dispatch can't carry a per-task identity, two distinct active TurnIn tasks at the same giver both advance the first-matched one. The common one-turn-in-per-giver case is fully correct; the multi-quest-same-giver edge is acceptable for v1.

Gather Task

Rule: A task with TaskType = Quest.TaskType.Gather advances on every Event.Skill.Succeeded event where InstigatorTag == Action.Gathering and the rolled drop's ItemId matches the task's ItemId. Subscribes to the existing Event.Skill.Succeeded ASC event (Source/CRADL/CradlGameplayTags.h:173).

Implementation surface: - The existing gather event payload (fired from UGatherAbility::HandleTickFinished at Source/CRADL/Abilities/GatherAbility.cpp) does not carry the rolled item's ItemId — only the source AGatheringNode. Phase 4 work (done): extended the gather success fire-site to attach a UGatheredItemPayload : UObject (typed descriptor in the leaf header Source/CRADL/Abilities/GatherPayload.h; mirrors UTransactRequest) carrying {FName ItemId; int32 Count} as EventData.OptionalObject. The existing 3-arg FireReplicatedGameplayEvent is replaced with the typed-payload overload FireReplicatedGameplayEvent(EventTag, const FGameplayTagContainer& InstigatorTags, AActor* Target, const UObject* OptionalObject) — a single overload shared by gather, craft, and the Eliminate kill event (it takes a tag container rather than a single tag so the kill can carry the victim's full classification set; gather/craft pass a one-tag container). The transient payload resolves on the authority's local multicast execution (where the server-side subscriber reads it) and is null on remote peers, which only consume the cosmetic action tag. - Subscribe at BeginPlay: ASC->GenericGameplayEventCallbacks.FindOrAdd(Event.Skill.Succeeded). Filter by EventData.InstigatorTags.HasTag(Action.Gathering); cast EventData.OptionalObject to UGatheredItemPayload; match ItemId.

Footguns: - Reverse-resolving from the node is wrong. A node has a drop table with multiple weighted items; subscribing to "I gathered from this node" and incrementing the counter would overcount any tick that rolled a different item. The event payload must carry the specific drop, not the node. - Multiple gatherers on the same node fire independently. The event fires per-player, on the player's own ASC; cross-player double-counting is not possible.

Craft Task

Rule: A task with TaskType = Quest.TaskType.Craft advances on every Event.Skill.Succeeded where InstigatorTag == Action.Crafting and the executed recipe's FName matches the task's RecipeId. Subscribes to the existing Event.Skill.Succeeded ASC event.

Implementation surface: - The existing craft event payload (fired from UCraftAbility::HandleDurationFinished at Source/CRADL/Abilities/CraftAbility.cpp) does not carry the recipe identity — only the source ACraftingStation. Phase 4 work (done): extended the craft success fire-site to attach the executed Recipe (a const URecipeDefinition*; already a UObject, no new wrapper) as EventData.OptionalObject, via the same typed-payload overload the gather site uses. - Subscribe at BeginPlay, filter by EventData.InstigatorTags.HasTag(Action.Crafting), cast EventData.OptionalObject to URecipeDefinition*, match GetFName().

Footguns: - Don't filter by StationTag alone. Multiple recipes can share a station (every anvil recipe). Filtering by station overcounts. - Failed craft rolls fire Event.Skill.Failed, not Event.Skill.Succeeded. Quest tasks subscribe only to the success channel by design. If a future quest needs "fail X recipe Y times," that's an Open Question.

Eliminate Task

Rule: A task with TaskType = Quest.TaskType.Eliminate advances on every new Combat.Event.Kill event fired on the attacker player's ASC, where the victim's ICombatStatsProvider::GetClassificationTags contains the task's EnemyFamilyTag. Subscribes to the new Combat.Event.Kill ASC event (Phase 0 work).

Why a new event: The existing Combat.Event.Death event (Source/CRADL/CradlGameplayTags.h) fires on the victim's ASC. Quest progress lives on the attacker — and only one attacker should get credit (the kill-attribution winner). The attribution surface already exists in UEnemyDropComponent::ResolveAttribution (Source/CRADL/Enemy/EnemyDropComponent.h:38-82). The cleanest pattern is for UEnemyDeathAbility (Source/CRADL/Enemy/EnemyDeathAbility.cpp:95, the existing canonical post-attribution site) to fire one new Combat.Event.Kill event on the attribution-winner's ASC, with the victim actor as EventData.Target. Quest tasks subscribe; the future Slayer skill subscribes; the surface is shared from day one as per the design prompt.

Implementation surface: (Phase 4 work — done.) - New tag: Combat.Event.Kill — fired by UEnemyDeathAbility immediately after ResolveAttribution returns a tagger, on the attribution winner's PlayerState ASC (Attribution.Tagger.Get()->GetCradlASC()). In CRADL the ASC lives on ACradlPlayerState — the same actor the quest component sits on — so the event fires there directly; there is no need to resolve the attacker pawn. FireReplicatedGameplayEvent sets Instigator = GetOwnerActor() (the PlayerState). Payload: Target = victim AEnemyCharacter, InstigatorTags = victim's classification tags (Enemy.Family.*) for filter convenience, OptionalObject = nullptr. - Fired via the typed-payload overload FireReplicatedGameplayEvent(EventTag, const FGameplayTagContainer& InstigatorTags, AActor* Target, const UObject* OptionalObject) (shared with gather/craft). The container form is what lets the kill carry the victim's full classification set rather than a single tag — the classification surface is designed to grow past today's lone Enemy.Family.* tag. - Subscribe at BeginPlay: ASC->GenericGameplayEventCallbacks.FindOrAdd(Combat.Event.Kill). Filter by EventData.InstigatorTags.HasTag(TaskEnemyFamilyTag) (the contained-in check supports hierarchical matching — Enemy.Family.Goblin matches a task for Enemy.Family.Goblin.Warrior). - The victim's classification tags are sourced via Cast<ICombatStatsProvider>(Enemy)->GetClassificationTags at fire-time on the death ability, so the same Enemy.Family.* namespace published by FTargetClassificationScope during damage resolution (Source/CRADL/Combat/TargetClassificationScope.h) is the single source of truth.

Slayer co-existence: Per PROGRESSION_RECIPES.md:265-279, Status.AssignedTarget.MatchingFamily is a stub for slayer-style "is the engaged enemy from my assigned family" gating. That is a separate publisher (assignment system + engagement tag, not a kill event) and is not part of this contract. The shared surface between Quest and Slayer is the Enemy.Family.* taxonomy + the new Combat.Event.Kill event; both systems consume the same kill notification with the same family-tag filter. Slayer adds its own assignment state and publishes its own status tag.

Footguns: - Don't subscribe to Combat.Event.Death. That fires on the victim. The attacker's ASC has no callback by default. - Hierarchical tag matching is intentional. Enemy.Family.Goblin matching a task means kills of Enemy.Family.Goblin.Warrior count. Designers who want strict-leaf matching must author the leaf as the task tag. - Attribution can be null. A player who died, was leashed, or didn't deal damage doesn't get credit. The event simply doesn't fire for them.


Reward Pipeline

Rule: FQuestReward is a sum-typed entry — exactly one of {SkillXp, Item, SpellbookUnlock, Loadout, LoadoutModifier} is set per entry. UQuestDefinition::Rewards is an ordered array of these. On quest completion, server iterates the array and routes each entry to the matching existing grant API:

FQuestReward variant Field shape Grant API Idempotency
SkillXp FGameplayTag SkillTag, int64 XpAmount USkillsComponent::GrantXP(SkillTag, XpAmount) (Source/CRADL/Skills/SkillsComponent.h:46-47) Delta-additive — manifest records granted amount; reconciliation grants the delta.
Item FName ItemId, int32 Count UInventoryComponent::AddItems(ItemId, Count) (Source/CRADL/Inventory/InventoryComponent.cpp:308-345) — see Overflow Ladder below. Conservative additive — never reclaimed.
SpellbookUnlock FGameplayTag BookTag USpellbookComponent::UnlockBook(BookTag) (Source/CRADL/Combat/SpellbookComponent.cpp:71-85) Set-membership; granting twice is a no-op.
Loadout (new) FPrimaryAssetId LoadoutAssetId (type "LoadoutDefinition") ULoadoutComponent::GrantLoadoutId(LoadoutAssetId) (ACradlPlayerState::LoadoutComponent). The theme-agnostic surface for what the game's fiction calls a "ship" per THEME.md. Idempotent on FPrimaryAssetId — appends to UnlockedLoadouts only if absent.
LoadoutModifier (new) FPrimaryAssetId ModifierAssetId (type "LoadoutModifierDefinition") ULoadoutModifierComponent::GrantInstance(Def) (ACradlPlayerState::GetLoadoutModifierComponent(); the asset is resolved from ModifierAssetId via the static ULoadoutModifierComponent::ResolveDefinition) — returns a fresh FGuid InstanceId per call. The theme-agnostic surface for what the game's fiction calls a "pilot" per THEME.md. Not idempotent at the API. Manifest carries the granted InstanceId; reconciliation diffs on ModifierAssetId and never re-grants an entry that already has a recorded InstanceId.

After each grant, a corresponding entry is appended to the quest's FQuestRewardManifest (see Reconciliation). The manifest is what persists; the definition's Rewards[] is the source of truth used by reconciliation.

Why this shape: Every grant API is already authority-only, replication-aware, and persistence-safe. Quest rewards do not need their own delivery machinery; they need an orchestrator that calls existing entry points in order and records what landed. Per the inventory/spellbook/loadout/modifier grounding, the APIs are either idempotent (UnlockBook, GrantLoadoutId), delta-additive (GrantXP, AddItems), or instance-minting (GrantInstance — the manifest carries the InstanceId to enforce idempotency at the quest layer, not the component layer).

Not the only consumer of these grant APIs. Vouchers — items that grant a spellbook/loadout/modifier on redeem — reach the same three component APIs through a separate, non-shared path. Vouchers are reward-and-forget (no manifest, no reconciliation): the item is the owed grant, and it's consumed on use. The split is deliberate — a prior unification attempt produced more problems than it solved. Do not extract a shared orchestrator between the two.

Implementation surface: - File: Source/CRADL/Quests/QuestReward.{h,cpp} (new) — struct definitions. - FQuestReward shape: flat struct with an EQuestRewardType enum discriminator + per-variant optional fields (the same flat representation as FQuestTask, kept consistent per that decision). Rewards use an enum rather than a tag discriminator because there is no reward tag taxonomy — and the enum buys back authoring UX the tag-discriminated tasks can't get, since EditCondition/EditConditionHides can hide the irrelevant variant fields. - Reward delivery: a server-side method UQuestComponent::DeliverReward(const FQuestReward&, FName SourceQuestId) that dispatches to the right component on the owning ACradlPlayerState. Returns a FQuestRewardManifestEntry recording what was applied (including any minted FGuid for modifiers). The SourceQuestId tags any overflow-deferred Item leftover pushed onto PendingRewards; the manifest entry records the def's requested Count, while the leftover-that-didn't-fit lives in the pending queue (the two are orthogonal — see Overflow Ladder). - Verb is "Grant", not "Unlock". Mirrors the existing component APIs (GrantXP, GrantLoadoutId, GrantInstance). UnlockBook is the historical outlier and we don't extend the inconsistency to new variants.

Overflow Ladder

AddItems returns leftover count (Source/CRADL/Inventory/InventoryComponent.cpp:308-345). For Item rewards (and only Item rewards — XP, Spellbook, Loadout, and Modifier have no overflow surface), the v1 policy is a three-step ladder, with each step running on authority before the next:

  1. Bag — call Inventory->AddItems(ItemId, Count). If the return is zero, done.
  2. Bank — call Bank->AddItems(ItemId, Leftover). UBankContainerComponent : UInventoryComponent (Source/CRADL/Inventory/BankContainerComponent.h) shares the same AddItems contract and returns leftover. If the return is zero, done.
  3. Pending — push the remaining {ItemId, Count} onto UQuestComponent::PendingRewards (a new TArray<FPendingQuestReward> on the component, persisted on the profile). Toast the owning client via PS->GetCradlASC()->PostClientMessage(Message.Source.Quest, Warning, "<NSLOCTEXT-wrapped overflow message>", Quest.Reason.OverflowDeferred) (the canonical server-to-owning-client RPC pattern from Source/CRADL/Abilities/CradlAbilitySystemComponent.cpp:121-134, used by UPickupItemAbility for the same bag-full case). On the next game-startup reconciliation pass (see Reconciliation), pending entries are re-played through the bag/bank ladder; any that still don't fit remain queued for the launch after that.

The toast text is localized (NSLOCTEXT), routed through the existing UCradlMessageLogSubsystem (Source/CRADL/Player/CradlMessageLogSubsystem.cpp), and matches the contract: "You have overflow items and your bank is full. Items will be awarded on next login once there is requisite space."

Why bank then pending (not ground-spawn): Ground-spawning a quest reward couples reward delivery to world geometry (where is the player standing? is there ground? in a vehicle?) and gives no recovery path if the player can't pick the item back up (left zone, died on the way over, network blip). A persisted pending queue is the same shape as the existing cold-storage resurrection (Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:71-108, 418-432) — items that didn't fit at load are stored on the profile and drained on the next opportunity. Quest pending-rewards reuse that pattern, sized to "the bank is also full" rather than "the registry forgot this item."

Footguns: - Never grant a reward outside CompleteQuest or the startup reconciliation pass. A direct call from gameplay code (e.g. "just give the player the reward now") bypasses the manifest, breaks reconciliation, and double-grants on next startup. - Item grants are conservative. If a definition is changed to remove a reward item, the player keeps what they already received. The manifest records "this was granted"; reconciliation never reclaims. This matches the user's "reconciliation-resilient" intent — players don't lose rewards they previously got. - Modifier grants are conservative too. Removing a modifier reward from the definition does not revoke the granted FLoadoutModifierInstance from the player. The manifest's InstanceId lets the bookkeeping survive even if the def is renamed; the underlying FGuid on ULoadoutModifierComponent::Unlocked is the durable identity. - Modifier reward and RolledMagnitudes are orthogonal. Quest-granted modifiers pass an empty RolledMagnitudes map to GrantInstance (Phase 1 behavior per Source/CRADL/Loadout/LoadoutModifierTypes.h:30). When Phase 2 modifier-rolling lands, quest rewards may opt into rolled stats; until then, every quest-granted modifier instance starts with empty rolled magnitudes. - Pending entries only carry Item variants. XP / Spellbook / Loadout / Modifier grants have no overflow surface — they always succeed when their target component is present. If the target component is somehow missing (impossible per the PlayerState wiring contract), drop with a warning log; do not queue. - The overflow toast fires once per Item-reward entry that overflows, not once per quest. A quest that grants three different items, all of which overflow bag+bank, sends three toasts. Adequate for v1; if the noise becomes a problem later, coalesce in the message log subsystem (out of scope for this contract).


Reconciliation

Rule: On quest completion, the server records a FQuestRewardManifest — an exact ledger of what was granted, derived from the definition's Rewards[] at the moment of completion. Once per game startup (latched on UCradlSaveSubsystem, a UGameInstanceSubsystem per Source/CRADL/SaveGame/CradlSaveSubsystem.h:17), the saved manifest is diffed against the current Rewards[] of each completed quest's definition. The delta (entries in current but not in manifest, by value-equality) is granted, the manifest is updated, and any PendingRewards queue from prior overflow is replayed through the overflow ladder.

Why startup, not every load: UCradlSaveSubsystem::LoadPlayer is called from ACradlPlayerState::BeginPlay (Source/CRADL/Player/CradlPlayerState.cpp:128) — exactly once per PlayerState creation. In practice that's once per app launch (or PIE Stop → Play): map travel and seamless travel preserve PlayerState, so BeginPlay doesn't re-fire. But to keep the "exactly once per session" guarantee resilient against future cheat reloads (Debug_LoadProfile at Source/CRADL/Player/CradlPlayerController.cpp:1697) or any future re-entry into LoadPlayer, the latch lives on the GameInstance subsystem, not on the per-PlayerState component. Specifically: a TSet<FString> ReconciledSlotsThisSession on UCradlSaveSubsystem, keyed by save slot name. Reconciliation runs the first time LoadPlayer resolves a slot; subsequent re-entries skip it. PIE restart / standalone restart / app relaunch creates a fresh GameInstance → fresh latch → reconciliation runs again on first load, as intended.

This is also how the cold-storage resurrection pass behaves (Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:71-108, 418-432) — it runs once during LoadPlayer, deposits stashed items into the bank, leaves anything that doesn't fit on the cache for the next session. Quest reconciliation is the same pattern, sized to a different kind of "deferred grant."

Per-player in P2P. Each peer (host + clients) calls LoadPlayer on their own ACradlPlayerState::BeginPlay against their own profile and runs their own reconciliation latch — there is no cross-peer reconciliation broadcast. Each player's manifest, definitions, and pending queue are private to that player.

FQuestRewardManifest shape: - Stored per completed quest: TArray<FQuestRewardManifestEntry>. - Each entry mirrors a FQuestReward field-for-field, with a value-equality contract: - SkillXp: (SkillTag, XpAmount) — equality on both. - Item: (ItemId, Count) — equality on both. - SpellbookUnlock: (BookTag) — equality on the tag. - Loadout: (LoadoutAssetId) — equality on the FPrimaryAssetId. - LoadoutModifier: (ModifierAssetId, GrantedInstanceId)diff identity is ModifierAssetId alone; GrantedInstanceId is bookkeeping only (never a diff axis; durable identity for any future revoke/cleanup path). - Serialized via UCradlPlayerProfile extension (new field; bumps CRADL_SAVE_VERSION per Source/CRADL/SaveGame/CradlPlayerProfile.h:20).

Diff semantics:

State Action
Entry in Rewards[] but not in manifest (by value-equality) Grant via the matching API; append to manifest (Item: route through the overflow ladder; LoadoutModifier: capture the returned FGuid into the manifest entry).
Entry in manifest but not in Rewards[] Leave alone. The player keeps what they got. (Conservative.)
Entry in both with matching value No action.
Entry in both with changed value (e.g. Cooking XP 500 → 800 on the same SkillTag; Item count 5 → 10 on the same ItemId) Grant the delta and update the manifest entry. For non-additive variants (SpellbookUnlock, Loadout, LoadoutModifier — equality is binary), already-recorded → no action.

Implementation surface: - UCradlSaveSubsystem adds mutable TSet<FString> ReconciledSlotsThisSession and a private ReconcileQuestRewardsForPlayer(ACradlPlayerState* PS, const FString& SaveSlot) const that runs exactly once per slot per game instance. The subsystem owns only the gate (which must outlive any single component lifetime); the diff + pending-replay logic itself lives on UQuestComponent::ReconcilePersistedRewards(), which the helper delegates to once the latch admits it. This keeps reward delivery (DeliverReward, the overflow ladder) and the manifest/pending state private to the component instead of exposing them to the savegame subsystem. - UQuestComponent::ApplyPersistedState(InUnlocked, InActive, InCompleted, InPending) (implemented Phase 1 with four runtime-typed arrays, not a single FQuestComponentSnapshot bundle — the conversion from the profile's snapshot types happens in UCradlSaveSubsystem): 1. Restore UnlockedQuests (filter through UQuestRegistry::IsKnownQuest; drop orphans). 2. Restore ActiveQuests (filter; drop orphans). For each, also reconcile against the definition: if the saved CurrentTaskIndex is no longer a valid index into the current Tasks (chain shrank/reordered), drop the quest (Open Question #1, RESOLVED — see below). (Phase 1 lands the orphan-filter restore only; the definition-drift drop is Phase 3.) 3. Restore CompletedQuests (filter; drop orphans). 4. Restore PendingRewards (filter against UQuestRegistry::IsKnownQuest for the source-quest FName — warn but keep the owed item, per Persistence; the source FName is diagnostic-only). 5. Reward reconciliation does not run inside ApplyPersistedState — for the reward pipeline the component is a passive restore target. The save subsystem's ReconcileQuestRewardsForPlayer (gated by the once-per-startup ReconciledSlotsThisSession latch) delegates to UQuestComponent::ReconcilePersistedRewards after apply returns, because the gate must outlive any single component lifetime (PlayerState components re-BeginPlay on map travel). The lone exception that does live in ApplyPersistedState is the active-quest definition-drift drop (step 2) — it touches only the component's own ActiveQuests, runs every restore by design, and is not a cross-component reward operation. (Reward reconciliation itself is Phase 3.) - Mirrors the pattern in Source/CRADL/Combat/SpellbookComponent.cpp:154-237 (USpellbookComponent::ApplyPersistedState) and the cold-storage resurrection in Source/CRADL/SaveGame/CradlSaveSubsystem.cpp.

Footguns: - Manifest is a ledger, not a snapshot of player state. A player who completed a quest, got the reward item, then sold it, then the definition changed — they do NOT get the original item back on reconciliation. The manifest records "granted"; player inventory is a separate concern. Conservative-on-grant only. - Active quests under definition drift are dropped (Open Question #1, RESOLVED). If a player is mid-quest and the task chain shrank/reordered so the saved CurrentTaskIndex is no longer valid, the quest is popped from ActiveQuests on load (not completed, not clamped) — see Open Questions #1. The untracked quest can be re-offered by the normal unlock sweep. This is handled in ApplyPersistedState (a per-restore integrity check on the component's own list), separately from the once-per-startup reward diff below. - Manifest equality for Item grants is by (ItemId, Count) pair. Changing "5 Bronze Bars" to "5 Iron Bars" is a remove + add (no Bronze grant outstanding; Iron grant pending). Changing "5 Bronze Bars" to "10 Bronze Bars" is a delta grant of 5 more. - Don't latch reconciliation on UQuestComponent. The component is reconstructed on every map travel (PlayerState components fire their own BeginPlay), so a component-local bReconciled would re-fire on travel. Latch on the GameInstance subsystem instead. - Reconciliation grants are silent. No quest-complete fanfare, no first-equip cue, no level-up toast. The exception is the pending-replay path: if bag+bank are STILL full on the next startup, the overflow ladder re-toasts the player (the same one toast per still-pending Item entry). This is intentional — players who freed inventory between sessions get items quietly; players who didn't get reminded.

Related: feedback_p2p_replication_audit, cold-storage resurrection pattern in Source/CRADL/SaveGame/CradlSaveSubsystem.cpp.


Requirements

Rule: FQuestRequirements is the eligibility gate on UQuestDefinition. Holds four parallel restriction arrays, mirroring the FEquipRequirements-as-future-gate-holder shape from Source/CRADL/Inventory/ItemRow.h:16-29:

  • TArray<FSkillRequirement> Skills — reuse of the existing FSkillRequirement struct (Source/CRADL/Skills/SkillRequirement.h:7-17). Evaluated via the existing USkillsComponent::MeetsRequirements.
  • TArray<FName> PrerequisiteQuests — completed-quest FName list. Player must have all listed quests in CompletedQuests.
  • TArray<FQuestItemRequirement> RequiredItems — presence-only check (FName ItemId + int32 Count). Reuses the same shape as FGatheringNodeDefinition::FItemDrop::RequiredItemId (presence-only — see Source/CRADL/World/GatheringNodeDefinition.h:46-50). Items are not consumed at quest start; they are a starting-condition check.
  • TArray<FGameplayTag> RequiredEquippedTagsItem.Equipped.* tags that must be present on the player's ASC (mirrors how UEquipmentComponent publishes set/material tags per the existing Item.Equipped.Material.* / Item.Equipped.Set.* namespace).

Empty arrays = no restriction in that axis. All non-empty arrays must be satisfied for the quest to unlock.

Why this shape: Per the requirement-pattern grounding, FSkillRequirement is the unified shape for skill gates and is the single canonical evaluator surface in the codebase (used by recipes, equipment, gathering nodes, loot drops). Reusing it for quests means one evaluator (MeetsRequirements), one validator pattern, one shape designers already know. The four-axis split is borrowed from FEquipRequirements (which reserves the same room for future gates per its inline comment). Future axes (e.g. completed-research, dungeon-clear-count) extend the struct, not the schema.

Implementation surface: - File: Source/CRADL/Quests/QuestRequirements.{h,cpp} (new). - FQuestRequirements struct with the four arrays above. - Evaluator: a single const member bool FQuestRequirements::IsSatisfiedBy(const ACradlPlayerState* PS) const that gates all four axes — calls USkillsComponent::MeetsRequirements, checks UQuestComponent::IsQuestCompleted membership, calls UInventoryComponent::CountOf for required items, and checks ASC->HasMatchingGameplayTag for equipped tags. (Implemented Phase 2; reads only owner-visible state, so it is valid on the owning client as well as authority. An empty axis is "no restriction".)

Footguns: - Requirements are eligibility, not enforcement. Once a quest is Active, requirements no longer apply — the player can lose the required equipment, drop the required item, etc. The check is gateway-only. (OSRS shape.) - Don't put per-task requirements here. Per-task gates (e.g. "must be in this area to interact with this NPC") belong on the task type's identity matcher, not on the quest-level requirement set.


Quest Giver Actor

Rule: AQuestGiverActor : AActor is a new placeable actor that implements two interfaces, each with a distinct job:

  • IInteractable (existing, extended in this contract with GetInteractableTag()) — the runtime/dispatch surface. Identifies the actor by a stable FGameplayTag InteractableTag under the new generic Interactable.* taxonomy. Used by Event.Interact matching, by the TurnIn ContextAction host match, and by any future cross-system code that asks "what interactable is this and how do I refer to it stably."
  • IQuestGiver (new) — the quest-domain enumeration surface. Answers "given this entity, what quests can it offer to this player right now?" Used by UI/cosmetic code: chat-head exclamation/question marks, future dialog "what do you have for me", quest-log "go here" markers. The runtime task-progression flow does not depend on this interface.

The two interfaces are deliberately separate because they have different cross-cutting concerns. IInteractable is the generic interactable boundary every system already uses; bolting "what quests do you offer" onto it would couple the interaction layer to the quest layer (every shop, gathering node, and crafting station would inherit a quest-domain method they have no business implementing). IQuestGiver is the natural seam for quest-aware composers (an NPC actor that aggregates dialog + shop + quest giver, a temple statue that offers a daily quest, etc.) to opt into quest enumeration without disturbing the interactable surface.

The actor: - Implements IInteractable::GetInteractableTag() to return its InteractableTag field. - Implements IQuestGiver::GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const — default impl queries UQuestRegistry::GetQuestsByInteractableTag(InteractableTag) for the membership set and filters by per-player lifecycle state (unlockable / startable / advanceable / turn-innable from this giver). - Implements GetPrimaryActionTag() to return Action.Trigger.Modal.Dialogue — the bDefault action that opens the dialogue modal (DIALOGUE_SYSTEM.md). This is not what the quest component subscribes to for Interact tasks; that subscription channel is the generic Event.Interact, which the dispatch funnel fires alongside the modal trigger for any bDefault action with a valid GetInteractableTag() (see Interact Task). - Implements GetSourceLabel() to return the giver's display name (NPC name). - Contributes a TurnIn ContextAction (with ActionTag = Action.Trigger.Quest.TurnIn) via GatherActions when a matching TurnIn task is active and inventory has the required items.

Why a new actor class: Per the fork decision: existing interactables (AStoreTerminal, ABankTerminal, ALoadoutTerminal) carry no stable identity field today, and conflating "this is a shop" with "this is a quest giver" on the same actor is more surface than it's worth for v1. A clean greenfield actor is one new file plus the IInteractable boilerplate already mirrored across six existing implementers. That split is what's actor-class-grain — the identity surface is unified (IInteractable::GetInteractableTag()), so any future terminal that wants to be quest-trackable opts in by overriding one method and adding one tag, not by reparenting or marker-interface adoption.

Implementation surface: - File: Source/CRADL/Quests/QuestGiverActor.{h,cpp} (new). - Class: AQuestGiverActor : AActor, public IInteractable, public IQuestGiver. Stable identity: FGameplayTag InteractableTag UPROPERTY with Categories="Interactable" meta — author-time enforced single tag from the Interactable.* namespace. - New interface: IQuestGiver at Source/CRADL/Quests/QuestGiverInterface.h — single pure-virtual virtual void GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const = 0;. Caller decides which subset to surface (e.g. UI shows the "unlockable + startable" subset as a chat-head exclamation; dialog shows the union). No UInterface boilerplate beyond what IInteractable already uses for reference. - Registry reverse-index: UQuestRegistry builds and caches a TMap<FGameplayTag, TArray<FName>> at EnsureBuilt() time by walking every definition's Interact/TurnIn Tasks[].InteractableTag (per-quest deduped). New method: GetQuestsByInteractableTag(FGameplayTag) const → const TArray<FName>& (static empty sentinel on miss). Exact-tag-keyed — the runtime Event.Interact match (HandleInteractEvent) is hierarchical, but this enumeration index is exact (UI-only seam, no v1 consumer; the common case is giver-tag == task-tag). - IInteractable extension: Source/CRADL/Interaction/InteractableInterface.h gains virtual FGameplayTag GetInteractableTag() const { return FGameplayTag(); } (default invalid; opt-in by override). - ContextAction contribution: GatherActions(APawn* Pawn, TArray<FContextAction>& Out): - Always append the primary action with ActionTag = Action.Trigger.Modal.Dialogue, bDefault = true, payload includes this as Target. This opens the dialogue modal (per DIALOGUE_SYSTEM.md, exactly as AStoreTerminal opens its shop via Action.Trigger.Modal.Shop); the central dispatch also fires Event.Interact for this bDefault action because the source returns a valid GetInteractableTag(), so Interact-task progression rides it for free. (The earlier Action.Trigger.Quest.TalkTo no-op verb is retired/folded.) - Inspect the player's active TurnIn tasks; for the first whose InteractableTag == this->GetInteractableTag() and whose inventory has the required items, append a single TurnIn action with ActionTag = Action.Trigger.Quest.TurnIn. No per-task payload object is carried — UTurnInQuestItemAbility re-derives the task server-side from EventData.Target + quest/inventory state (see TurnIn Task). The standard context dispatch can't carry per-task identity (GetActionPayload is ActionTag-keyed, no pawn), and server re-derivation is the contract-required re-validation anyway. - New ability UTurnInQuestItemAbility (LocalPredicted per the modal/transact pattern in Source/CRADL/Abilities/TransactItemAbility.cpp; inventory consume + task advance guarded to authority — see TurnIn Task). Must be added to ACradlPlayerState::DefaultAbilities to be granted.

Future composition: Any actor that wants to be quest-trackable just overrides IInteractable::GetInteractableTag() and (if it should hand out quests) implements IQuestGiver. A AStoreTerminal that doubles as a quest giver doesn't need to inherit from AQuestGiverActor — it just implements both interfaces and authors its InteractableTag. The previous "NPC bundle actor" sketch is no longer needed; the seam is per-interface, not per-actor. (The standalone AQuestGiverActor remains the v1 placement default for pure quest givers.)

Footguns: - InteractableTag is not optional on AQuestGiverActor. A quest giver without an identity tag is unreachable from quest tasks. The validator (see below) enforces non-empty + Interactable.* namespace on AQuestGiverActor specifically; on other IInteractable implementers, the tag is optional (default invalid = "not quest-trackable"). - The TurnIn ContextAction is locally evaluated. The list of active TurnIn tasks lives in UQuestComponent on the local PlayerState. Per the existing IInteractable pattern, GatherActions is called locally by UInteractionComponent::GatherContextActions (Source/CRADL/Interaction/InteractionComponent.cpp) — server re-validation happens inside the ability. - GetOfferedQuests filters by per-player state, not just registry membership. A naive impl that returned every quest whose tasks reference this giver would over-surface (showing "Cook's Assistant" on the cook's chat-head even after the player completed it). The default impl walks the player's UQuestComponent and includes only quests whose next-action site is this giver. - The dialogue system is now in v1 scope — see DIALOGUE_SYSTEM.md. The primary action opens an OSRS-style dialogue modal (Action.Trigger.Modal.Dialogue); quest acceptance (Action.Trigger.Quest.AcceptUAcceptQuestAbility) and turn-in (Action.Trigger.Quest.TurnIn) are surfaced as quest-binding dialogue nodes. Interact tasks still observe via Event.Interact. The earlier "dialog out of scope / Action.Trigger.Quest.TalkTo parked for a future PR" stance is superseded.


Authority & Replication

Rule: Every state mutation on UQuestComponent is server-authoritative. Client-side callers route through Server_* RPCs (mirroring USkillsComponent::GrantXP at Source/CRADL/Skills/SkillsComponent.cpp:55-67). Replication is COND_OwnerOnly for all three state arrays.

Per-field audit (per feedback_p2p_replication_audit):

Field on UQuestComponent Replication RepNotify Reason
UnlockedQuests : TArray<FName> Replicated + COND_OwnerOnly OnRep_UnlockedQuests Owner needs UI updates; spectators do not see unlocks.
ActiveQuests : TArray<FActiveQuest> Replicated + COND_OwnerOnly OnRep_ActiveQuests Progress counters must reach the owner for HUD updates.
CompletedQuests : TArray<FCompletedQuest> Replicated + COND_OwnerOnly OnRep_CompletedQuests Manifest is large-ish; only the owner needs it.
(No new server-mutated fields on the player ASC that need loose-tag pairing.) If a future requirement adds an Item.Equipped.* mirror tag for "wearing X" gating, follow feedback_replicated_loose_tag_ue54 — pair AddLooseGameplayTag + AddReplicatedLooseGameplayTag.

Trigger ability net policy: New abilities (UTurnInQuestItemAbility) follow the existing modal/transact pattern: LocalPredicted, server is authoritative, client predicts for snappiness. Per ARCH #18.

Footguns: - Authority broadcasts the delegate directly. OnRep does not fire on the listen-server's own host; mutation paths broadcast OnQuestUnlocked / OnQuestProgressChanged / OnQuestCompleted in the authoritative writer after the mutation. Mirrors Source/CRADL/Skills/SkillsComponent.cpp:75-116. - Don't replicate the reward manifest as a separate field. It lives inside the FCompletedQuest struct entries of CompletedQuests; one rep notify covers both. Avoids the "two replicated fields, one rep notify" footgun.


Persistence

Rule: Quest state is persisted through the existing UCradlSaveSubsystem + UCradlPlayerProfile snapshot pipeline. New profile fields: - TArray<FName> UnlockedQuests - TArray<FActiveQuestSnapshot> ActiveQuests (FName + current task index + per-task progress counts) - TArray<FCompletedQuestSnapshot> CompletedQuests (FName + FQuestRewardManifest — the manifest now includes Loadout and LoadoutModifier entries per Reconciliation) - TArray<FPendingQuestReward> PendingRewards (new) — overflow-deferred Item grants that the bag/bank ladder couldn't seat. Each entry: {FName SourceQuestId; FName ItemId; int32 Count;}. Drained at the next startup reconciliation pass; toasts re-fire for entries that still don't fit.

Bump CRADL_SAVE_VERSION from 9 → 10 (Source/CRADL/SaveGame/CradlPlayerProfile.h:20).

Load ordering inside UCradlSaveSubsystem::LoadPlayer (Source/CRADL/SaveGame/CradlSaveSubsystem.cpp): 1. Loadout reshape (existing). 2. Skills (existing). 3. Attributes (existing). 4. Inventory (existing). 5. Spellbook (existing). 6. Modifiers (existing). 7. Bank + cold-storage resurrection (existing) — important: this runs before quest reconciliation, so the bank's current free capacity is correct when the overflow ladder re-evaluates pending Item rewards. 8. Quests (new) — applied last because reconciliation may call GrantXP / AddItems / UnlockBook / GrantLoadoutId / GrantInstance and needs the target components in their loaded state. The actual diff + pending-replay only runs if the save subsystem's ReconciledSlotsThisSession latch hasn't seen this slot yet — see Reconciliation.

Footguns: - Save version bump is non-negotiable. A new field means existing saves can't deserialize without the version gate. Mirror the per-field version checks already in UCradlPlayerProfile. - Reconciliation grants happen post-load. During load, the player isn't fully connected yet on a dedicated server — but for P2P the host's own load fires reward delegates before the UI is bound. The reward delivery is silent (no toasts during reconcile for successful grants); the overflow-deferred toast IS allowed during reconcile because it's the only way the player learns their pending queue is still pending. First-equip toasts and quest-complete fanfare remain separately gated. - Cold storage interaction. If a reward item is itself a deleted/unknown item in the current registry (definition author renamed the item), UInventoryComponent::AddItems would route it through the existing cold-storage triage. Quest reconciliation does not need to do anything special — the bag/bank ladder treats unknown items the same as known ones, and cold-storage owns the resurrection on the next startup. - PendingRewards is server-only knowledge. Not replicated (it's per-player private). The toast on overflow uses the existing Client_PostMessage RPC; the pending queue itself stays on the authoritative PlayerState and is round-tripped through the profile. - PendingRewards source-quest FName is for diagnostics, not gating. Even if the source quest is deleted from the registry between sessions, the pending Item grant still drains into the bag/bank — it's an item-owed-to-the-player, not a quest-owed-completion. Orphan source FNames just log a warning during the filter pass.

Related: Source/CRADL/SaveGame/CradlPlayerProfile.h:22-127, cold-storage resurrection pattern in Source/CRADL/SaveGame/CradlSaveSubsystem.cpp.


Validator

Rule: A new editor validator UCradlQuestDefinitionValidator : UEditorValidatorBase enforces: - DisplayName non-empty. - Tasks non-empty. - Per-task: TargetCount >= 1; type-specific identity field non-empty and resolves against the matching registry (recipe FName resolves, item FName resolves, enemy family tag is under Enemy.Family.*, interactable tag for Interact/TurnIn is under Interactable.*). - Per-reward: variant well-formed (exactly one of SkillXp / Item / SpellbookUnlock / Loadout / LoadoutModifier set); referenced item FName resolves; referenced skill tag resolves; referenced book tag resolves; LoadoutAssetId resolves to a known ULoadoutDefinition asset (best-effort cross-asset scan — no registry exists today, so the validator mirrors the PrerequisiteQuests resolution pattern); ModifierAssetId resolves to a known ULoadoutModifierDefinition asset (same best-effort scan). - Requirements.Skills resolve via CradlValidationHelpers::CollectKnownSkillTags. - Requirements.PrerequisiteQuests resolve to known quest assets (best-effort — assets in the same scan). - Requirements.RequiredItems resolve to the items DataTable.

Per CLAUDE.md's "validators in lockstep" rule, the validator lands in the same PR as the struct definitions.

Implementation surface: - File: Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.{h,cpp} (new). - Pattern mirrors Source/CRADLEditor/Validators/CradlSpellDefinitionValidator.h:32 and Source/CRADLEditor/Validators/CradlRecipeDefinitionValidator.h:22.

Footguns: - The validator must reject duplicate-grant paths. A quest cannot include both an FQuestReward::SpellbookUnlock for Spellbook.Fire AND have its rewards mutate MagicDamage directly via a placeholder GE — the validator enforces one-path-per-reward-axis, mirroring the existing items-table one-path-per-stat rule per PROGRESSION_RECIPES.md:362. - Cross-asset prerequisite validation is best-effort. If QuestB.Requirements.PrerequisiteQuests references QuestA and QuestA is renamed, the validator catches it. If the validator is run on QuestA first and QuestB is unloaded, the dangle is missed. Acceptable — runtime registry filtering catches the orphan on player load.


Tag Taxonomy

All new tags land in Config/DefaultGameplayTags.ini with DevComment per feedback_gameplay_tag_decl_minimal. C++ symbols in Source/CRADL/CradlGameplayTags.h are added only for tags referenced by name in C++.

Tag Existing? Where declared Where referenced
Quest (root, categorization) new .ini only designer-authored categorization on UQuestDefinition.CategoryTags
Quest.MainStory, Quest.Side, Quest.Daily (leaves) new .ini only as needed by authoring
Quest.TaskType.Interact new .ini + C++ symbol task discriminator; matched by name in UQuestComponent::HandleInteractEvent (Phase 6)
Quest.TaskType.TurnIn new .ini + C++ symbol task discriminator; matched by name in UQuestRegistry's InteractableTag reverse-index (filters Interact/TurnIn tasks, Phase 6)
Quest.TaskType.Gather new .ini + C++ symbol task discriminator; matched by name in HandleSkillEvent (Phase 5)
Quest.TaskType.Craft new .ini + C++ symbol task discriminator; matched by name in HandleSkillEvent (Phase 5)
Quest.TaskType.Eliminate new .ini + C++ symbol task discriminator; matched by name in HandleKillEvent (Phase 5)
Interactable (root) new .ini only generic identity for any IInteractable — quest tasks key against this namespace via IInteractable::GetInteractableTag()
Interactable.NPC.<NpcName> (leaves) new .ini only per quest giver / NPC, authored on AQuestGiverActor::InteractableTag
Interactable.<Other> (leaves) new .ini only per opted-in world object / terminal authoring (when an interactable becomes quest-trackable, future)
Event.Interact new .ini + C++ symbol fired by UInteractionComponent::DispatchContextAction for any bDefault action whose source IInteractable returns a valid GetInteractableTag(). Subscribed by UQuestComponent for Quest.TaskType.Interact progression against non-dialogue world objects (levers, search points). Quest-giver talk-to does not ride this — it uses Action.Trigger.Quest.Advance in dialogue (see below).
Action.Trigger.Quest.TalkTo new (removed) v1 leftover, deleted as a root concept. Interaction opens dialogue (Action.Trigger.Modal.Dialogue); talk-to progression is Action.Trigger.Quest.Advance. Strip the symbol + .ini entry.
Action.Trigger.Modal.Dialogue new (in DIALOGUE_SYSTEM.md) .ini + C++ symbol ActionTag of AQuestGiverActor's bDefault action (and its BeginInteract); opens the dialogue modal (Action.Modal.Dialogue) — the AStoreTerminal pattern. Event.Interact fires alongside, orthogonally.
Action.Trigger.Quest.Accept new .ini + C++ symbol dispatched by the dialogue widget for an Offer-role node; triggers UAcceptQuestAbility, which server-side re-validates eligibility and calls UnlockQuestStartQuest. The in-world quest-commit verb. See DIALOGUE_SYSTEM.md.
Action.Trigger.Quest.Advance new .ini + C++ symbol dispatched by the dialogue widget for a step-gated (DialogueKey) response, payload UQuestAdvanceRequest { QuestId, DialogueKey }; triggers UAdvanceQuestTaskAbility, which server-side advances the named quest's current giver-hosted Interact ("talk to") task when its DialogueKey matches. The dialogue-only talk-to verb that replaces TalkTo/Progress. See DIALOGUE_SYSTEM.md.
Action.Trigger.Quest.TurnIn new .ini + C++ symbol fired by AQuestGiverActor's TurnIn ContextAction and by a dialogue TurnIn-role node (DIALOGUE_SYSTEM.md); both publishers trigger UTurnInQuestItemAbility (the quest component does not subscribe — the ability owns turn-in). A non-default action with item-consumption semantics, so it keeps its own dedicated channel rather than going through Event.Interact. Not gated on Action.Modal.Dialogue — it must activate from both the context menu and the dialogue page.
Combat.Event.Kill new .ini + C++ symbol fired by UEnemyDeathAbility on attacker ASC; subscribed by quest component (and future Slayer)
Event.Quest.Started, Event.Quest.Completed, Event.Quest.TaskAdvanced new .ini + C++ symbol (selectively) fired by UQuestComponent for UI/cue listeners
Message.Source.Quest new .ini + C++ symbol source tag for UCradlMessageLogSubsystem toasts — overflow-deferred reward notice, future quest-event toasts
Quest.Reason.OverflowDeferred new .ini + C++ symbol ReasonTag on the overflow toast; pairs with the new Message.Source.Quest source per the existing (Source, ReasonTag) shape in Source/CRADL/Player/CradlMessageLogSubsystem.cpp:96-105
Enemy.Family.* existing .ini reused by Eliminate task filtering
Event.Skill.Succeeded, Action.Gathering, Action.Crafting existing .ini + C++ symbol reused by Gather/Craft task filtering
Status.AssignedTarget.MatchingFamily existing stub .ini NOT consumed by Quest (out of scope) — reserved for Slayer

Forward Code References

Future PRs land in named, predictable places:

  • Source/CRADL/Quests/QuestDefinition.{h,cpp}UQuestDefinition, FQuestTask, FQuestReward, FQuestRequirements.
  • Source/CRADL/Quests/QuestRegistry.{h,cpp}UQuestRegistry, including the TMap<FGameplayTag, TArray<FName>> reverse-index over Tasks[].InteractableTag and the GetQuestsByInteractableTag(FGameplayTag) const accessor.
  • Source/CRADL/Quests/QuestComponent.{h,cpp}UQuestComponent, FActiveQuest, FCompletedQuest, FQuestRewardManifest.
  • Source/CRADL/Quests/QuestGiverActor.{h,cpp}AQuestGiverActor, implementing IInteractable::GetInteractableTag() (runtime identity) and IQuestGiver::GetOfferedQuests (UI enumeration).
  • Source/CRADL/Quests/QuestGiverInterface.hIQuestGiver quest-domain interface: virtual void GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const = 0;.
  • Source/CRADL/Quests/QuestRequirements.{h,cpp}FQuestRequirements evaluator.
  • Source/CRADL/Abilities/TurnInQuestItemAbility.{h,cpp} — server-authoritative turn-in ability.
  • Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.{h,cpp} — validator.
  • Modifications to existing files (each is a small surface change, not a refactor):
  • Source/CRADL/Interaction/InteractableInterface.h — add virtual FGameplayTag GetInteractableTag() const { return FGameplayTag(); } (default invalid; opt-in by override).
  • Source/CRADL/Interaction/InteractionComponent.cpp — in DispatchContextAction, after the action's own event fires, additionally fire Event.Interact on the player's ASC when Action.bDefault == true and the source actor's IInteractable::GetInteractableTag() returns a valid tag. Payload: Target = source actor, InstigatorTags = { interactable tag }.
  • Source/CRADL/Abilities/GatherPayload.h (new) — UGatheredItemPayload : UObject carrying {FName ItemId; int32 Count}; a leaf header so the quest subscriber includes it without the gather-ability header.
  • Source/CRADL/Abilities/GatherAbility.cpp — extend gather success event payload with the rolled UGatheredItemPayload.
  • Source/CRADL/Abilities/CraftAbility.cpp — extend craft success event payload with the URecipeDefinition*.
  • Source/CRADL/Abilities/CradlAbilitySystemComponent.{h,cpp} — typed-payload overload FireReplicatedGameplayEvent(EventTag, const FGameplayTagContainer& InstigatorTags, AActor* Target, const UObject* OptionalObject) + its NetMulticast_HandleGameplayEventObject RPC (shared by gather/craft/kill fire-sites).
  • Source/CRADL/Enemy/EnemyDeathAbility.cpp — fire Combat.Event.Kill on the attribution-winner's PlayerState ASC after ResolveAttribution.
  • Source/CRADL/Player/CradlPlayerState.{h,cpp} — instantiate UQuestComponent alongside skills/inventory/spellbook/loadout/modifier.
  • Source/CRADL/SaveGame/CradlPlayerProfile.h — new fields (quests state + PendingRewards); bump CRADL_SAVE_VERSION to 10.
  • Source/CRADL/SaveGame/CradlSaveSubsystem.{h,cpp}TSet<FString> ReconciledSlotsThisSession latch + ReconcileQuestRewardsForPlayer helper; apply quest snapshot last in LoadPlayer; capture in SavePlayer.
  • Source/CRADL/CradlGameplayTags.h — C++ symbols for the tags listed above as "+ C++ symbol" (including Event.Interact, Message.Source.Quest, Quest.Reason.OverflowDeferred).
  • Config/DefaultGameplayTags.ini — tag declarations with DevComment.
  • Config/DefaultGame.ini+PrimaryAssetTypesToScan entry for QuestDefinition.

Open Questions

  1. Active quest under definition drift — RESOLVED (drop). A player is mid-quest; the definition's task chain is edited (task removed, reordered, target count lowered) such that the saved CurrentTaskIndex is no longer a valid index into Tasks. Decision (2026-05-20): pop the quest entirely — it is removed from ActiveQuests on load, NOT moved to Completed and NOT clamped. The two original sketches were rejected: "completed-with-empty-manifest" forces an awkward choice in reward reconciliation (an empty manifest makes the diff grant every reward; only a full no-grant manifest makes "completed, no rewards" stick — murkier than dropping), and "clamp-and-continue" silently teleports the player to the new last task. Dropping leaves the quest untracked, so the standard unlock sweep (level-up / prerequisite completion) re-offers it if its requirements still hold. Implemented in UQuestComponent::ApplyPersistedState (range check + warning log), co-located with the orphan-FName filter; see Reconciliation and QUEST_IMPLEMENTATION.md Phase 3.

  2. Reward overflow policy — RESOLVED. Bag → bank → PendingRewards persistent queue with overflow toast via UCradlMessageLogSubsystem. Pending entries drain on the next startup reconciliation pass. See Overflow Ladder and Persistence. The original alternative — ground-spawn — was rejected because quest rewards must survive the player walking away, dying, or disconnecting before pickup.

  3. In-world accept / turn-in interaction — RESOLVED (dialogue). The quest-giver interaction that commits the player to a quest (accept) and that hands in items (turn-in) is now in v1 scope, governed by DIALOGUE_SYSTEM.md: talking to a giver opens an OSRS-style dialogue modal whose quest-binding nodes dispatch Action.Trigger.Quest.Accept (→ UAcceptQuestAbility) and the existing Action.Trigger.Quest.TurnIn (→ UTurnInQuestItemAbility). What remains deferred is the read-only quest journal UI — the player-opened log that browses Available / Active / Completed rosters (the Action.Modal.QuestLog modal). The three-state data model commits to what that journal must surface; CommonUI page architecture per ARCH #9. No journal design here; flag for the UI doc.

  4. World markers / quest indicators. OSRS uses NPC chat-head icons + minimap dots. CRADL has no minimap yet; the chat-head equivalent is a HUD overlay over AQuestGiverActor. Defer to a later UI phase; the data model exposes "is there an active task targeting this giver?" trivially via UQuestComponent.

  5. Repeatable / daily quests. v1 ships one-shot quests only. Repeatable shape needs a "last completed at" timestamp on FCompletedQuest and a cooldown gate on unlock. Defer until v2 design.

  6. Public "currently on quest" cosmetic. Spectator UI may want to display another player's active quest title. The data model is COND_OwnerOnly; surfacing this for spectators means adding a small public mirror field on ACradlPlayerState (analogous to FActivityDescriptor). Deferred — not load-bearing for v1.

  7. Identity expansion for existing interactables — RESOLVED. The contract now extends identity uniformly via IInteractable::GetInteractableTag() (new virtual on the existing interface, default invalid). Any IInteractable implementer becomes quest-trackable by overriding the method and authoring a tag in the Interactable.* namespace. Quest-handing-out behavior is a separate, opt-in surface (IQuestGiver::GetOfferedQuests). AQuestGiverActor is the v1 canonical implementer of both interfaces, but other actors (a AStoreTerminal that doubles as a quest giver, an ASceneryActor you "examine") opt in per-instance without inheritance churn. The previously-feared "retrofit every terminal" cost is no longer present — the per-actor cost is one method override + one tag.

  8. Abandon / fail / branching. Out of v1 scope by design. If a future quest needs branching, the easier model is two separate quests with mutually-exclusive prerequisites (the Prerequisite axis can be extended to "must NOT have completed X" — a new restriction type per the FQuestRequirements expansion contract).

  9. UGatheredItemPayload shape. Is it worth a UObject just to carry {FName, int32} through EventData.OptionalObject? Alternative: encode the rolled ItemId as EventData.InstigatorTags extension (with a registered Item.Granted.<ItemId> tag — high tag-churn) or via EventData.EventMagnitude packing (hack). v1 sketch uses the UObject; the impl phase can reconsider.

  10. Slayer assignment publisher. Status.AssignedTarget.MatchingFamily is the slayer surface. Quest does not publish it. When Slayer ships, the natural publisher site is in the engagement-tracking component on ACradlPlayerState that fires on Combat.Event.Hurt for the current target. Out of this contract; recorded here so the future Slayer doc can pick up the thread.