0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI QUEST_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
QUEST_IMPLEMENTATION.md 13217 words ~60 min read Updated 2026-07-03

CRADL Quest Implementation

Companion to QUEST_SYSTEM.md (the contract), COMBAT_SYSTEM.md, PROGRESSION_RECIPES.md, and ARCHITECTURE.md. This doc tracks the build order for v1 Quests: phased delivery, per-phase rationale, task checklists, and verification gates. The contract doc says what Quests are; this doc says what we build first, what depends on what, and how we know each step works.

Greenfield system. No prior Quest subsystem exists in Source/CRADL/Quests/ — Phase 0 is the first commit under that folder. Every other component the system binds to (USkillsComponent, UInventoryComponent, USpellbookComponent, UEnemyDeathAbility, UCradlSaveSubsystem, the ASC event multicast) is already in production and gets additive changes only.

Conventions

  • Phase status legend: [ ] not started · [~] in progress · [x] done · [!] blocked / deferred.
  • Verification gate: every phase ends with a runnable demo or observable behavior. If a phase can't be verified end-to-end, it's split.
  • Cheat commands: test fixtures land under ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded.
  • Per CLAUDE.md "validators in lockstep": any phase that touches UQuestDefinition, FQuestTask, FQuestReward, or FQuestRequirements updates UCradlQuestDefinitionValidator under Source/CRADLEditor/Validators/ in the same change.
  • Per CLAUDE.md "no UBT here": Claude does not build. After C++ edits, the user compiles and reports back.
  • Tag declaration rule (per feedback_gameplay_tag_decl_minimal): declare in Config/DefaultGameplayTags.ini with DevComment; add a C++ symbol in Source/CRADL/CradlGameplayTags.h only when referenced by name from C++ code.
  • Replication audit per phase: any new server-mutated field or replicated property gets a one-line answer in the phase's task list per feedback_p2p_replication_audit.
  • American English spellings per feedback_american_english.md.

Phase tracking

Phase Title Status Unblocks
0 Tag & data scaffolding [x] All later phases
1 Per-player state & persistence [x] 2, 3, 5, 6
2 Requirements & unlock pipeline [x] 5, 6 (eligibility); 7 (validator deepening)
3 Reward pipeline & manifest reconciliation [x] 5, 6 (auto-completion observable end-to-end)
4 Gameplay event surfaces [x] 5
5 Task type subscribers (Gather, Craft, Eliminate) [x] 6 (Interact/TurnIn build on the same subscriber pattern)
6 Quest giver actor & turn-in ability [x] 7
7 Validator depth, cheats, debug overlay [x] v1 (cheat-driven) launch
8 Quest-giver dialogue integration & accept ability [x] verified in PIE v1 in-world play (depends on DIALOGUE_SYSTEM.md)

Phase 0 — Tag & Data Scaffolding

Goal. Compile-clean codebase with the full data shape (definition, registry, struct types, validator stub) in place. No behavior. After this phase the editor can author a UQuestDefinition asset with all author-visible fields populated; saves still load (empty quest profile fields round-trip on bumped version).

Rationale. Per QUEST_SYSTEM.md "Quest Definition" and the registry-first pattern in ARCH #14, every later phase reads from the registry and writes to a player-component. Both need the asset class to exist. Saves are version-gated: bumping CRADL_SAVE_VERSION 9 → 10 with empty new fields keeps existing saves loadable.

Tasks.

  • [x] Gameplay tags (.ini declarations) — Config/DefaultGameplayTags.ini:
  • [x] Quest root + Quest.MainStory, Quest.Side, Quest.Daily (.ini only; categorization only — see Tag Taxonomy).
  • [x] Quest.TaskType.Interact, Quest.TaskType.TurnIn, Quest.TaskType.Gather, Quest.TaskType.Craft, Quest.TaskType.Eliminate (.ini only; discriminators read as data).
  • [x] Message.Source.Quest (.ini + C++ symbol — source tag for overflow toasts and future quest-event toasts, consumed by Phase 3 reward delivery).
  • [x] Quest.Reason.OverflowDeferred (.ini + C++ symbol — ReasonTag on the overflow toast, used by Source/CRADL/Player/CradlMessageLogSubsystem.cpp:96-105's (Source, ReasonTag) pair).
  • [x] Each new tag gets a DevComment per feedback_gameplay_tag_decl_minimal.md.
  • [x] PrimaryAssetTypesToScan — Config/DefaultGame.ini:
  • [x] +PrimaryAssetTypesToScan entry: PrimaryAssetType="QuestDefinition", AssetBaseClass=/Script/CRADL.QuestDefinition, Directories=((Path="/Game/Definitions/Quests")). Mirror the SpellDefinition entry shape exactly.
  • [x] UQuestDefinitionSource/CRADL/Quests/QuestDefinition.h (new):
  • [x] class UQuestDefinition : public UPrimaryDataAsset.
  • [x] Override GetPrimaryAssetId()FPrimaryAssetId(TEXT("QuestDefinition"), GetFName()) (mirror USpellDefinition, UEnemyDefinition).
  • [x] Fields per QUEST_SYSTEM.md "Quest Definition": DisplayName, Description, Icon (TSoftObjectPtr<UTexture2D>), CategoryTags (FGameplayTagContainer with Categories="Quest"), Requirements, Tasks, Rewards.
  • [x] FQuestTask shape — flat discriminator (decided) — Source/CRADL/Quests/QuestTask.h (new):
  • [x] Chose flat discriminator over FInstancedStruct. Why: FInstancedStruct/StructUtils is used nowhere in CRADL (every existing definition struct — FItemDrop, FSkillRequirement, FEquipRequirements — is flat-with-optional-fields, so it would be a new plugin/module dep on a foundational shape); the reward sibling must persist in the save-game manifest and be diffed field-by-field on reconciliation (Phase 3), trivial with a flat struct; and the contract wants tasks and rewards to share one representation. The one cost — no EditCondition field-hiding, since the discriminator is a gameplay tag — is bought back by the strict validator. Rationale recorded in the QuestTask.h header comment.
  • [x] FQuestTask { FGameplayTag TaskType; int32 TargetCount; FText Description; FGameplayTag InteractableTag; FName ItemId; int32 ItemCount; FName RecipeId; FGameplayTag EnemyFamilyTag; } with validator enforcing field/type fit. Per QUEST_SYSTEM.md "Task Chain", InteractableTag is the single identity field for both Interact and TurnIn tasks — no per-task-type bifurcation (no separate QuestGiverTag).
  • [x] FQuestReward shape — flat struct + EQuestRewardType enum discriminator — Source/CRADL/Quests/QuestReward.h (new):
  • [x] Same flat representation as FQuestTask (kept consistent per that decision), but discriminated by an EQuestRewardType enum rather than a tag — there is no reward tag taxonomy, and the enum lets EditCondition/EditConditionHides hide the irrelevant variant fields (authoring UX the tag-discriminated tasks can't get).
  • [x] Five variants:
  • [x] FQuestRequirementsSource/CRADL/Quests/QuestRequirements.h (new):
  • [x] Struct with four arrays: Skills (TArray<FSkillRequirement>), PrerequisiteQuests (TArray<FName>), RequiredItems (TArray<FQuestItemRequirement>), RequiredEquippedTags (TArray<FGameplayTag>). Evaluator method comes in Phase 2; only the struct lands here.
  • [x] FQuestItemRequirement { FName ItemId; int32 Count; } — mirrors the presence-only shape of FGatheringNodeDefinition::FItemDrop::RequiredItemId.
  • [x] UQuestRegistrySource/CRADL/Quests/QuestRegistry.h + .cpp (new):
  • [x] class UQuestRegistry : public UGameInstanceSubsystem.
  • [x] mutable bool bBuilt = false latch + EnsureBuilt() exactly mirroring USpellRegistry::EnsureBuiltbBuilt = true set before the scan call to guard reentrancy (per the PIE-race footgun in QUEST_SYSTEM.md "Quest Registry" Footguns).
  • [x] TMap<FName, TObjectPtr<UQuestDefinition>> DefinitionsByName indexed during build (stored as TObjectPtr<const UQuestDefinition>, mirroring USpellRegistry's const storage + the read-only-at-runtime rule).
  • [x] API: GetDefinition(FName) const, IsKnownQuest(FName) const, GetAllQuestIds(TArray<FName>& Out) const (enumerator for editor/UI).
  • [x] UCradlQuestDefinitionValidator stub — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h + .cpp (new):
  • [x] class UCradlQuestDefinitionValidator : public UEditorValidatorBase. Mirror CradlSpellDefinitionValidator.
  • [x] Phase-0 checks: DisplayName non-empty; Tasks non-empty; each task's TargetCount >= 1; CategoryTags only contains tags under Quest.*. Deeper checks land in their owning phases.
  • [x] Save version bump — Source/CRADL/SaveGame/CradlPlayerProfile.h:
  • [x] #define CRADL_SAVE_VERSION 10 (was 9).
  • [x] Add four new fields on UCradlPlayerProfile: TArray<FName> UnlockedQuests, TArray<FActiveQuestSnapshot> ActiveQuests, TArray<FCompletedQuestSnapshot> CompletedQuests, TArray<FPendingQuestReward> PendingRewards. The three snapshot/pending USTRUCTs are defined inline in the profile header at the minimal shape needed for the v10 round-trip (UHT can't forward-declare a USTRUCT used as a TArray UPROPERTY element). FActiveQuestSnapshot and FPendingQuestReward land complete; FCompletedQuestSnapshot carries identity only — Phase 1 adds its FQuestRewardManifest member (additive, tagged-property-safe).
  • [x] Field ordering mirrors the existing profile structure (appended after the death-state section; bank/cold-storage and modifier-instance "list of value structs" are the precedent — see Source/CRADL/SaveGame/CradlPlayerProfile.h:47, 52, 57). No explicit per-field SaveVersion guard: additive fields rely on the profile's tagged-property serialization (per the CRADL_SAVE_VERSION header note). Capture/apply wiring is Phase 1.

Verification.

  • Compile clean (user-side).
  • Open the editor. Create a new UQuestDefinition asset under /Game/Definitions/Quests/. Author DisplayName = "Test Quest", leave Tasks empty → save → asset validator flags the empty-Tasks error.
  • Add one Eliminate task with TargetCount = 0 → save → validator flags TargetCount >= 1 error.
  • With a single well-formed task, asset saves clean.
  • Load an existing save (pre-v10) → confirm migration to v10 succeeds with empty quest fields.

Exits. Phase 1 now has the asset shape, registry, and persistence slot.


Phase 1 — Per-Player State & Persistence

Goal. A player can hold quest state. UQuestComponent lives on ACradlPlayerState, replicates owner-only with the three TArray pattern, and round-trips through save/load. No event subscriptions, no requirements, no rewards yet — just the authority entry points (UnlockQuest, StartQuest, AdvanceTask, CompleteQuest) callable from cheats.

Rationale. Per QUEST_SYSTEM.md "Per-Player State", state lives on PlayerState alongside skills/inventory/spellbook. Reconciliation in Phase 3 needs the manifest to persist; eligibility sweeps in Phase 2 need CompletedQuests and UnlockedQuests. Without the persistence wiring there is nothing to reconcile against. Decoupling state from event consumption means Phase 5's subscribers can wire onto a known-good store.

Tasks.

Phase 1 structural note (deviation from "structs in QuestComponent.h"): FQuestRewardManifest / FQuestRewardManifestEntry / FPendingQuestReward landed in a new leaf header Source/CRADL/Quests/QuestPersistentTypes.h instead of QuestComponent.h. The manifest is needed by both the runtime FCompletedQuest (QuestComponent.h) and the save FCompletedQuestSnapshot (CradlPlayerProfile.h); putting it in either would force the other to include it and create a component↔savegame cycle. The leaf header both sides include avoids the cycle and keeps the savegame format decoupled from the runtime component. FPendingQuestReward moved here from the Phase-0 profile header for the same reason (UQuestComponent owns the authoritative copy, the profile round-trips it). The component speaks runtime types across ApplyPersistedState / getters; UCradlSaveSubsystem does the snapshot↔runtime conversion, so the component never includes the savegame header.

  • [x] UQuestComponentSource/CRADL/Quests/QuestComponent.h + .cpp (new):
  • [x] class UQuestComponent : public UActorComponent. Constructor: SetIsReplicatedByDefault(true).
  • [x] Three replicated fields, each COND_OwnerOnly with ReplicatedUsing = OnRep_*:
    • [x] TArray<FName> UnlockedQuestsOnRep_UnlockedQuests broadcasts OnQuestUnlocked per added entry.
    • [x] TArray<FActiveQuest> ActiveQuestsOnRep_ActiveQuests broadcasts OnQuestProgressChanged per added/changed entry.
    • [x] TArray<FCompletedQuest> CompletedQuestsOnRep_CompletedQuests broadcasts OnQuestCompleted per added entry.
  • [x] GetLifetimeReplicatedProps with the three DOREPLIFETIME_CONDITION(..., COND_OwnerOnly) lines.
  • [x] Authority broadcasts the delegate directly after mutation on listen-server (mirror USkillsComponent::ApplyXPGrant's pattern — OnRep does not fire on the host owner).
  • [x] Server-only mutator API (each routes via Server_* RPC from client):
    • [x] UnlockQuest(FName QuestId) — guard: IsKnownQuest(QuestId) and not already in any of the three lists; append to UnlockedQuests.
    • [x] StartQuest(FName QuestId) — guard: present in UnlockedQuests; promote to ActiveQuests with CurrentTaskIndex = 0 and zero counters.
    • [x] AdvanceTask(FName QuestId, int32 TaskIndex, int32 Delta) — clamp delta-added counter at 0; on hitting TargetCount increment CurrentTaskIndex; on overrun call CompleteQuest. (Also guards TaskIndex == CurrentTaskIndex — see Task Chain reconciliation note.)
    • [x] CompleteQuest(FName QuestId) — move from ActiveQuests to CompletedQuests. Phase-1 manifest is empty (FQuestRewardManifest()); Phase 3 fills it.
  • [x] FActiveQuest, FCompletedQuest, FQuestRewardManifest, FQuestRewardManifestEntry, FPendingQuestRewardSource/CRADL/Quests/QuestComponent.h (manifest/entry/pending moved to QuestPersistentTypes.h — see structural note above):
  • [x] FActiveQuest { FName QuestId; int32 CurrentTaskIndex; TArray<int32> TaskProgress; }.
  • [x] FCompletedQuest { FName QuestId; FQuestRewardManifest Manifest; }.
  • [x] FQuestRewardManifest { TArray<FQuestRewardManifestEntry> Entries; }. Entry shape mirrors FQuestReward field-for-field per QUEST_SYSTEM.md "Reconciliation" — including Loadout entries (FPrimaryAssetId LoadoutAssetId) and LoadoutModifier entries (FPrimaryAssetId ModifierAssetId, FGuid GrantedInstanceId). GrantedInstanceId is bookkeeping only — never a diff axis. Diff identity for modifiers is ModifierAssetId alone.
  • [x] FPendingQuestReward { FName SourceQuestId; FName ItemId; int32 Count; } (new — overflow-deferred Item grants per QUEST_SYSTEM.md "Overflow Ladder"). Only the Item variant overflows; XP / Spellbook / Loadout / Modifier have no overflow surface. SourceQuestId is diagnostic-only (filter against IsKnownQuest on load with warning log; do not gate the grant on it).
  • [x] Server-only TArray<FPendingQuestReward> PendingRewards on UQuestComponent (NOT replicated — per-player private, lives only on authority; round-trips through the profile). Phase 1 just declares the field; the drain loop lands in Phase 3.
  • [x] IQuestGiver interface header — Source/CRADL/Quests/QuestGiverInterface.h (new, scaffold only):
  • [x] UINTERFACE + IQuestGiver with virtual void GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const = 0; per QUEST_SYSTEM.md "Quest Giver Actor". This is the quest-domain enumeration seam — "what quests can this entity offer this player right now"; the runtime identity surface is IInteractable::GetInteractableTag() (extension landing in Phase 4). Caller decides which subset to surface (chat-head exclamation = unlockable+startable; dialog = union). No implementer yet — actor lands in Phase 6.
  • [x] PlayerState wiring — Source/CRADL/Player/CradlPlayerState.h + .cpp:
  • [x] UPROPERTY(VisibleAnywhere) TObjectPtr<UQuestComponent> QuestComponent; instantiated in the constructor alongside the existing components. Added GetQuestComponent() accessor alongside the existing component getters.
  • [x] Persistence — Source/CRADL/SaveGame/CradlPlayerProfile.h:
  • [x] FActiveQuestSnapshot { FName QuestId; int32 CurrentTaskIndex; TArray<int32> TaskProgress; }. (Already present from Phase 0; unchanged.)
  • [x] FCompletedQuestSnapshot { FName QuestId; FQuestRewardManifest Manifest; }. (Phase 1 added the Manifest member.)
  • [x] PendingRewards profile field directly uses the runtime FPendingQuestReward struct (it's already a plain value struct — mirrors how FLoadoutModifierInstance round-trips via Source/CRADL/SaveGame/CradlPlayerProfile.h:52 without a parallel snapshot type). (Struct now lives in QuestPersistentTypes.h per the structural note; the field is unchanged.)
  • [x] Update the previously-stubbed Phase-0 fields to use the real types.
  • [x] Save subsystem — Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:
  • [x] SavePlayer: capture UnlockedQuests + pending directly, and convert active/completed runtime records to their snapshot types. (Phase-1 reconciliation: implemented as read-only getters on UQuestComponentGetUnlockedQuests / GetActiveQuests / GetCompletedQuests / GetPendingRewards — with the snapshot↔runtime conversion done in UCradlSaveSubsystem, NOT a CaptureSnapshot() method returning a bundle struct. This mirrors USpellbookComponent (getters) and keeps the component free of any savegame-header dependency.)
  • [x] LoadPlayer: apply quest snapshot last in the load order (after Loadout / Skills / Health / Cold storage + bank resurrection / Spellbook / Modifiers / Death state) so Phase-3 reconciliation can call into already-loaded components and so the bank's free capacity reflects cold-storage resurrection before the overflow ladder reruns. See QUEST_SYSTEM.md "Persistence" load ordering.
  • [x] UQuestComponent::ApplyPersistedState: restore each list (Unlocked / Active / Completed / Pending); filter every entry through UQuestRegistry::IsKnownQuest (for Pending, filter by SourceQuestId but only warn — don't drop the pending Item itself, per the contract); drop orphans with a UE_LOG(..., Warning, ...) per QUEST_SYSTEM.md "Quest Registry" Footguns. (Logged via a LogCradlQuestState category in QuestComponent.cpp.)
  • [x] Cheat commands — Source/CRADL/Player/CradlPlayerController.h + .cpp:
  • [x] Declare UFUNCTION(Exec) void Debug_UnlockQuest(FName QuestId); unconditionally; body guarded by the existing #if !UE_BUILD_SHIPPING exec block per CLAUDE.md. Same shape for Debug_StartQuest, Debug_AdvanceTask, Debug_CompleteQuest. Added Debug_PrintQuests (dumps the three lists + per-task counters) as the observation surface for the verification flow. The cheats call the component mutators directly — those self-route through Server_* RPCs when off-authority (mirroring Debug_GrantXP/USkillsComponent::GrantXP), so no Server_Cheat_* wrappers were needed.
  • [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
  • [x] Per-task identity field non-empty (Interact → InteractableTag; TurnIn → InteractableTag + ItemId + ItemCount >= 1; Gather → ItemId; Craft → RecipeId; Eliminate → EnemyFamilyTag under Enemy.Family.*). Also added a TaskType must-be-a-Quest.TaskType.*-leaf gate (the per-type checks key off it). Namespace check on the Interactable.* root lands in Phase 6 alongside the per-actor InteractableTag UPROPERTY.
  • [x] Validators-in-lockstep per CLAUDE.md — same change as the struct shape.

P2P replication audit (per feedback_p2p_replication_audit):

New replicated field Replication Reason
UQuestComponent::UnlockedQuests Replicated + COND_OwnerOnly + OnRep_UnlockedQuests Per-player private progression.
UQuestComponent::ActiveQuests Replicated + COND_OwnerOnly + OnRep_ActiveQuests Progress counters reach owner for HUD.
UQuestComponent::CompletedQuests Replicated + COND_OwnerOnly + OnRep_CompletedQuests Manifest is large-ish; only owner needs it.
UQuestComponent::PendingRewards Not replicated — server-only knowledge Per-player private; the owning client learns about the deferred grant via the existing Client_PostMessage RPC, not via state replication. Round-trips through the profile.
ACradlPlayerState::QuestComponent Implicit (component replicated via SetIsReplicatedByDefault); not a UPROPERTY(Replicated) itself. Component reference; replicated state is on the component.

Verification.

  • Cheat flow on listen-server (single player): Debug_UnlockQuest TestQuestDebug_StartQuest TestQuestDebug_AdvanceTask TestQuest 0 1 (until the task target is hit) → observe auto-Debug_CompleteQuest fire → quest appears in CompletedQuests with empty manifest.
  • On a P2P host+client setup, run the same flow on the client: confirm only the owner-client observes the rep notifies fire; the host's other-player observer sees nothing.
  • Trigger Debug_AdvanceTask with a negative delta: counter stays at zero (no underflow). Per QUEST_SYSTEM.md "Task Chain" Footguns.
  • Save → close PIE → load → confirm the three lists round-trip; the manifest (empty) survives.
  • Manually rename a quest asset in the editor between save and load: confirm ApplyPersistedState drops the orphan FName with a warning log.

Exits. Phase 2 can sweep UnlockedQuests/CompletedQuests; Phase 3 can fill manifests on CompleteQuest; Phase 5 can call AdvanceTask from event handlers.


Phase 2 — Requirements & Unlock Pipeline

Goal. Eligibility is checked. FQuestRequirements::IsSatisfiedBy(PS) evaluates the four axes against the player. A skill-level-up triggers an unlock sweep; completing a quest triggers a prerequisite-completion sweep. Designers can author "unlock at Cooking 10" or "unlock after QuestA" semantics and observe them fire.

Rationale. Per QUEST_SYSTEM.md "Lifecycle", unlock has three triggers: manual server call (already in Phase 1), prerequisite sweep on completion, and skill-threshold sweep on level-up. Phase 1 left the door open for "any unlock"; this phase wires the automatic two. The evaluator is the canonical surface — UI uses it to render "Available" filtering, validators use it to sanity-check requirement references.

Tasks.

  • [x] FQuestRequirements::IsSatisfiedBy(const ACradlPlayerState* PS) constSource/CRADL/Quests/QuestRequirements.cpp (new): (Implemented as a const member, NOT static. The contract's "single static ... const" wording is self-contradictory; the const member matches the Phase-0 QuestRequirements.h comment and lets Requirements.IsSatisfiedBy(PS) read like the other per-struct evaluators. Contract wording reconciled — see QUEST_SYSTEM.md "Requirements".)
  • [x] Skills.IsEmpty() || PS->GetSkillsComponent()->MeetsRequirements(Skills) — reuse the existing evaluator. (MeetsRequirements takes TArrayView<const FSkillRequirement>; the TArray converts implicitly.)
  • [x] PrerequisiteQuests — every FName must satisfy PS->GetQuestComponent()->IsQuestCompleted(QuestId). (Phase 1 added IsQuestUnlocked / IsQuestActive / IsQuestCompleted BlueprintPure accessors — named IsQuest* because UActorComponent::IsActive() is already a UFUNCTION. The lifecycle arrays themselves are protected, so use these rather than reaching for the array; the skill-threshold sweep dedupes via IsTracked.)
  • [x] RequiredItemsPS->GetInventoryComponent()->CountOf(ItemId) >= Count for each entry.
  • [x] RequiredEquippedTagsPS->GetAbilitySystemComponent()->HasMatchingGameplayTag(Tag) for each entry. (Reads only owner-visible/replicated state across all four axes, so the evaluator is safe to call on the owning client too — used by the Debug_TestEligibility cheat and future UI.)
  • [x] Skill-threshold sweep — Source/CRADL/Quests/QuestComponent.cpp:
  • [x] BeginPlay: bind USkillsComponent::OnLevelUpHandleSkillLevelUp. Authority-only binding (deviation/refinement — the unlock sweep is a server-side write and OnLevelUp fires on authority directly; binding on a client would only spawn redundant Server_UnlockQuest RPCs, and clients receive unlocks via COND_OwnerOnly replication). Verified safe at load: USkillsComponent::ApplySnapshot (the authority load path) broadcasts only OnXPChanged, never OnLevelUp (the OnLevelUp broadcast lives in the client OnRep_Skills), so restoring a save never triggers a spurious mid-load sweep before quest state is applied.
  • [x] HandleSkillLevelUp(FGameplayTag, int32): payload unused; delegates to the shared RunUnlockSweep().
  • [x] RunUnlockSweep() (shared): iterate UQuestRegistry::GetAllQuestIds; for each FName not already tracked (IsTracked), call Requirements.IsSatisfiedBy(PS); if true, UnlockQuest_Authority(QuestId).
  • [x] Dedupe at write site per QUEST_SYSTEM.md "Lifecycle" FootgunsUnlockQuest_Authority rechecks IsTracked + IsKnownQuest, so the sweep never appends a duplicate or re-fires OnQuestUnlocked for the same FName.
  • [x] Prerequisite-completion sweep — Source/CRADL/Quests/QuestComponent.cpp:
  • [x] At the tail of the real-completion path in CompleteQuest_Authority (after the CompletedQuests.Add + broadcast): call the shared RunUnlockSweep(). The already-completed early-out never reaches it, so completion can't re-fire the sweep.
  • [x] Defensive: don't reseed on every tick per feedback_no_speculative_defense_in_depth — unlock is one-shot per quest (IsTracked skip). The sweep is event-triggered (level-up / completion), not periodic.
  • [x] Cheat addition — Source/CRADL/Player/CradlPlayerController.cpp:
  • [x] Debug_TestEligibility(FName QuestId) — resolves the definition via UQuestRegistry and logs the IsSatisfiedBy result plus the per-axis requirement counts for the named quest.
  • [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
  • [x] Requirements.Skills resolve via CradlValidationHelpers::CollectKnownSkillTags (invalid tag and dangling tag are distinct failures).
  • [x] Requirements.PrerequisiteQuests — best-effort cross-asset resolution via new CradlValidationHelpers::CollectKnownQuestIds() (reads QuestDefinition primary-asset metadata only — FAssetData::AssetName is the QuestId — so no asset load). Also rejects a self-prerequisite (a quest naming itself, which would deadlock its own unlock) and a None entry. Per QUEST_SYSTEM.md "Validator" Footguns, runtime registry filtering catches any orphan the validator misses. This is the canonical "PrerequisiteQuests scan pattern" Phase 3 mirrors for loadout/modifier asset resolution.
  • [x] Requirements.RequiredItems resolve to the items DataTable via CradlValidationHelpers::CollectKnownItemIds (gated on bItemsLoaded); Count >= 1 enforced defensively alongside the ClampMin meta.

Verification.

  • Author TestEligibility quest with Requirements.Skills = [{Cooking, 10}].
  • Cheat: grant Cooking XP until level 9. No unlock. Reach level 10 via XP grant. Observe OnQuestUnlocked fires exactly once; quest appears in UnlockedQuests; replicated owner-only.
  • Author QuestB with PrerequisiteQuests = [QuestA]. Complete QuestA via cheats. Observe QuestB unlocks during CompleteQuest's sweep.
  • Cheat: complete QuestA again (force re-add to CompletedQuests — should be a no-op since dedupe is at write site). Observe QuestB does not re-unlock.

Exits. Phase 5 can rely on eligibility-driven unlocks for cheat-built scenarios. Phase 6 surfaces eligibility to the quest log UI (deferred to UI doc).


Phase 3 — Reward Pipeline & Manifest Reconciliation

Goal. Quest completion grants the rewards in the definition's Rewards[], records what landed in FQuestRewardManifest, and reconciles-by-diff once per game startup (latched on UCradlSaveSubsystem). Item overflow walks a bag → bank → persistent pending-queue ladder with a localized toast on the deferred case. Edit a quest's rewards in development and players who already finished the quest get the delta on next session.

Rationale. Per QUEST_SYSTEM.md "Reward Pipeline", QUEST_SYSTEM.md "Overflow Ladder", and QUEST_SYSTEM.md "Reconciliation" — the manifest is the user's stated requirement-defining feature ("quest x now grants 2 spells → player needs those"). The startup-latch instead of every-load avoids re-running the diff on map travel; the bag/bank/pending ladder mirrors the existing cold-storage resurrection pattern so quest items survive bag-full edge cases through a save/load cycle instead of dropping to ground. Without this phase, completion is a no-op for player state and the contract's reconciliation guarantee is unmet.

Tasks.

  • [x] UQuestComponent::DeliverReward(const FQuestReward& Reward, FName SourceQuestId)Source/CRADL/Quests/QuestComponent.cpp:
  • [x] Server-only; check(IsAuthority()) (matches the doc's "asserts on authority"; reached only from the authority-guarded CompleteQuest_Authority / ReconcilePersistedRewards).
  • [x] Dispatch by variant (flat-struct switch (Reward.RewardType)):
    • [x] SkillXpPS->GetSkillsComponent()->GrantXP(SkillTag, XpAmount) (int64 amount).
    • [x] Item → run the overflow ladder via the shared SeatItemViaLadder(ItemId, Count) helper (Bag->AddItemsBank->AddItems, both returning leftover); the leftover is pushed onto PendingRewards and ToastDeferred fires. The manifest entry records the originally-requested Count (matches the definition), not the actually-seated amount — manifest is "what the def said to grant," pending queue is "what's still owed." (Refinement: the bag→bank ladder is factored into a private SeatItemViaLadder so DeliverReward's Item path and the pending-drain in ReconcilePersistedRewards share one implementation.)
    • [x] SpellbookUnlockPS->GetSpellbookComponent()->UnlockBook(BookTag).
    • [x] Loadout (new)PS->GetLoadoutComponent()->GrantLoadoutId(LoadoutAssetId) per Source/CRADL/Loadout/LoadoutComponent.h:61. Idempotent at the API; manifest entry stores the granted FPrimaryAssetId.
    • [x] LoadoutModifier (new) → resolve the def first via the static ULoadoutModifierComponent::ResolveDefinition(ModifierAssetId), then FGuid Granted = PS->GetLoadoutModifierComponent()->GrantInstance(Def, TMap<FGameplayTag,float>()). Capture Granted into the manifest entry's GrantedInstanceId. Empty rolled magnitudes per Phase-1 modifier behavior; Phase-2-modifier-rolling integration is a follow-up. (Accessor is GetLoadoutModifierComponent(), not the doc's earlier GetModifierComponent(). A modifier asset that fails to resolve logs a warning and records the manifest entry with no minted instance — the diff still keys on ModifierAssetId, so a later startup with the asset present grants it.)
  • [x] Returns a FQuestRewardManifestEntry describing what was applied (for LoadoutModifier, the entry carries the minted FGuid).
  • [x] UQuestComponent::ToastDeferred(FName ItemId, int32 Count) — helper:
  • [x] Server-only. Resolves the owning ASC via PS->GetCradlASC() and calls ASC->PostClientMessage(CradlTags::Message_Source_Quest, ECradlMessageLevel::Warning, FText, CradlTags::Quest_Reason_OverflowDeferred).
  • [x] Toast text is NSLOCTEXT-wrapped ("Cradl"/"Quest_OverflowDeferred"), matching the contract: "You have overflow items and your bank is full. Items will be awarded on next login once there is requisite space." ItemId/Count params are accepted for a future name-interpolated variant; v1 sends the fixed string.
  • [x] Routes via the canonical server-to-owning-client pattern in Source/CRADL/Abilities/CradlAbilitySystemComponent.cpp:121-134 (same PostClientMessage path the cold-storage load notices use).
  • [x] CompleteQuest extension — Source/CRADL/Quests/QuestComponent.cpp:
  • [x] In CompleteQuest_Authority, fetch the definition; iterate Rewards[]; call DeliverReward(Reward, QuestId) for each; append each returned entry to FCompletedQuest::Manifest. A missing definition leaves the manifest empty (the player still completes; a later startup with the asset restored back-fills via the diff).
  • [x] Startup-latched reconciliation — Source/CRADL/SaveGame/CradlSaveSubsystem.h + .cpp:
  • [x] Add mutable TSet<FString> ReconciledSlotsThisSession; on UCradlSaveSubsystem (mutable so the const LoadPlayer can record the slot).
  • [x] Add void ReconcileQuestRewardsForPlayer(ACradlPlayerState* PS, const FString& SaveSlot) const; private helper.
  • [x] In LoadPlayer, after the quest snapshot is applied (load order step 8 per QUEST_SYSTEM.md "Persistence"): call ReconcileQuestRewardsForPlayer(PS, FString(SlotName)). The helper itself owns the latch check + insert (record-before-call, so a re-entrant load can't loop).
  • [x] Diff/drain LOGIC lives on the component, not the subsystem helper (deviation). The subsystem helper is a thin latch wrapper that calls UQuestComponent::ReconcilePersistedRewards(). Why: the diff calls DeliverReward (a component method that reaches the player's other components via the owning PlayerState) and mutates the component-owned CompletedQuests manifest + PendingRewards. Putting the body in the subsystem would force exposing DeliverReward, mutable manifest access, and the overflow ladder publicly — leaking component internals. The subsystem keeps what it owns (the once-per-slot gate, which must outlive any component lifetime); the component keeps what it owns (reward delivery + its own state). The contract's "ReconcileQuestRewardsForPlayer runs the diff" wording is reconciled to "gates + delegates the diff" — see QUEST_SYSTEM.md "Reconciliation".
  • [x] UQuestComponent::ReconcilePersistedRewards() body (server-only):
    • For each FCompletedQuest, fetch the current UQuestDefinition::Rewards. For each reward, find its manifest entry by identity (QuestRewardIdentityMatches): no match → DeliverReward (with SourceQuestId = QuestId) and append; match with a positive value delta (XP / Item) → grant the delta and raise the manifest value (never lower it, so a reduced-then-raised def can't re-grant); match on a binary variant (Spellbook / Loadout / Modifier) → no-op. Entries in the manifest but absent from Rewards are left alone (conservative).
    • Drain PendingRewards via SeatItemViaLadder: for each pending entry (reverse iteration), re-attempt bag→bank; fully-seated entries are removed, partials shrink to the leftover and re-toast once each (per QUEST_SYSTEM.md "Reconciliation" Footguns).
  • [x] Don't latch on UQuestComponent. PlayerState components fire BeginPlay on map travel even though the PlayerState itself persists — a component-local latch would re-fire reconciliation on every map load. The subsystem-level latch survives travel and resets only on GameInstance teardown (PIE Stop/Play, standalone restart, app relaunch). Per QUEST_SYSTEM.md "Reconciliation" Footguns.
  • [x] UQuestComponent::ApplyPersistedState — no reward diff here:
  • [x] Restore Unlocked / Active / Completed / Pending lists with IsKnownQuest filtering (already in Phase 1).
  • [x] Do not run the manifest diff or pending drain inside ApplyPersistedState. The save subsystem owns the once-per-startup gate; the component is a passive restore target for the reward pipeline. Reasoning: the gate has to outlive any single component lifetime. (The active-quest definition-drift drop — see below — DOES live in ApplyPersistedState: it's a per-restore integrity check touching only this component's own ActiveQuests, the same nature as the orphan-FName filter already there, and not a cross-component reward operation.)
  • [x] Value-equality helper — Source/CRADL/Quests/QuestPersistentTypes.h: (Phase-1 reconciliation: FQuestRewardManifestEntry landed in QuestPersistentTypes.h, which includes QuestReward.h. The helper must live with the entry type — QuestReward.h cannot reference FQuestRewardManifestEntry without an include cycle.)
  • [x] inline bool QuestRewardIdentityMatches(const FQuestRewardManifestEntry&, const FQuestReward&) — an identity match (variant + identity key), NOT a full value-equality, so the caller can find the corresponding entry and then compute the value delta itself. (Refinement over the doc's operator==/IsSameReward: the diff needs "same axis," with the value treated separately, so a same-axis-different-value pair is found, not rejected.) Keyed by:
    • SkillXp: SkillTag matches; XpAmount is the value-with-delta axis (handled by the caller).
    • Item: ItemId matches; Count is the value-with-delta axis (handled by the caller).
    • SpellbookUnlock: BookTag matches; no value axis.
    • Loadout: LoadoutAssetId matches; no value axis.
    • LoadoutModifier: ModifierAssetId matches; GrantedInstanceId is not part of the diff key — it's bookkeeping the manifest carries forward so future revoke paths have a target.
  • [x] Listen-server delegate broadcastCompleteQuest_Authority directly broadcasts OnQuestCompleted after manifest population (host won't get OnRep). Reconciliation manifest-only mutations do not re-broadcast: OnRep_CompletedQuests only fires OnQuestCompleted for quests newly added to the list, so a manifest delta on an already-present quest stays silent (matches "reconciliation grants are silent").
  • [x] Active-quest definition drift — RESOLVED (Open Question #1): DROP the quest. Decision (user, 2026-05-20): a saved Active quest whose CurrentTaskIndex is no longer a valid index into the current definition's Tasks is popped from ActiveQuests entirely — not completed-with-empty-manifest (the original v1 sketch), not clamp-and-continue. Rationale: the quest can't be resumed coherently, and fabricating a completion would also muddy reward reconciliation (an empty manifest would make the diff grant everything; a full no-grant manifest is the only way to make "completed, no rewards" stick — both are murkier than simply dropping). Dropping leaves the quest untracked, so the normal unlock sweep (level-up / prerequisite completion) can re-offer it if its requirements still hold. Implemented in ApplyPersistedState's active-quest restore loop (range check !Def->Tasks.IsValidIndex(CurrentTaskIndex) + warning log), co-located with the orphan-FName filter. Contract reconciled — see QUEST_SYSTEM.md "Open Questions" #1 and the Reconciliation drift footgun.
  • [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
  • [x] Per-reward variant well-formed: the RewardType enum selects the variant; validates the selected variant's required field(s) are populated and — defensively — that fields belonging to other variants are at their defaults (the flat-struct analog of "exactly one variant set"; note Count's default is 1, not 0).
  • [x] SkillXp.SkillTag resolves via CradlValidationHelpers::CollectKnownSkillTags; XpAmount > 0 enforced defensively.
  • [x] Item.ItemId resolves to the items DataTable (gated on bItemsLoaded); Count >= 1.
  • [x] SpellbookUnlock.BookTag resolves to a known USpellbookDefinition asset. (Deviation: scans SpellbookDefinition primary-asset data at validation time — USpellbookRegistry is a UGameInstanceSubsystem and isn't built during editor validation, so the doc's "known book in USpellbookRegistry" is reconciled to the asset scan.)
  • [x] Loadout.LoadoutAssetId resolves to a known ULoadoutDefinition asset via best-effort AssetRegistry scan (no ULoadoutRegistry exists; mirrors the cross-asset PrerequisiteQuests scan — builds FPrimaryAssetId("LoadoutDefinition", AssetName) from metadata without loading).
  • [x] LoadoutModifier.ModifierAssetId resolves to a known ULoadoutModifierDefinition asset via the same best-effort scan.
  • [x] Collectors kept file-local (anonymous namespace in the validator .cpp), NOT added to CradlValidationHelpers — only this validator needs them today (per the doc's own "consider a shared helper if more than one validator ends up needing it"; it doesn't yet, so the shared API isn't grown speculatively).
  • [x] Reject duplicate-grant paths per QUEST_SYSTEM.md "Validator" Footguns — one path per reward axis (two rewards on the same identity would make reconciliation's diff ambiguous / leave a phantom manifest entry).

P2P replication audit (per feedback_p2p_replication_audit): Phase 3 adds no new replicated fields.

Server-mutated state Replication Reason
FCompletedQuest::Manifest (filled on completion + diff-grown on reconcile) Rides the existing CompletedQuests COND_OwnerOnly rep notify (Phase 1) — no separate field. Manifest is owner-private; an in-place element mutation on the (comparison-replicated, non-push) CompletedQuests array is detected and replicated. Manifest-only changes don't re-broadcast OnQuestCompleted (the OnRep diff only fires for newly-added quests), so reconciliation grants stay silent.
UQuestComponent::PendingRewards (written by DeliverReward overflow + drained on reconcile) Not replicated — server-only knowledge (Phase-1 decision, unchanged). Per-player private; the owning client learns of a deferred grant via the PostClientMessage toast, not state replication. Round-trips through the profile.
Reward grants (GrantXP / AddItems / UnlockBook / GrantLoadoutId / GrantInstance) Each target component owns its own replication (unchanged). DeliverReward is a server-only orchestrator calling already-authority-only, replication-aware grant APIs — no new net surface.

Verification.

  • Author a quest with one of each reward type: SkillXp { Cooking, 500 }, Item { Bronze_Bar, 5 }, SpellbookUnlock { Spellbook.Standard }, Loadout { LoadoutDefinition:Frigate }, LoadoutModifier { LoadoutModifierDefinition:RookiePilot }.
  • Cheat: drive the quest to completion. Observe Cooking gains 500 XP, inventory has +5 Bronze Bars, Standard spellbook is unlocked, UnlockedLoadouts contains the Frigate id, ULoadoutModifierComponent::Unlocked contains a new instance with the RookiePilot def id. Manifest has five entries; the modifier entry carries the minted FGuid.
  • Save → close PIE → reopen PIE → load. Observe no double-grant of the modifier (manifest's ModifierAssetId matches the def; diff says no-op). Observe no double-Loadout-grant (idempotent at the API plus manifest equality).
  • Edit the definition: change Cooking XP to 800; change Bronze Bars count to 10; add a new SpellbookUnlock { Spellbook.Ancient }; add a second LoadoutModifier { LoadoutModifierDefinition:VeteranPilot }. Save the asset.
  • Restart PIE (fresh GameInstance → fresh latch). Load the save. Observe Cooking gains +300 (delta), inventory gains +5 more Bronze Bars (delta), Ancient spellbook unlocks, a new Veteran instance is minted with a fresh FGuid. Manifest now has seven entries.
  • Edit again: remove the Bronze Bars reward entirely. Restart PIE → load. Observe nothing is removed from the player; manifest still has the old Bronze entry.
  • Travel-doesn't-re-reconcile: load save → trigger Open Level <other-map> (or whatever map-travel cheat exists). Observe reconciliation does NOT re-run (the latch on UCradlSaveSubsystem survives map travel). No double-grant.
  • Bag-full → bank → pending toast: fill bag → empty bank → complete a quest with a 5-item reward → observe bank receives 5; no toast.
  • Bag-full + bank-full → pending: fill bag → fill bank → complete a quest with a 5-item reward → observe PendingRewards has the entry; observe the localized overflow toast lands on the owning client (visible in the message log UI / per the existing UCradlMessageLogSubsystem surface). Save → exit PIE.
  • Pending drain on restart: empty the bag/bank externally → restart PIE → load. Observe PendingRewards drains (items land in inventory); no toast fires for entries that successfully seated.
  • Pending re-toast on still-full: with PendingRewards populated and bag/bank still full, restart PIE → load. Observe the toast re-fires.
  • Active-quest drift drop (OQ #1): seed/start a multi-task quest, advance to (say) task index 2, save → exit PIE. In the editor, delete tasks from the definition so Tasks.Num() ≤ 2 (the saved CurrentTaskIndex is now out of range). Restart PIE → load. Observe the quest is dropped from ActiveQuests (a LogCradlQuestState warning names it), it is not in CompletedQuests, and no rewards fire. If its requirements still hold, observe it can re-unlock via a subsequent sweep (e.g. a relevant level-up).

Exits. Phase 5 sees full lifecycle (counter → advance → complete → rewards). Phase 6 turn-in flow completes cleanly with rewards.


Phase 4 — Gameplay Event Surfaces

Goal. The new event channels that quest tasks subscribe to are firing on the correct ASC with the correct payload. Quest component is not yet a subscriber — this phase is producer-side only. After this phase, log filters or temporary debug subscribers observe Event.Skill.Succeeded (with UGatheredItemPayload / URecipeDefinition* as OptionalObject), the brand-new Combat.Event.Kill, and the brand-new generic Event.Interact.

Rationale. Per QUEST_SYSTEM.md "Task Type Surfaces": the existing gather/craft success events do not currently carry the rolled item or recipe identity (only the source actor); the Eliminate task needs a new Combat.Event.Kill event because the existing Combat.Event.Death fires on the victim, not the attacker; and the Interact task needs a generic Event.Interact channel fired centrally from UInteractionComponent::DispatchContextAction for any bDefault action whose source IInteractable returns a valid GetInteractableTag() (per QUEST_SYSTEM.md "Interact Task", one identity surface + one event channel — never a per-NPC bespoke tag). All four changes are additive to existing fire-sites; nothing else in the codebase reads the new payload/channel yet, so this phase is safe to land independently.

The contract intentionally shares Combat.Event.Kill with future Slayer work per QUEST_SYSTEM.md "Eliminate Task" Slayer co-existence, and Event.Interact is the canonical interactable-fired channel that any future system (dialog, ambient prompts, tutorial gates) can subscribe to. Land both correctly the first time.

Tasks.

  • [x] New tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h + .cpp:
  • [x] Combat.Event.Kill.ini declaration with DevComment and C++ symbol CradlTags::Combat_Event_Kill (defined via UE_DEFINE_GAMEPLAY_TAG, mirroring the sibling Combat.Event.* tags which are declared in both .ini and .cpp). Referenced by name from UEnemyDeathAbility (and UQuestComponent in Phase 5).
  • [x] Event.Interact.ini declaration with DevComment and C++ symbol CradlTags::Event_Interact. Referenced by name from UInteractionComponent (and UQuestComponent in Phase 6).
  • [x] IInteractable::GetInteractableTag() virtual — Source/CRADL/Interaction/InteractableInterface.h:
  • [x] Added virtual FGameplayTag GetInteractableTag() const { return FGameplayTag(); }. Default invalid = "not quest-trackable"; implementers override to return a stable Interactable.* tag.
  • [x] No existing implementer (AStoreTerminal, ABankTerminal, ALoadoutTerminal, AGatheringNode, ACraftingStation) opts in this phase — they all keep the default invalid return. AQuestGiverActor is the first opt-in, landing in Phase 6.
  • [x] UInteractionComponent::DispatchContextAction extension — Source/CRADL/Interaction/InteractionComponent.cpp:
  • [x] After the existing fire of the action's own Payload.EventTag event (capturing its result into bHandled), additionally: if Action.bDefault == true and Cast<IInteractable>(Action.SourceActor.Get()) returns a valid GetInteractableTag(), fire a second FGameplayEventData on the same ASC via ASC->HandleGameplayEvent(Event.Interact, ...) with Target = source actor, InstigatorTags = { interactable tag }, then return bHandled. Mirrors the action's own fire path exactly (HandleGameplayEvent, not FireReplicatedGameplayEvent) per the contract's "same fire path, same replication" replication-audit note.
  • [x] The action's own event still fires unconditionally — Event.Interact is a secondary fan-out for the quest-subscription surface; it does not replace per-action verb routing. Non-default actions (TurnIn, Examine, Prospect) do not double-fire as Event.Interact per QUEST_SYSTEM.md "Interact Task" Footguns.
  • [x] Per QUEST_SYSTEM.md "Interact Task" Why-the-channel-publishes-from-the-dispatch-site: the bridge sits on the boundary, not on each implementer. Authors don't have to remember to fire Event.Interact from their own IInteractable impls — opting in is one method override + one tag.
  • [x] Typed-payload FireReplicatedGameplayEvent overload — Source/CRADL/Abilities/CradlAbilitySystemComponent.h + .cpp:
  • [x] Branching decision (deviation from the doc's literal single-tag signature) — overload takes a tag container + a const UObject* payload: FireReplicatedGameplayEvent(FGameplayTag EventTag, const FGameplayTagContainer& InstigatorTags, AActor* Target, const UObject* OptionalObject). Why: the doc prescribed (…, FGameplayTag InstigatorTag, AActor*, UObject*) for gather/craft, then for the kill said the victim's classification tags need to land in InstigatorTags "via a small extension to the call shape, or via a pre-fire FGameplayEventData assembly path — pick the cleaner of the two." A single container-taking overload serves all three Phase-4 fire-sites (gather/craft pass a one-tag container FGameplayTagContainer(Action.Gathering/Crafting); kill passes the full ICombatStatsProvider classification container, which is designed to grow past today's single Enemy.Family.* tag) — cleaner than a single-tag overload plus a bespoke manual-assembly path for the kill. OptionalObject is const because FGameplayEventData::OptionalObject is itself const UObject* (read-only payload); a one-line const_cast at the RPC boundary feeds the non-const UFUNCTION object param. Contract reconciled — see QUEST_SYSTEM.md "Eliminate Task" Implementation surface and Gather Task.
  • [x] Backed by a new NetMulticast_HandleGameplayEventObject RPC; on authority it multicasts (the body runs locally on the server once → server-side subscribers in Phase 5/6 read the payload), off-authority it does the same local-only fanout as the sibling overloads. The existing 3-arg / 4-arg-magnitude overloads stay unchanged for their callers.
  • [x] Transient payloads are server-local. A UGatheredItemPayload (non-replicated) resolves on the authority's local multicast execution but arrives null on remote peers — fine, since cosmetic peer listeners (UCradlVisualStateComponent) filter on the InstigatorTags[0] action tag, not the object. Verified UCradlVisualStateComponent::HandleSkillRollSucceededEvent reads only InstigatorTags/Target, so the gather/craft switch is regression-free.
  • [x] UGatheredItemPayload — new leaf header Source/CRADL/Abilities/GatherPayload.h:
  • [x] Chose the dedicated GatherPayload.h over GatherAbility.h (the doc's sanctioned "if cleaner" option): the Phase-5 quest subscriber includes this leaf header to Cast the payload rather than pulling the full gather-ability header (which drags in SkillStatsResolver, CradlGameplayAbility). Mirrors TransactRequest.h.
  • [x] class UGatheredItemPayload : public UObject with UPROPERTY() FName ItemId; UPROPERTY() int32 Count = 0;. Transient (no replication; the payload lives only on the firing ASC).
  • [x] Per QUEST_SYSTEM.md "Open Questions" #9 — the UObject route is the v1 sketch (kept). If the user later resolves the OQ to a different shape, this struct/UObject gets revisited here.
  • [x] Gather success payload — Source/CRADL/Abilities/GatherAbility.cpp:
  • [x] In the gather-success fire-site inside HandleTickFinished (verified path), construct a transient UGatheredItemPayload (NewObject<UGatheredItemPayload>(this), local named GatheredPayload to avoid the Payload shadow) with the rolled {Drop.ItemId, Count}.
  • [x] Replaced the 3-arg FireReplicatedGameplayEvent call with the container+payload overload, passing the payload as OptionalObject.
  • [x] Craft success payload — Source/CRADL/Abilities/CraftAbility.cpp:
  • [x] In the craft-success fire-site inside HandleDurationFinished, pass the executed Recipe (TObjectPtr<const URecipeDefinition>) directly as OptionalObject (already a UObject; the const UObject* param accepts it without a wrapper).
  • [x] Combat.Event.Kill fire-site — Source/CRADL/Enemy/EnemyDeathAbility.cpp:
  • [x] After UEnemyDropComponent::ResolveAttribution returns, fire directly on the tagger's PlayerState ASC via Attribution.Tagger.Get()->GetCradlASC()not via GetOwningController()->GetPawn(). Branching decision (deviation): in CRADL the ASC lives on ACradlPlayerState (where the quest component also lives and subscribes), so resolving the attacker pawn is unnecessary indirection — the event must land on the PlayerState's ASC to reach the subscriber. FireReplicatedGameplayEvent already sets Instigator = GetOwnerActor() (the PlayerState). Contract reconciled — see QUEST_SYSTEM.md "Eliminate Task" Implementation surface.
  • [x] Called the container+payload overload: FireReplicatedGameplayEvent(Combat.Event.Kill, VictimClassification, /*Target*/ victim Enemy, /*OptionalObject*/ nullptr). The victim's classification tags ride in InstigatorTags (the container overload — see the overload note above).
  • [x] Sourced the victim's classification tags via Cast<ICombatStatsProvider>(Enemy)->GetClassificationTags(OutTags) at fire-time, exactly mirroring FTargetClassificationScope's read (interface cast, not the concrete AEnemyCharacter method, per CLAUDE.md interface-preference).
  • [x] Per QUEST_SYSTEM.md "Eliminate Task" Footguns — attribution can be null (no attacker credit). Guarded by if (Tagger.Get()); no event, no log noise in that case.

P2P replication audit (per feedback_p2p_replication_audit):

New event channel Replication Reason
Combat.Event.Kill FireReplicatedGameplayEvent (multicast) The event already replicates per the existing helper; the subscriber filters on the receiving ASC. Quest state mutation (Phase 5) happens server-only inside the handler.
Event.Interact Match the channel UInteractionComponent::DispatchContextAction already uses for the action's own Payload.EventTag event — same fire path, same replication. The dispatch site is the canonical source for both events. Server is authoritative on context-action dispatch; the secondary Event.Interact rides the same path. Quest state mutation (Phase 6 subscriber) happens server-only inside the handler.
Event.Skill.Succeeded payload extension (existing replication path unchanged) Adding OptionalObject doesn't change the multicast shape. Server-side handlers still authoritative.

Verification.

  • Add a temporary UE_LOG subscriber inside the ASC's GenericGameplayEventCallbacks.FindOrAdd(Combat.Event.Kill) for the local player ASC (delete after this phase). Kill a goblin in PIE. Observe one log line on the attacker's ASC; no log line on the dead goblin's ASC.
  • Multi-attacker scenario: two players damage the same goblin; one finishes the kill (gets attribution per the existing rules). Observe Combat.Event.Kill fires on the attribution-winner's ASC only.
  • Gather a Bronze_Ore from a tin/copper mixed node: observe the gather event fires with OptionalObject containing the rolled item's ID, not the node's ID.
  • Craft a recipe: observe the craft event fires with OptionalObject = the URecipeDefinition*.
  • Add a temporary UE_LOG subscriber for Event.Interact on the local player ASC. Interact with any existing IInteractable (store terminal, bank, gathering node) — observe no Event.Interact log line (none of them override GetInteractableTag() yet; default invalid suppresses the secondary fire). Confirm the original action's own event tag still fires (existing dispatch behavior unchanged).
  • Author a one-off test pawn with IInteractable::GetInteractableTag() overridden to return a debug tag (e.g. Interactable.Debug.Test) and GatherActions returning a single bDefault = true action. Right-click → observe Event.Interact fires on the player ASC with Target = the test pawn, InstigatorTags containing Interactable.Debug.Test. Delete the test pawn after the gate passes — the canonical opt-in (AQuestGiverActor) lands in Phase 6.

Exits. Phase 5 wires UQuestComponent subscribers onto the gather/craft/kill channels; Phase 6 wires it onto Event.Interact and lands the first opt-in implementer.


Phase 5 — Task Type Subscribers (Gather, Craft, Eliminate)

Goal. UQuestComponent subscribes to the three event channels, filters payloads against active-task identity, and drives AdvanceTask on match. Static cheat-driven quest fixtures verify the full lifecycle: unlock → start → progress → advance → complete → rewards.

Rationale. Per QUEST_SYSTEM.md "Task Type Surfaces". Interact and TurnIn (Phase 6) build on the same subscriber pattern but additionally need the quest giver actor; landing Gather/Craft/Eliminate first proves out the subscriber+filter shape without the actor wrinkle. By end of phase, the only thing missing for a full v1 quest catalogue is the NPC actor.

Tasks.

  • [x] Task-type tag C++ symbols (per feedback_gameplay_tag_decl_minimal) — Source/CRADL/CradlGameplayTags.h + .cpp: added Quest_TaskType_Gather, Quest_TaskType_Craft, Quest_TaskType_Eliminate (.ini already declared them in Phase 0; Phase 5 is the first reference by name from runtime C++, which is what the rule keys off). Interact / TurnIn stay .ini-only until Phase 6 adds Quest_TaskType_Interact for HandleInteractEvent. The editor validator continues to resolve all five via FGameplayTag::RequestGameplayTag (string-keyed, symbol-independent) — unchanged.
  • [x] Subscriber registration — Source/CRADL/Quests/QuestComponent.cpp:
  • [x] In BeginPlay (server-side; the existing top-of-function if (!IsAuthority()) return; already gates everything below it, including the level-up bind), register two handlers on the owning ASC's GenericGameplayEventCallbacks (ASC resolved via the new GetOwnerASC() helper → ACradlPlayerState::GetCradlASC()):
    • [x] Event.Skill.SucceededHandleSkillEvent.
    • [x] Combat.Event.KillHandleKillEvent.
    • [x] (Phase 6 will add the Event.Interact subscription for Interact tasks. Action.Trigger.Quest.TurnIn is consumed by UTurnInQuestItemAbility, not subscribed by the component. Action.Trigger.Quest.TalkTo is a menu-verb tag — not a subscription channel.)
  • [x] Unregister on EndPlay (newly overridden): Find (not FindOrAdd) the channel and Remove each stored FDelegateHandle. Handles are members (SkillEventHandle / KillEventHandle).
  • [x] HandleSkillEvent(const FGameplayEventData* EventData)Source/CRADL/Quests/QuestComponent.cpp: (Branching decision — pointer, not reference. The doc's const FGameplayEventData& is reconciled to const FGameplayEventData*: the value type stored in UAbilitySystemComponent::GenericGameplayEventCallbacks is FGameplayEventMulticastDelegate, declared OneParam(const FGameplayEventData*). The pointer signature is mandatory to bind, and matches the canonical subscriber UCradlVisualStateComponent::HandleSkillRollSucceededEvent. Applies to HandleKillEvent here and HandleInteractEvent in Phase 6 — same pointer signature.)
  • [x] For each active quest, inspect Tasks[CurrentTaskIndex].
  • [x] If TaskType == Quest.TaskType.Gather and EventData->InstigatorTags.HasTag(Action.Gathering): cast EventData->OptionalObject to UGatheredItemPayload; if ItemId matches the task's ItemId, advance by Payload->Count.
  • [x] If TaskType == Quest.TaskType.Craft and EventData->InstigatorTags.HasTag(Action.Crafting): cast EventData->OptionalObject to URecipeDefinition; if GetFName() matches the task's RecipeId, advance by 1.
  • [x] Skip silently if cast fails (a payload-less skill event from another source is not a quest concern).
  • [x] HandleKillEvent(const FGameplayEventData* EventData)Source/CRADL/Quests/QuestComponent.cpp:
  • [x] For each active quest where the current task TaskType == Quest.TaskType.Eliminate:
  • [x] EventData->InstigatorTags.HasTag(Task.EnemyFamilyTag) is the filter (hierarchical match — per QUEST_SYSTEM.md "Eliminate Task" Footguns, Enemy.Family.Goblin matches Enemy.Family.Goblin.Warrior).
  • [x] On match: advance by 1.
  • [x] Mid-iteration mutation guard (refinement, not in the original task list). AdvanceTask can complete a quest, which RemoveAts it from ActiveQuests — mutating the array being iterated. Both handlers collect matched (QuestId, TaskIndex, Delta) tuples into a small TInlineAllocator<4> array during the scan, then call AdvanceTask_Authority in a second pass. Because AdvanceTask re-finds by QuestId and re-validates TaskIndex == CurrentTaskIndex, a snapshot index made stale by an earlier advance simply no-ops rather than mis-advancing. Calls AdvanceTask_Authority directly (already on authority — no need to re-route through the public AdvanceTask's RPC check).
  • [x] Multi-active-quest concurrency — multiple active quests may have tasks matching the same event. Both handlers iterate all active quests and collect every match; no early-exit (a player could be on two gather quests for the same item).
  • [x] Cheat command — Source/CRADL/Player/CradlPlayerController.cpp + .h:
  • [x] Debug_SeedQuest(FName QuestId) — bypass requirements, force-promote a quest to ActiveQuests. Implemented as UnlockQuest then StartQuest (no new component API): UnlockQuest_Authority already skips the requirements gate — only RunUnlockSweep checks requirements — so unlock-then-start is the requirement bypass. Both self-route through ordered/reliable Server_* RPCs when off-authority.

P2P replication audit (per feedback_p2p_replication_audit): Phase 5 adds no new replicated fields.

Server-mutated state Replication Reason
ActiveQuests (advanced by the new subscribers via AdvanceTask_Authority) Rides the existing COND_OwnerOnly rep notify (Phase 1) — no new field. The handlers bind authority-only and call the existing authority writer; progress reaches the owner via the Phase-1 OnRep_ActiveQuests diff.
SkillEventHandle / KillEventHandle (FDelegateHandle members) Not replicated — local subscription bookkeeping. Authority-only delegate handles; no game state. Bound in BeginPlay, removed in EndPlay.

Verification.

  • Author TestGather quest: one Gather task {ItemId = "Bronze_Ore", TargetCount = 5}. Debug_SeedQuest TestGather. Gather from a Bronze-Ore node five times. Observe OnQuestProgressChanged fires per gather; on the fifth, the task auto-advances → OnQuestCompleted fires → rewards (Phase 3) deliver.
  • Author TestCraft quest: one Craft task {RecipeId = "BronzeBar_Recipe", TargetCount = 3}. Craft three Bronze Bars. Observe advance + complete + rewards.
  • Author TestKill quest: one Eliminate task {EnemyFamilyTag = "Enemy.Family.Goblin", TargetCount = 4}. Kill four goblins of mixed sub-families (Goblin.Warrior + Goblin.Wizard). Observe hierarchical match — all four count.
  • Negative case: craft an unrelated recipe → no progress. Gather an unrelated item → no progress. Kill a non-goblin (cow) → no progress.
  • Concurrency case: seed two quests, both targeting Bronze_Ore. Gather one → both advance.

Exits. Phase 6 follows the same pattern for Interact / TurnIn.


Phase 6 — Quest Giver Actor & Turn-In Ability

Goal. AQuestGiverActor is placeable in worlds, opts into the generic interactable identity surface via IInteractable::GetInteractableTag() (introduced in Phase 4), and contributes an Action.Trigger.Quest.TurnIn ContextAction when a matching turn-in task is active. UQuestComponent subscribes to the generic Event.Interact channel (also from Phase 4) and matches via the task's InteractableTag. UTurnInQuestItemAbility consumes the items server-side and advances the task. A complete OSRS-shape quest — talk to NPC, gather item, turn in item — runs end-to-end.

Rationale. Per QUEST_SYSTEM.md "Quest Giver Actor", QUEST_SYSTEM.md "Interact Task", and QUEST_SYSTEM.md "TurnIn Task". The Interact + TurnIn surfaces need a placeable, identity-bearing actor; nothing existing supplies it. The two-interface split — IInteractable::GetInteractableTag() for runtime identity (task matching, TurnIn host match) and IQuestGiver::GetOfferedQuests for UI enumeration (chat-head indicators, future dialog "what do you have for me") — was settled in the contract iterate; mirror that exactly. The identity surface is uniform across every IInteractable (Phase 4 already added the virtual); this phase ships the first opt-in implementer.

Tasks.

  • [x] New tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.h:
  • [x] Action.Trigger.Quest.TalkTo.ini + C++ symbol. Menu-verb tag only: used by AQuestGiverActor's default ContextAction for label routing and future dialog-ability binding. The quest component does not subscribe to this tag — it subscribes to Event.Interact per Phase 4. Per QUEST_SYSTEM.md "Tag Taxonomy".
  • [x] Action.Trigger.Quest.TurnIn.ini + C++ symbol (referenced by AQuestGiverActor::GatherActions and UTurnInQuestItemAbility). 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 with item-consumption semantics.
  • [x] Interactable root + Interactable.NPC subspace added (.ini only; not C++). Per-giver leaves (Interactable.NPC.<NpcName>) land as content does. The Interactable.* namespace is the generic identity surface for any IInteractable (per QUEST_SYSTEM.md "Tag Taxonomy"); Interactable.NPC.* is the conventional subspace for NPC quest givers.
  • [x] Quest_TaskType_Interact C++ symbol — Source/CRADL/CradlGameplayTags.h + .cpp: the .ini leaf exists from Phase 0; HandleInteractEvent is the first runtime reference by name (Phase 5 added the Gather/Craft/Eliminate siblings the same way).
  • [x] Quest_TaskType_TurnIn C++ symbol added too (deviation from "TurnIn stays .ini-only"). Why: the Phase-6 UQuestRegistry reverse-index must filter Interact/TurnIn tasks, which references the TurnIn task-type tag by name from runtime C++ — exactly the trigger feedback_gameplay_tag_decl_minimal keys on. A string-keyed RequestGameplayTag in the registry's hot build path would be the alternative; the symbol is cleaner. (The component still never matches TurnIn by name for progressionUTurnInQuestItemAbility owns turn-in via the separate Action.Trigger.Quest.TurnIn action channel.)
  • [x] IQuestGiver body — Source/CRADL/Quests/QuestGiverInterface.h:
  • [x] The header was scaffolded in Phase 1 with virtual void GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const = 0;. Unchanged — AQuestGiverActor is the first implementer.
  • [x] UQuestRegistry reverse-index — Source/CRADL/Quests/QuestRegistry.h + .cpp:
  • [x] In RegisterDefinition (reached from EnsureBuilt per loaded def), walk the definition's Tasks[].InteractableTag and (for Interact/TurnIn tasks with a valid tag) populate TMap<FGameplayTag, TArray<FName>> QuestsByInteractableTag with AddUnique (per-quest dedupe). Cleared in Deinitialize alongside DefinitionsByName.
  • [x] New accessor: const TArray<FName>& GetQuestsByInteractableTag(FGameplayTag InteractableTag) const; Returns a static const TArray<FName> empty sentinel on miss.
  • [x] Per QUEST_SYSTEM.md "Quest Giver Actor" — this index is the membership half of IQuestGiver::GetOfferedQuests; per-player lifecycle filtering is the second half (lives on the actor's impl, not on the registry). Index is exact-tag-keyed (the runtime HandleInteractEvent match is hierarchical; the index/enumeration is exact — the common case is giver-tag == task-tag, and exact avoids a hierarchical registry walk for a UI-only seam with no v1 consumer).
  • [x] AQuestGiverActorSource/CRADL/Quests/QuestGiverActor.h + .cpp (new):
  • [x] class AQuestGiverActor : public AActor, public IInteractable, public IQuestGiver.
  • [x] UPROPERTY(EditAnywhere, meta=(Categories="Interactable")) FGameplayTag InteractableTag; — author-time picker-constrained to the Interactable.* namespace (per QUEST_SYSTEM.md "Quest Giver Actor"). A guarded BeginPlay warning fires (non-shipping) if the placed instance left it empty/wrong-namespace — see the validator deviation note below.
  • [x] bReplicates = false — level-placed content actor; no server-mutated state.
  • [x] IInteractable::GetInteractableTag() returns the InteractableTag field. (This is the runtime identity surface — used by Event.Interact matching and by TurnIn host matching.)
  • [x] IInteractable::GetPrimaryActionTag() returns Action.Trigger.Quest.TalkTo (menu-verb tag for label routing — not the quest subscription channel).
  • [x] IInteractable::GetSourceLabel() returns a display name (per-instance FText DisplayName UPROPERTY); GetInteractLabel() returns "Talk to". Also overrides CanInteract with an InteractionDistanceSquared range gate (parity with AStoreTerminal — a quest giver should be reachable only when beside it; not in the doc's literal list but the established interactable pattern).
  • [x] IInteractable::BeginInteract(APawn* Pawn)fires the Action.Trigger.Quest.TalkTo event (mirroring AStoreTerminal's primary-verb fire). This is the raw Input_Interact-key path; the primary left-click / queued-interact path funnels through DispatchContextAction, which fires both the TalkTo event and the secondary Event.Interact that drives Interact-task progression. Firing TalkTo here is the future dialog-ability hook (no v1 ability is bound, so it is a harmless no-op today). Quest Interact tracking deliberately rides the dispatch funnel, not this path.
  • [x] IInteractable::GatherActions(APawn* Pawn, TArray<FContextAction>& Out):
    • [x] Always append a TalkTo FContextAction with ActionTag = Action.Trigger.Quest.TalkTo, bDefault = true, SourceActor = this. (The bDefault = true flag is what licenses the dispatch site to fire the secondary Event.Interact from Phase 4 — this is the contract glue.)
    • [x] Read the local pawn's UQuestComponent::GetActiveQuests(); for the first active quest whose current task is TurnIn, task.InteractableTag == this->GetInteractableTag() (exact), and Inventory.CountOf(task.ItemId) >= task.ItemCount, append a single TurnIn FContextAction (ActionTag = Action.Trigger.Quest.TurnIn, SourceActor = this). Branching decision (deviation): no per-task payload object, single entry, server re-derives — see the Phase 6 deviation note below.
  • [x] IQuestGiver::GetOfferedQuests(const ACradlPlayerState* Player, TArray<FName>& OutQuestIds) const:
    • [x] Call UQuestRegistry::GetQuestsByInteractableTag(InteractableTag) for the membership set.
    • [x] Filter each candidate by per-player lifecycle state via Player->GetQuestComponent() — drop completed (over-surface footgun); for an active quest include only if the current task is an Interact/TurnIn at this giver (advanceable/turn-innable here); for an untracked/unlocked quest include only if the chain begins here (Tasks[0] is an Interact/TurnIn at this giver) and the player is unlocked-or-eligible (Requirements.IsSatisfiedBy(Player)). Per QUEST_SYSTEM.md "Quest Giver Actor" Footguns.
    • [x] Callers decide which subset to surface. No UI lands in v1 — the method exists so the seam is callable. (Membership is exact-tag; see the registry note above.)
  • [x] UTurnInQuestItemAbilitySource/CRADL/Abilities/TurnInQuestItemAbility.h + .cpp (new):
  • [x] Net policy: LocalPredicted per the modal/transact pattern in UTransactItemAbility. Mutation is guarded to authority (ActorInfo->IsNetAuthority()): the client predicts activation (responsive menu dismissal) but does not mutate, because UQuestComponent::AdvanceTask self-routes through a Server_ RPC off-authority and a predicted client call would double-advance on the server. (Deviation from "predict the mutation like Transact" — Transact's Inv->RemoveItems is predict-safe because it doesn't RPC; AdvanceTask does.)
  • [x] Server execution: re-validate by re-deriving the target task from EventData->Target (the host IInteractable::GetInteractableTag()) + the player's active TurnIn tasks + inventory — first satisfiable TurnIn task hosted here wins (matches the single entry GatherActions surfaced). No carried payload object. Mirrors the server-side re-check spirit of UTransactItemAbility.
  • [x] On valid: UInventoryComponent::RemoveItems(ItemId, ItemCount)UQuestComponent::AdvanceTask(QuestId, TaskIndex, ItemCount).
  • Content step (not C++): the ability class must be added to ACradlPlayerState::DefaultAbilities (BP-authored list) so it is granted and the Action.Trigger.Quest.TurnIn GameplayEvent trigger resolves — same as UTransactItemAbility/UGatherAbility.
  • [x] Subscriber wiring — Source/CRADL/Quests/QuestComponent.cpp:
  • [x] On BeginPlay (server-side; the existing if (!IsAuthority()) return; already gates the binds), register one new handler on the owning ASC's GenericGameplayEventCallbacks: Event.InteractHandleInteractEvent. Added a third FDelegateHandle InteractEventHandle member, removed in EndPlay alongside the Phase-5 handles. (Action.Trigger.Quest.TurnIn is consumed by the ability — the component does not subscribe; the counter advances inside the ability.)
  • [x] HandleInteractEvent(const FGameplayEventData* EventData) (pointer signature, mirroring HandleSkillEvent/HandleKillEvent, including the collect-then-advance mid-iteration guard): read the interactable tag(s) from EventData->InstigatorTags (do not cast EventData->Target to IQuestGiver). For each active quest whose current task is Quest.TaskType.Interact, hierarchical-match the task's InteractableTag against the event's instigator tags (HasTagInteractable.NPC task matches an Interactable.NPC.Cook giver, mirroring Enemy.Family.*); on match, AdvanceTask(QuestId, CurrentTaskIndex, 1).
  • [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
  • [x] Interact/TurnIn task InteractableTag non-empty and under the Interactable.* namespace (shared ValidateInteractableTag lambda; item-id collection hoisted above the task loop and reused).
  • [x] AQuestGiverActor tag check is a runtime BeginPlay warning, NOT an editor validator (deviation). Why: UEditorValidatorBase validates assets (DataAssets/Blueprints), but InteractableTag is an EditAnywhere per-placed-instance value — a BP-CDO validator wouldn't see the per-instance value designers actually set, and EditorValidator doesn't validate placed level actors. The meta=(Categories="Interactable") picker constrains authoring to the namespace; a left-default placed instance is caught by the guarded (#if !UE_BUILD_SHIPPING) AQuestGiverActor::BeginPlay warning. On other IInteractable implementers the tag remains optional (default invalid = "not quest-trackable"). A formal Map-Check (CheckForErrors) validator is a possible future hardening.
  • [x] TurnIn ItemId resolves to the items DataTable (gated on bItemsLoaded); ItemCount >= 1.

Footguns.

  • Don't subscribe to Action.Trigger.Quest.TalkTo from the quest component. That's a menu-verb tag (label routing + future dialog binding). The quest subscription channel is Event.Interact, which the central dispatch fires automatically alongside any bDefault action whose source has a valid GetInteractableTag(). Per QUEST_SYSTEM.md "Interact Task" Footguns.
  • Don't reach for Action.Trigger.Modal.QuestDialog. Dialog system is out of v1 scope per QUEST_SYSTEM.md "Quest Giver Actor" Footguns. Interact tasks observe via Event.Interact and that's all.
  • 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 Event.Interact. Per QUEST_SYSTEM.md "Interact Task" Footguns.
  • GatherActions runs on the local pawn. Server re-validation lives inside UTurnInQuestItemAbility. The action only appears in the UI if the local pawn observes the matching active task (per QUEST_SYSTEM.md "Quest Giver Actor" Footguns).
  • TurnIn payload — branching decision: server re-derives, no payload object, single first-match entry. The doc/contract sketch carried {QuestId, TaskIndex, ItemId, ItemCount} in the TurnIn action's payload. That isn't feasible through the standard context dispatch: for a SourceActor action the dispatch sets OptionalObject = IInteractable::GetActionPayload(ActionTag), and GetActionPayload is keyed on the ActionTag alone and receives no pawn — so it can't build a per-task request, and caching pawn-specific state on a shared (bReplicates=false, level-shared on a listen server) actor is a P2P footgun. Instead: GatherActions surfaces a single TurnIn entry (first satisfiable turn-in task at this giver), and UTurnInQuestItemAbility re-derives the same first-match server-side from EventData->Target's GetInteractableTag() + the player's active TurnIn tasks + inventory. This is the contract-required server re-validation, and it sidesteps the no-pawn problem. Known v1 limitation: two distinct active TurnIn tasks at the same giver advance the first (the rare multi-quest-same-giver edge); the common one-turn-in-per-giver case is fully consistent UI↔server. Relies on EventData->Target (a level-placed actor, stable NetGUID) resolving on the server through the predicted-activation RPC.
  • Item consumption is authoritative. A client may have predicted use of the item between context-menu open and ability dispatch. Re-check inside the ability, mirroring UTransactItemAbility. Per QUEST_SYSTEM.md "TurnIn Task" Footguns.
  • Partial turn-ins are allowed. TargetCount = 10, ItemCount = 5 means two TurnIn invocations complete the task; gate the action on Inventory.HasCount >= ItemCount, not >= TargetCount.
  • Hierarchical match is intentional in HandleInteractEvent. A task tagged Interactable.NPC.Goblin matches a giver with Interactable.NPC.Goblin.Smith. Designers wanting strict-leaf must author the leaf as the task tag (same rule as Enemy.Family.* in Eliminate). Per QUEST_SYSTEM.md "Interact Task" Footguns.
  • GetOfferedQuests filters by per-player state, not just registry membership. Returning the raw membership set would over-surface completed quests on their original givers. The default impl walks the player's UQuestComponent and includes only quests whose next-action site is this giver. Per QUEST_SYSTEM.md "Quest Giver Actor" Footguns.

Verification.

  • Setup (content step): add UTurnInQuestItemAbility to ACradlPlayerState's DefaultAbilities list (BP) so it is granted; otherwise the Action.Trigger.Quest.TurnIn event triggers nothing. Add the Interactable.NPC.CooksAssistant leaf in the editor (the Interactable / Interactable.NPC roots ship in .ini).
  • Place an AQuestGiverActor in PIE with InteractableTag = Interactable.NPC.CooksAssistant, DisplayName = "The Cook".
  • Author "Cook's Assistant" quest: Interact task {InteractableTag = Interactable.NPC.CooksAssistant, TargetCount = 1} → Gather task {ItemId = Egg, TargetCount = 1} → TurnIn task {InteractableTag = Interactable.NPC.CooksAssistant, ItemId = Egg, ItemCount = 1, TargetCount = 1}.
  • Click the cook → context menu shows "Talk to The Cook" → trigger → observe both events fire on the player ASC: the action's own Action.Trigger.Quest.TalkTo and the secondary Event.Interact (with InstigatorTags containing Interactable.NPC.CooksAssistant). Quest advances to task 1 (Gather).
  • Gather an Egg → quest advances to task 2 (TurnIn).
  • Click the cook → context menu now also shows "Turn In" (because the local quest+inventory match). Trigger TurnIn → item consumed server-side → quest completes → rewards land.
  • Negative: empty inventory + active TurnIn task → context menu does NOT show TurnIn.
  • Negative: trigger the "Turn In" action → observe Event.Interact does not fire (non-default action; only Action.Trigger.Quest.TurnIn fires) per the contract.
  • Hierarchical match: place a second giver with InteractableTag = Interactable.NPC.CooksAssistant.Sub; author a quest task on Interactable.NPC.CooksAssistant; interact with the sub-giver → task advances (hierarchical match works).
  • GetOfferedQuests filter sanity: complete the Cook's Assistant quest; reopen the cook's menu; mock a UI consumer that calls GetOfferedQuests → observe the completed quest is not in the output (next-action site no longer this giver).
  • Cross-player: in P2P, two clients each have their own active quest with the same giver — each sees their own context-menu state.

Exits. v1 quest catalogue authorable end-to-end. Phase 7 is polish.


Phase 7 — Validator Depth, Cheats, Debug Overlay

Goal. All validator surfaces are deep enough to catch authoring mistakes pre-runtime. Cheat command set is complete enough to drive any test scenario from console. A debug overlay (no UI design — just an editor-only viewport widget or GEngine->AddOnScreenDebugMessage block per CLAUDE.md guarding) shows active quests + tasks + counters for manual QA. Quest log UI is out of scope (Open Question #3).

Rationale. Most validator extensions landed in lockstep with their owning phases (per CLAUDE.md). This phase is the consolidation pass: surfaces missed during incremental work, cross-asset checks, and the final ergonomics layer that lets QA reproduce any scenario without modifying code.

Tasks.

  • [x] Validator consolidation pass — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
  • [x] Per-task EnemyFamilyTag for Eliminate resolves under Enemy.Family.*already satisfied (Phase 1). The FGameplayTag UPROPERTY can only hold a tag the manager knows, and the existing Eliminate branch already gates on MatchesTag(EnemyFamilyRoot). No change this phase.
  • [x] Per-task RecipeId for Craft resolves via AssetRegistry scan — NEW this phase. Added a file-local CollectKnownRecipeIds() (mirrors the LoadoutDefinition/LoadoutModifierDefinition/PrerequisiteQuests best-effort primary-asset scans — URecipeDefinition is a UPrimaryDataAsset whose AssetName == GetFName(), the FName Phase-5 HandleSkillEvent matches RecipeId against). Craft branch now flags a RecipeId that resolves to no RecipeDefinition asset (no "loaded" gate, consistent with the other primary-asset scans).
  • [x] Cross-asset best-effort: PrerequisiteQuests references resolve to other QuestDefinition assets — already satisfied (Phase 2) via CradlValidationHelpers::CollectKnownQuestIds (+ self-prereq + None rejection). No change this phase.
  • [x] One-path-per-reward-axis rule — already satisfied (Phase 3): the reward loop's SeenRewardIdentities set flags a duplicate grant on any axis. Quests have no separate GE "side path" (rewards are the only grant surface), so the per-axis duplicate check is the complete realization of the contract's one-path rule.
  • [x] Cheat command set — Source/CRADL/Player/CradlPlayerController.cpp + .h:
  • [x] ~~Debug_ListQuests~~ — branching decision: not added (redundant). Phase 1 already shipped Debug_PrintQuests, which dumps the Unlocked/Active/Completed rosters (with per-task counters). Adding a second roster command would only shadow it. Debug_PrintQuests is the roster dump this bullet asked for.
  • [x] Debug_QuestStatus FName QuestId — deep per-quest dump: lifecycle bucket (incl. a (NO DEFINITION) flag), every task's counter/target + type with the current task marked *, and (when completed) each reward manifest entry via a file-local DescribeManifestEntry.
  • [x] Debug_GrantPrerequisites FName QuestId — force-completes the quest's prerequisite chain depth-first with empty manifests (no reward delivery), leaving the named quest itself unblocked-not-completed. Required a new dev-only component surface (UQuestComponent::DebugGrantPrerequisites, self-routing like the Phase-1 mutators) because the public CompleteQuest always runs the reward pipeline and requires an active quest — neither fits "complete a not-yet-started prereq with no rewards." Cycle-guarded via a Visited set seeded with the target.
  • [x] Debug_ResetQuest FName QuestId — wipes the quest from all three lifecycle lists + its PendingRewards. New self-routing UQuestComponent::DebugResetQuest (same reason — no existing public API removes from the lists).
  • [x] All declared UFUNCTION(Exec) unconditionally; bodies live in the file's existing #if !UE_BUILD_SHIPPING cheat block per CLAUDE.md (same pattern as every other Debug_*).
  • [x] Debug overlay — Source/CRADL/Player/CradlPlayerController.cpp (branching decision — controller, not QuestComponent):
  • [x] Debug_ToggleQuestOverlay flips a bShowQuestOverlay flag; a new ACradlPlayerController::PlayerTick override calls DrawQuestOverlay() while on, posting one stable-keyed GEngine->AddOnScreenDebugMessage line per active quest (current task + counter/target). Branching decision (deviation from "implement in QuestComponent.cpp"): the overlay is a local-viewport concern that reads the owning PlayerState's already-replicated GetActiveQuests() + the client-available UQuestRegistry, so it belongs where the player's input/HUD already lives — the controller, alongside every other Debug_Toggle*/on-screen-debug cheat (e.g. the AddOnScreenDebugMessage sites at the controller's existing debug helpers). Putting it on UQuestComponent would have meant enabling per-frame component ticking purely for debug. The flag/helper are #if !UE_BUILD_SHIPPING-guarded; the PlayerTick override stays unconditional (vtable) with a guarded body. Per CLAUDE.md's AddOnScreenDebugMessage-wrapped-in-#if rule.
  • [x] Resolve outstanding Open Questions before launch (project-tracked, not Claude-tracked):
  • [x] QUEST_SYSTEM.md #1RESOLVED (drop) by the user (2026-05-20); implemented in Phase 3's ApplyPersistedState drift check. Contract OQ #1 already reflects this.
  • [x] QUEST_SYSTEM.md #2RESOLVED in this doc: bag → bank → PendingRewards queue + overflow toast; reconciliation drains on next startup. Implemented in Phase 3.
  • [x] QUEST_SYSTEM.md #7RESOLVED in the contract: uniform identity via IInteractable::GetInteractableTag() (Phase 4 producer + Phase 6 first opt-in). Any future terminal becomes quest-trackable with one method override + one tag in Interactable.*.
  • [x] QUEST_SYSTEM.md #9kept as the v1 sketch: UGatheredItemPayload stays a UObject carrying {FName ItemId; int32 Count} (Phase 4). No alternative encoding was needed; revisit only if a future producer wants a different payload shape. (Not a code task — left unchanged.)
  • [ ] Quest log UI design — still deferred to the UI doc per QUEST_SYSTEM.md #3. Out of v1 contract scope; not a Phase-7 code task.

Verification.

  • Author quests covering each validator failure mode (empty DisplayName, empty Tasks, TargetCount = 0, wrong-namespace tag, unknown ItemId/RecipeId, unknown skill tag, unknown book tag, dangling PrerequisiteQuests, duplicate-path reward). Each authoring action is flagged by the validator at save time.
  • Drive a full quest end-to-end via cheat console only — no UI required: 1. Debug_GrantPrerequisites CooksAssistant 2. Debug_TestEligibility CooksAssistant → logs pass. 3. Debug_StartQuest CooksAssistant 4. Gather + interact + turn in via normal world play. 5. Debug_QuestStatus CooksAssistant after each step. 6. Final: rewards landed; manifest is complete.
  • Toggle the overlay; PIE rendering shows live active-quest state without UI.

Exits. Quest mechanics fully exercisable via the cheat console (no UI). The in-world player-facing accept/turn-in interaction lands in Phase 8.


Phase 8 — Quest-Giver Dialogue Integration & Accept Ability

Goal. Close the in-world loop the contract previously left as Open Question #3: talking to a quest giver opens an OSRS-style dialogue modal, and the player accepts a quest and turns in items through that conversation — no cheats. Adds the missing quest-commit verb (UAcceptQuestAbility) and wires AQuestGiverActor to open dialogue. After this phase a quest is playable start-to-finish from world interaction alone.

Rationale. Per QUEST_SYSTEM.md "Lifecycle" (as amended) and DIALOGUE_SYSTEM.md, the unlock sweeps make a quest available but nothing converted "offered" (IQuestGiver::GetOfferedQuests) into a StartQuest call — the only start path was the debug cheats. The dialogue contract supplies the modal + widget; this phase supplies the quest-domain half: the server-authoritative accept ability and the giver wiring. Turn-in reuses the existing UTurnInQuestItemAbility verb-source-agnostically (a second publisher, per ARCH #18).

Depends on. DIALOGUE_SYSTEM.md / DIALOGUE_IMPLEMENTATION.md for the dialogue-domain pieces: UDialogueDefinition + validator, the CBA_Modal_Dialogue modal ability (Action.Modal.Dialogue + Action.Trigger.Modal.Dialogue), and UDialogueWidget (which dispatches the quest trigger events). The quest-side work below compiles independently, but the end-to-end in-world verification needs the dialogue modal + widget present. Land the dialogue scaffolding (its Phase 0) before this phase's verification.

Build note. Phase 8 is complete and verified in PIE: talking to a placed AQuestGiverActor opens the dialogue modal, Accept starts the quest, the step-gated (DialogueKey) talk-to response advances giver-hosted Interact steps (with the bare interact correctly not advancing them, via the HandleInteractEvent keyed-task skip), and TurnIn consumes items + advances. The abilities are granted in BP_CradlPlayerState.DefaultAbilities. The talk-to disambiguation iterated during the build: the coarse Progress concept became a per-step FName DialogueKey on FQuestTask + the UQuestAdvanceRequest payload, so one giver hosts multiple distinct talk-to beats.

Tasks.

  • [x] Gameplay tag — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.{h,cpp}:
  • [x] Action.Trigger.Quest.Accept (.ini + C++ symbol — referenced by UAcceptQuestAbility's trigger and the dialogue widget's dispatch). DevComment per feedback_gameplay_tag_decl_minimal.
  • [x] Action.Trigger.Quest.Advance (.ini + C++ symbol — referenced by UAdvanceQuestTaskAbility's trigger and the dialogue widget's step-gated (DialogueKey) response dispatch). The dialogue-only "talk to NPC" advance verb replacing TalkTo/Progress.
  • [x] Remove Action.Trigger.Quest.TalkTo — C++ symbol + .ini entry deleted; BeginInteract / GetPrimaryActionTag / GatherActions no longer fire it. v1 leftover, stripped per DIALOGUE_SYSTEM.md.
  • [x] (Action.Modal.Dialogue + Action.Trigger.Modal.Dialogue are declared by the dialogue impl doc; the giver references the latter — consumed, not re-declared here.)
  • [x] UQuestAcceptRequestSource/CRADL/Abilities/QuestAcceptRequest.h (new):
  • [x] UQuestAcceptRequest : public UObject { UPROPERTY() FName QuestId; } — transient payload mirroring UTransactRequest (Source/CRADL/Abilities/TransactRequest.h), placed on FGameplayEventData::OptionalObject by the dialogue widget.
  • [x] UQuestAdvanceRequestSource/CRADL/Abilities/QuestAdvanceRequest.h (new):
  • [x] UQuestAdvanceRequest : public UObject { UPROPERTY() FName QuestId; UPROPERTY() FName DialogueKey; } — transient payload for the step-gated talk-to advance; QuestId addresses the exact quest, DialogueKey the talk-to beat.
  • [x] UAcceptQuestAbilitySource/CRADL/Abilities/AcceptQuestAbility.{h,cpp} (new):
  • [x] Trigger: FAbilityTriggerData { Action.Trigger.Quest.Accept, EGameplayAbilityTriggerSource::GameplayEvent }. NetExecutionPolicy = LocalPredicted (mirror UTransactItemAbility).
  • [x] ActivationRequiredTags = { Action.Modal.Dialogue } — accept is only ever dispatched from an open conversation; failed activation short-circuits before the body (declarative gate, not in-body check).
  • [x] Server body (guarded IsNetAuthority): read QuestId from EventData.OptionalObject (UQuestAcceptRequest); resolve the source giver from EventData.Target (IQuestGiver); confirm the quest is Offer-eligible at that giver (reuses EvaluateQuestRole); on pass call Quests->UnlockQuest(QuestId) then Quests->StartQuest(QuestId). On fail, PostServerMessage(Message.Source.Quest, ...).
  • [x] Predict, don't mutate. Mutation guarded behind IsNetAuthority; the component mutators self-route through Server_*.
  • [x] Add UAcceptQuestAbility to BP_CradlPlayerState.DefaultAbilities (same as UTurnInQuestItemAbility).
  • [x] UAdvanceQuestTaskAbilitySource/CRADL/Abilities/AdvanceQuestTaskAbility.{h,cpp} (new): The dialogue-driven "talk to NPC" advance — the real replacement for TalkTo progression.
  • [x] Trigger: FAbilityTriggerData { Action.Trigger.Quest.Advance, EGameplayAbilityTriggerSource::GameplayEvent }. NetExecutionPolicy = LocalPredicted. ActivationRequiredTags = { Action.Modal.Dialogue }.
  • [x] Payload UQuestAdvanceRequest { FName QuestId; FName DialogueKey; } (new; mirrors UQuestAcceptRequest) on EventData.OptionalObject — names the exact quest + talk-to beat. Server body (guarded IsNetAuthority): resolve the giver from EventData.Target (IInteractable::GetInteractableTag()); resolve the payload's quest by QuestId (NOT first-active-match — addressing by id avoids the collision when two quests have a talk-to step current at one giver); confirm its current task is an Interact step at this giver whose DialogueKey equals the payload key; read its CurrentTaskIndex; call Quests->AdvanceTask(QuestId, CurrentTaskIndex, 1).
  • [x] Pass CurrentTaskIndex, never an arbitrary indexAdvanceTask rejects TaskIndex != CurrentTaskIndex (per QUEST_SYSTEM.md "Task Chain").
  • [x] Predict, don't mutate (same as Accept). Added to BP_CradlPlayerState.DefaultAbilities.
  • [x] AQuestGiverActor dialogue wiring — Source/CRADL/Quests/QuestGiverActor.{h,cpp}:
  • [x] Add UPROPERTY(EditAnywhere) TObjectPtr<UDialogueDefinition> Dialogue; (level-authored; actor stays bReplicates = false). Also added GetMenuHeader() returning DisplayName.
  • [x] Point both BeginInteract and GetPrimaryActionTag() at Action.Trigger.Modal.Dialogue, mirroring AStoreTerminal. One outcome — dialogue opens — identical across the interact-key path (BeginInteract) and the click path (the default bDefault action through DispatchContextAction). Payload Target = this so UDialogueWidget::GetModalContext()->Target resolves the giver.
  • [x] Remove Action.Trigger.Quest.TalkTo — the dead no-op fire is gone from BeginInteract / GetPrimaryActionTag / GatherActions; symbol + .ini entry deleted.
  • [x] Leave Event.Interact alone. The bDefault action still funnels Event.Interact for non-dialogue Interact tasks; not duplicated into BeginInteract. Giver talk-to progression rides Action.Trigger.Quest.Advance instead.
  • [x] Quest-role predicate for dialogue node visibility — Source/CRADL/Quests/QuestGiverInterface.h / QuestGiverActor.cpp:
  • [x] EvaluateQuestRole(const ACradlPlayerState*, FName QuestId) const → EDialogueQuestRole added to the IQuestGiver seam (enum forward-declared in the interface, full include in the impl) and implemented on AQuestGiverActor reusing the exact GetOfferedQuests lifecycle checks + the GatherActions turn-in predicate. The widget calls this; it does not re-derive lifecycle state.
  • [x] Validator: no UCradlQuestDefinitionValidator change this phase — the dialogue-asset validation landed as UCradlDialogueDefinitionValidator in lockstep with UDialogueDefinition.

Verification.

  • Console regression: the Phase 1–7 cheat flow (Debug_SeedQuest / Debug_AdvanceTask / Debug_QuestStatus) still drives quests with no dialogue dependency.
  • In-world accept (the new path): place an AQuestGiverActor with an authored UDialogueDefinition containing an Offer-role node for a startable quest. Walk up, primary-interact → dialogue modal opens (NPC header from GetMenuHeader). Pick the Accept response → Debug_QuestStatus shows the quest promoted to Active. The same interact still advances any matching Interact task (Event.Interact fired alongside).
  • In-world talk-to advance: for a quest whose current task is an Interact ("talk to me") step at this giver (carrying a DialogueKey), the dialogue's step-gated response (Role=None + matching DialogueKey) is visible; pick it → Action.Trigger.Quest.Advance fires, the task advances (Debug_QuestStatus shows counter ↑; Debug_DumpDialogueRoles shows the live stepKey). Confirm the bare interact (just opening dialogue) does not advance it — HandleInteractEvent skips keyed Interact tasks, so progression is the dialogue response, not the open.
  • In-world turn-in: with an active TurnIn task and the items in bag, the dialogue's TurnIn-role node is visible; pick it → items consumed, task advances. Confirm the context-menu turn-in verb still works too (not gated on the dialogue modal).
  • Server re-validation: open the dialogue, then (via a second client / cheat) drop a required item before clicking Accept → server rejects the start; node visibility was advisory only.

Footguns.

  • Don't gate UTurnInQuestItemAbility on Action.Modal.Dialogue. It must activate from both the context menu and the dialogue page; only the new UAcceptQuestAbility carries the modal gate. (Contract: DIALOGUE_SYSTEM.md "Turn-In Integration".)
  • Event.Interact still fires — no second progression path. Opening dialogue does not change Interact-task progression; don't add an in-dialogue advance for the same task or it double-counts.
  • Eligibility is re-validated server-side. Hiding an Offer node when ineligible is owner-local UX, not the gate; UAcceptQuestAbility re-checks Requirements.IsSatisfiedBy on authority (feedback_p2p_replication_audit).
  • UnlockQuest then StartQuest, in order. StartQuest_Authority requires the quest in UnlockedQuests; unlock-then-start is the same requirement-bypass Debug_SeedQuest uses. UnlockQuest on an already-unlocked quest is a no-op (dedupe at the write site).

Exits. A quest is playable end-to-end from world interaction — accept in dialogue, progress via tasks, turn in / complete — with no cheats. v1 in-world ready.


Cross-doc dependencies

  • Phase 4 modifies fire-sites in Source/CRADL/Enemy/EnemyDeathAbility.cpp. If [ENEMY_IMPLEMENTATION.md] is mid-flight on the same file, coordinate via a shared branch.
  • Phase 5 subscribes to gather/craft events that the same phase extends in Phase 4 — keep the order. Subscriber landing before the payload extension would compile but read OptionalObject == nullptr and silently no-op.
  • Phase 3 reconciliation depends on the existing cold-storage resurrection ordering. Quest snapshot apply + reconciliation must run after the bank's cold-storage resurrection pass (Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:71-108, 418-432) so the bank's free capacity at overflow-ladder time reflects any cold-storage drain. The pending-rewards drain is essentially a per-quest sibling of cold-storage resurrection.
  • Phase 3 reconciliation latch lives on UCradlSaveSubsystem, not UQuestComponent. Any future system that wants similar once-per-startup semantics should reuse the same latch shape (TSet<FString> ReconciledSlotsThisSession) on the same subsystem — not invent a new subsystem.
  • Save migration to v10 affects every saved profile in the project. Test with a v9 save on hand before merging Phase 0.
  • Phase 8 depends on the dialogue system. The quest-side accept ability + giver wiring compile standalone, but the in-world verification needs CBA_Modal_Dialogue + UDialogueWidget + UDialogueDefinition from DIALOGUE_SYSTEM.md / DIALOGUE_IMPLEMENTATION.md. Land the dialogue scaffolding before Phase 8 verification. The Action.Modal.Dialogue / Action.Trigger.Modal.Dialogue tags are owned by the dialogue docs; Phase 8 declares only Action.Trigger.Quest.Accept.

Out-of-doc deferrals

Items from QUEST_SYSTEM.md "Open Questions" that explicitly do not ship in v1:

  • Repeatable / daily quest cooldowns (OQ #5).
  • Public "currently on quest" spectator cosmetic (OQ #6).
  • Per-instance opt-in of existing terminals (AStoreTerminal, ABankTerminal, etc.) to the Interactable.* identity surface — contractually possible from Phase 4 onward (each terminal overrides IInteractable::GetInteractableTag() and authors one tag), but no terminal opts in for v1. The cost is one method + one tag per actor; no inheritance reshape needed.
  • Abandon / fail / branching (OQ #8).
  • Slayer assignment publisher (OQ #10) — the shared surface (Combat.Event.Kill + Enemy.Family.* filter) is in place from Phase 4 onward; Slayer is its own doc.