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
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. - Per CLAUDE.md "validators in lockstep": any phase that touches
UQuestDefinition,FQuestTask,FQuestReward, orFQuestRequirementsupdatesUCradlQuestDefinitionValidatorunder 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 inConfig/DefaultGameplayTags.iniwith DevComment; add a C++ symbol inSource/CRADL/CradlGameplayTags.honly 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 (
.inideclarations) —Config/DefaultGameplayTags.ini: - [x]
Questroot +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 —ReasonTagon 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]
+PrimaryAssetTypesToScanentry:PrimaryAssetType="QuestDefinition",AssetBaseClass=/Script/CRADL.QuestDefinition,Directories=((Path="/Game/Definitions/Quests")). Mirror theSpellDefinitionentry shape exactly. - [x]
UQuestDefinition— Source/CRADL/Quests/QuestDefinition.h (new): - [x]
class UQuestDefinition : public UPrimaryDataAsset. - [x] Override
GetPrimaryAssetId()→FPrimaryAssetId(TEXT("QuestDefinition"), GetFName())(mirrorUSpellDefinition,UEnemyDefinition). - [x] Fields per QUEST_SYSTEM.md "Quest Definition":
DisplayName,Description,Icon (TSoftObjectPtr<UTexture2D>),CategoryTags (FGameplayTagContainer with Categories="Quest"),Requirements,Tasks,Rewards. - [x]
FQuestTaskshape — 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 — noEditConditionfield-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",InteractableTagis the single identity field for bothInteractandTurnIntasks — no per-task-type bifurcation (no separateQuestGiverTag). - [x]
FQuestRewardshape — flat struct +EQuestRewardTypeenum discriminator — Source/CRADL/Quests/QuestReward.h (new): - [x] Same flat representation as
FQuestTask(kept consistent per that decision), but discriminated by anEQuestRewardTypeenum rather than a tag — there is no reward tag taxonomy, and the enum letsEditCondition/EditConditionHideshide the irrelevant variant fields (authoring UX the tag-discriminated tasks can't get). - [x] Five variants:
SkillXp { FGameplayTag SkillTag; int64 XpAmount; }Item { FName ItemId; int32 Count; }SpellbookUnlock { FGameplayTag BookTag; }Loadout { FPrimaryAssetId LoadoutAssetId; }(new — "ship" axis per THEME.md; routes toULoadoutComponent::GrantLoadoutIdper Source/CRADL/Loadout/LoadoutComponent.h:61)LoadoutModifier { FPrimaryAssetId ModifierAssetId; }(new — "pilot" axis per THEME.md; routes toULoadoutModifierComponent::GrantInstanceper Source/CRADL/Loadout/LoadoutModifierComponent.cpp:102-114, which mints a freshFGuid InstanceIdper call)
- [x]
FQuestRequirements— Source/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 ofFGatheringNodeDefinition::FItemDrop::RequiredItemId. - [x]
UQuestRegistry— Source/CRADL/Quests/QuestRegistry.h +.cpp(new): - [x]
class UQuestRegistry : public UGameInstanceSubsystem. - [x]
mutable bool bBuilt = falselatch +EnsureBuilt()exactly mirroringUSpellRegistry::EnsureBuilt—bBuilt = trueset before the scan call to guard reentrancy (per the PIE-race footgun in QUEST_SYSTEM.md "Quest Registry" Footguns). - [x]
TMap<FName, TObjectPtr<UQuestDefinition>> DefinitionsByNameindexed during build (stored asTObjectPtr<const UQuestDefinition>, mirroringUSpellRegistry'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]
UCradlQuestDefinitionValidatorstub — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h +.cpp(new): - [x]
class UCradlQuestDefinitionValidator : public UEditorValidatorBase. MirrorCradlSpellDefinitionValidator. - [x] Phase-0 checks:
DisplayNamenon-empty;Tasksnon-empty; each task'sTargetCount >= 1;CategoryTagsonly contains tags underQuest.*. 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 aTArrayUPROPERTY element).FActiveQuestSnapshotandFPendingQuestRewardland complete;FCompletedQuestSnapshotcarries identity only — Phase 1 adds itsFQuestRewardManifestmember (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
SaveVersionguard: additive fields rely on the profile's tagged-property serialization (per theCRADL_SAVE_VERSIONheader note). Capture/apply wiring is Phase 1.
Verification.
- Compile clean (user-side).
- Open the editor. Create a new
UQuestDefinitionasset under/Game/Definitions/Quests/. AuthorDisplayName = "Test Quest", leaveTasksempty → save → asset validator flags the empty-Taskserror. - Add one Eliminate task with
TargetCount = 0→ save → validator flagsTargetCount >= 1error. - 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/FPendingQuestRewardlanded in a new leaf header Source/CRADL/Quests/QuestPersistentTypes.h instead ofQuestComponent.h. The manifest is needed by both the runtimeFCompletedQuest(QuestComponent.h) and the saveFCompletedQuestSnapshot(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.FPendingQuestRewardmoved 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 acrossApplyPersistedState/ getters;UCradlSaveSubsystemdoes the snapshot↔runtime conversion, so the component never includes the savegame header.
- [x]
UQuestComponent— Source/CRADL/Quests/QuestComponent.h +.cpp(new): - [x]
class UQuestComponent : public UActorComponent. Constructor:SetIsReplicatedByDefault(true). - [x] Three replicated fields, each
COND_OwnerOnlywithReplicatedUsing = OnRep_*:- [x]
TArray<FName> UnlockedQuests→OnRep_UnlockedQuestsbroadcastsOnQuestUnlockedper added entry. - [x]
TArray<FActiveQuest> ActiveQuests→OnRep_ActiveQuestsbroadcastsOnQuestProgressChangedper added/changed entry. - [x]
TArray<FCompletedQuest> CompletedQuests→OnRep_CompletedQuestsbroadcastsOnQuestCompletedper added entry.
- [x]
- [x]
GetLifetimeReplicatedPropswith the threeDOREPLIFETIME_CONDITION(..., COND_OwnerOnly)lines. - [x] Authority broadcasts the delegate directly after mutation on listen-server (mirror
USkillsComponent::ApplyXPGrant's pattern —OnRepdoes 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 toUnlockedQuests. - [x]
StartQuest(FName QuestId)— guard: present inUnlockedQuests; promote toActiveQuestswithCurrentTaskIndex = 0and zero counters. - [x]
AdvanceTask(FName QuestId, int32 TaskIndex, int32 Delta)— clamp delta-added counter at 0; on hittingTargetCountincrementCurrentTaskIndex; on overrun callCompleteQuest. (Also guardsTaskIndex == CurrentTaskIndex— see Task Chain reconciliation note.) - [x]
CompleteQuest(FName QuestId)— move fromActiveQueststoCompletedQuests. Phase-1 manifest is empty (FQuestRewardManifest()); Phase 3 fills it.
- [x]
- [x]
FActiveQuest,FCompletedQuest,FQuestRewardManifest,FQuestRewardManifestEntry,FPendingQuestReward— Source/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 mirrorsFQuestRewardfield-for-field per QUEST_SYSTEM.md "Reconciliation" — including Loadout entries(FPrimaryAssetId LoadoutAssetId)and LoadoutModifier entries(FPrimaryAssetId ModifierAssetId, FGuid GrantedInstanceId).GrantedInstanceIdis bookkeeping only — never a diff axis. Diff identity for modifiers isModifierAssetIdalone. - [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.SourceQuestIdis diagnostic-only (filter againstIsKnownQueston load with warning log; do not gate the grant on it). - [x] Server-only
TArray<FPendingQuestReward> PendingRewardsonUQuestComponent(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]
IQuestGiverinterface header — Source/CRADL/Quests/QuestGiverInterface.h (new, scaffold only): - [x]
UINTERFACE+IQuestGiverwithvirtual 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 isIInteractable::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. AddedGetQuestComponent()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 theManifestmember.) - [x]
PendingRewardsprofile field directly uses the runtimeFPendingQuestRewardstruct (it's already a plain value struct — mirrors howFLoadoutModifierInstanceround-trips via Source/CRADL/SaveGame/CradlPlayerProfile.h:52 without a parallel snapshot type). (Struct now lives inQuestPersistentTypes.hper 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: captureUnlockedQuests+ pending directly, and convert active/completed runtime records to their snapshot types. (Phase-1 reconciliation: implemented as read-only getters onUQuestComponent—GetUnlockedQuests/GetActiveQuests/GetCompletedQuests/GetPendingRewards— with the snapshot↔runtime conversion done inUCradlSaveSubsystem, NOT aCaptureSnapshot()method returning a bundle struct. This mirrorsUSpellbookComponent(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 throughUQuestRegistry::IsKnownQuest(forPending, filter bySourceQuestIdbut only warn — don't drop the pending Item itself, per the contract); drop orphans with aUE_LOG(..., Warning, ...)per QUEST_SYSTEM.md "Quest Registry" Footguns. (Logged via aLogCradlQuestStatecategory 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_SHIPPINGexec block per CLAUDE.md. Same shape forDebug_StartQuest,Debug_AdvanceTask,Debug_CompleteQuest. AddedDebug_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 throughServer_*RPCs when off-authority (mirroringDebug_GrantXP/USkillsComponent::GrantXP), so noServer_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 →EnemyFamilyTagunderEnemy.Family.*). Also added aTaskTypemust-be-a-Quest.TaskType.*-leaf gate (the per-type checks key off it). Namespace check on theInteractable.*root lands in Phase 6 alongside the per-actorInteractableTagUPROPERTY. - [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 TestQuest→Debug_StartQuest TestQuest→Debug_AdvanceTask TestQuest 0 1(until the task target is hit) → observe auto-Debug_CompleteQuestfire → quest appears inCompletedQuestswith 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_AdvanceTaskwith 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
ApplyPersistedStatedrops 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) const— Source/CRADL/Quests/QuestRequirements.cpp (new): (Implemented as aconstmember, NOTstatic. The contract's "single static ... const" wording is self-contradictory; the const member matches the Phase-0QuestRequirements.hcomment and letsRequirements.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. (MeetsRequirementstakesTArrayView<const FSkillRequirement>; theTArrayconverts implicitly.) - [x]
PrerequisiteQuests— every FName must satisfyPS->GetQuestComponent()->IsQuestCompleted(QuestId). (Phase 1 addedIsQuestUnlocked/IsQuestActive/IsQuestCompletedBlueprintPure accessors — namedIsQuest*becauseUActorComponent::IsActive()is already a UFUNCTION. The lifecycle arrays themselves areprotected, so use these rather than reaching for the array; the skill-threshold sweep dedupes viaIsTracked.) - [x]
RequiredItems—PS->GetInventoryComponent()->CountOf(ItemId) >= Countfor each entry. - [x]
RequiredEquippedTags—PS->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 theDebug_TestEligibilitycheat and future UI.) - [x] Skill-threshold sweep — Source/CRADL/Quests/QuestComponent.cpp:
- [x]
BeginPlay: bindUSkillsComponent::OnLevelUp→HandleSkillLevelUp. Authority-only binding (deviation/refinement — the unlock sweep is a server-side write andOnLevelUpfires on authority directly; binding on a client would only spawn redundantServer_UnlockQuestRPCs, and clients receive unlocks viaCOND_OwnerOnlyreplication). Verified safe at load:USkillsComponent::ApplySnapshot(the authority load path) broadcasts onlyOnXPChanged, neverOnLevelUp(theOnLevelUpbroadcast lives in the clientOnRep_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 sharedRunUnlockSweep(). - [x]
RunUnlockSweep()(shared): iterateUQuestRegistry::GetAllQuestIds; for each FName not already tracked (IsTracked), callRequirements.IsSatisfiedBy(PS); if true,UnlockQuest_Authority(QuestId). - [x] Dedupe at write site per QUEST_SYSTEM.md "Lifecycle" Footguns —
UnlockQuest_AuthorityrechecksIsTracked+IsKnownQuest, so the sweep never appends a duplicate or re-firesOnQuestUnlockedfor 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 theCompletedQuests.Add+ broadcast): call the sharedRunUnlockSweep(). 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 (IsTrackedskip). 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 viaUQuestRegistryand logs theIsSatisfiedByresult plus the per-axis requirement counts for the named quest. - [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
- [x]
Requirements.Skillsresolve viaCradlValidationHelpers::CollectKnownSkillTags(invalid tag and dangling tag are distinct failures). - [x]
Requirements.PrerequisiteQuests— best-effort cross-asset resolution via newCradlValidationHelpers::CollectKnownQuestIds()(readsQuestDefinitionprimary-asset metadata only —FAssetData::AssetNameis the QuestId — so no asset load). Also rejects a self-prerequisite (a quest naming itself, which would deadlock its own unlock) and aNoneentry. 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.RequiredItemsresolve to the items DataTable viaCradlValidationHelpers::CollectKnownItemIds(gated onbItemsLoaded);Count >= 1enforced defensively alongside theClampMinmeta.
Verification.
- Author
TestEligibilityquest withRequirements.Skills = [{Cooking, 10}]. - Cheat: grant Cooking XP until level 9. No unlock. Reach level 10 via XP grant. Observe
OnQuestUnlockedfires exactly once; quest appears inUnlockedQuests; replicated owner-only. - Author
QuestBwithPrerequisiteQuests = [QuestA]. CompleteQuestAvia cheats. ObserveQuestBunlocks duringCompleteQuest's sweep. - Cheat: complete
QuestAagain (force re-add toCompletedQuests— should be a no-op since dedupe is at write site). ObserveQuestBdoes 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-guardedCompleteQuest_Authority/ReconcilePersistedRewards). - [x] Dispatch by variant (flat-struct
switch (Reward.RewardType)):- [x]
SkillXp→PS->GetSkillsComponent()->GrantXP(SkillTag, XpAmount)(int64amount). - [x]
Item→ run the overflow ladder via the sharedSeatItemViaLadder(ItemId, Count)helper (Bag->AddItems→Bank->AddItems, both returning leftover); the leftover is pushed ontoPendingRewardsandToastDeferredfires. The manifest entry records the originally-requestedCount(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 privateSeatItemViaLadderso DeliverReward's Item path and the pending-drain inReconcilePersistedRewardsshare one implementation.) - [x]
SpellbookUnlock→PS->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 grantedFPrimaryAssetId. - [x]
LoadoutModifier(new) → resolve the def first via the staticULoadoutModifierComponent::ResolveDefinition(ModifierAssetId), thenFGuid Granted = PS->GetLoadoutModifierComponent()->GrantInstance(Def, TMap<FGameplayTag,float>()). CaptureGrantedinto the manifest entry'sGrantedInstanceId. Empty rolled magnitudes per Phase-1 modifier behavior; Phase-2-modifier-rolling integration is a follow-up. (Accessor isGetLoadoutModifierComponent(), not the doc's earlierGetModifierComponent(). A modifier asset that fails to resolve logs a warning and records the manifest entry with no minted instance — the diff still keys onModifierAssetId, so a later startup with the asset present grants it.)
- [x]
- [x] Returns a
FQuestRewardManifestEntrydescribing what was applied (forLoadoutModifier, the entry carries the mintedFGuid). - [x]
UQuestComponent::ToastDeferred(FName ItemId, int32 Count)— helper: - [x] Server-only. Resolves the owning ASC via
PS->GetCradlASC()and callsASC->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/Countparams 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
PostClientMessagepath the cold-storage load notices use). - [x]
CompleteQuestextension — Source/CRADL/Quests/QuestComponent.cpp: - [x] In
CompleteQuest_Authority, fetch the definition; iterateRewards[]; callDeliverReward(Reward, QuestId)for each; append each returned entry toFCompletedQuest::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;onUCradlSaveSubsystem(mutableso theconstLoadPlayercan 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"): callReconcileQuestRewardsForPlayer(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 callsDeliverReward(a component method that reaches the player's other components via the owning PlayerState) and mutates the component-ownedCompletedQuestsmanifest +PendingRewards. Putting the body in the subsystem would force exposingDeliverReward, 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 currentUQuestDefinition::Rewards. For each reward, find its manifest entry by identity (QuestRewardIdentityMatches): no match →DeliverReward(withSourceQuestId = 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 fromRewardsare left alone (conservative). - Drain
PendingRewardsviaSeatItemViaLadder: 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).
- For each
- [x] Don't latch on
UQuestComponent. PlayerState components fireBeginPlayon 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
IsKnownQuestfiltering (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 inApplyPersistedState: it's a per-restore integrity check touching only this component's ownActiveQuests, 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:
FQuestRewardManifestEntrylanded inQuestPersistentTypes.h, which includesQuestReward.h. The helper must live with the entry type —QuestReward.hcannot referenceFQuestRewardManifestEntrywithout 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'soperator==/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:SkillTagmatches;XpAmountis the value-with-delta axis (handled by the caller).Item:ItemIdmatches;Countis the value-with-delta axis (handled by the caller).SpellbookUnlock:BookTagmatches; no value axis.Loadout:LoadoutAssetIdmatches; no value axis.LoadoutModifier:ModifierAssetIdmatches;GrantedInstanceIdis not part of the diff key — it's bookkeeping the manifest carries forward so future revoke paths have a target.
- [x] Listen-server delegate broadcast —
CompleteQuest_Authoritydirectly broadcastsOnQuestCompletedafter manifest population (host won't getOnRep). Reconciliation manifest-only mutations do not re-broadcast:OnRep_CompletedQuestsonly firesOnQuestCompletedfor 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
CurrentTaskIndexis no longer a valid index into the current definition'sTasksis popped fromActiveQuestsentirely — 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 inApplyPersistedState'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
RewardTypeenum 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"; noteCount's default is1, not0). - [x]
SkillXp.SkillTagresolves viaCradlValidationHelpers::CollectKnownSkillTags;XpAmount > 0enforced defensively. - [x]
Item.ItemIdresolves to the items DataTable (gated onbItemsLoaded);Count >= 1. - [x]
SpellbookUnlock.BookTagresolves to a knownUSpellbookDefinitionasset. (Deviation: scansSpellbookDefinitionprimary-asset data at validation time —USpellbookRegistryis aUGameInstanceSubsystemand isn't built during editor validation, so the doc's "known book inUSpellbookRegistry" is reconciled to the asset scan.) - [x]
Loadout.LoadoutAssetIdresolves to a knownULoadoutDefinitionasset via best-effort AssetRegistry scan (noULoadoutRegistryexists; mirrors the cross-assetPrerequisiteQuestsscan — buildsFPrimaryAssetId("LoadoutDefinition", AssetName)from metadata without loading). - [x]
LoadoutModifier.ModifierAssetIdresolves to a knownULoadoutModifierDefinitionasset via the same best-effort scan. - [x] Collectors kept file-local (anonymous namespace in the validator
.cpp), NOT added toCradlValidationHelpers— 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,
UnlockedLoadoutscontains the Frigate id,ULoadoutModifierComponent::Unlockedcontains a new instance with the RookiePilot def id. Manifest has five entries; the modifier entry carries the mintedFGuid. - Save → close PIE → reopen PIE → load. Observe no double-grant of the modifier (manifest's
ModifierAssetIdmatches 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 secondLoadoutModifier { 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 onUCradlSaveSubsystemsurvives 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
PendingRewardshas the entry; observe the localized overflow toast lands on the owning client (visible in the message log UI / per the existingUCradlMessageLogSubsystemsurface). Save → exit PIE. - Pending drain on restart: empty the bag/bank externally → restart PIE → load. Observe
PendingRewardsdrains (items land in inventory); no toast fires for entries that successfully seated. - Pending re-toast on still-full: with
PendingRewardspopulated 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 savedCurrentTaskIndexis now out of range). Restart PIE → load. Observe the quest is dropped fromActiveQuests(aLogCradlQuestStatewarning names it), it is not inCompletedQuests, 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—.inideclaration with DevComment and C++ symbolCradlTags::Combat_Event_Kill(defined viaUE_DEFINE_GAMEPLAY_TAG, mirroring the siblingCombat.Event.*tags which are declared in both.iniand.cpp). Referenced by name fromUEnemyDeathAbility(andUQuestComponentin Phase 5). - [x]
Event.Interact—.inideclaration with DevComment and C++ symbolCradlTags::Event_Interact. Referenced by name fromUInteractionComponent(andUQuestComponentin 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 stableInteractable.*tag. - [x] No existing implementer (
AStoreTerminal,ABankTerminal,ALoadoutTerminal,AGatheringNode,ACraftingStation) opts in this phase — they all keep the default invalid return.AQuestGiverActoris the first opt-in, landing in Phase 6. - [x]
UInteractionComponent::DispatchContextActionextension — Source/CRADL/Interaction/InteractionComponent.cpp: - [x] After the existing fire of the action's own
Payload.EventTagevent (capturing its result intobHandled), additionally: ifAction.bDefault == trueandCast<IInteractable>(Action.SourceActor.Get())returns a validGetInteractableTag(), fire a secondFGameplayEventDataon the same ASC viaASC->HandleGameplayEvent(Event.Interact, ...)withTarget = source actor,InstigatorTags = { interactable tag }, thenreturn bHandled. Mirrors the action's own fire path exactly (HandleGameplayEvent, notFireReplicatedGameplayEvent) per the contract's "same fire path, same replication" replication-audit note. - [x] The action's own event still fires unconditionally —
Event.Interactis 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 asEvent.Interactper 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.Interactfrom their ownIInteractableimpls — opting in is one method override + one tag. - [x] Typed-payload
FireReplicatedGameplayEventoverload — 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 inInstigatorTags"via a small extension to the call shape, or via a pre-fireFGameplayEventDataassembly 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 containerFGameplayTagContainer(Action.Gathering/Crafting); kill passes the fullICombatStatsProviderclassification container, which is designed to grow past today's singleEnemy.Family.*tag) — cleaner than a single-tag overload plus a bespoke manual-assembly path for the kill.OptionalObjectisconstbecauseFGameplayEventData::OptionalObjectis itselfconst UObject*(read-only payload); a one-lineconst_castat the RPC boundary feeds the non-constUFUNCTIONobject param. Contract reconciled — see QUEST_SYSTEM.md "Eliminate Task" Implementation surface and Gather Task. - [x] Backed by a new
NetMulticast_HandleGameplayEventObjectRPC; 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 theInstigatorTags[0]action tag, not the object. VerifiedUCradlVisualStateComponent::HandleSkillRollSucceededEventreads onlyInstigatorTags/Target, so the gather/craft switch is regression-free. - [x]
UGatheredItemPayload— new leaf header Source/CRADL/Abilities/GatherPayload.h: - [x] Chose the dedicated
GatherPayload.hoverGatherAbility.h(the doc's sanctioned "if cleaner" option): the Phase-5 quest subscriber includes this leaf header toCastthe payload rather than pulling the full gather-ability header (which drags inSkillStatsResolver,CradlGameplayAbility). MirrorsTransactRequest.h. - [x]
class UGatheredItemPayload : public UObjectwithUPROPERTY() 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 transientUGatheredItemPayload(NewObject<UGatheredItemPayload>(this), local namedGatheredPayloadto avoid thePayloadshadow) with the rolled{Drop.ItemId, Count}. - [x] Replaced the 3-arg
FireReplicatedGameplayEventcall with the container+payload overload, passing the payload asOptionalObject. - [x] Craft success payload — Source/CRADL/Abilities/CraftAbility.cpp:
- [x] In the craft-success fire-site inside
HandleDurationFinished, pass the executedRecipe(TObjectPtr<const URecipeDefinition>) directly asOptionalObject(already a UObject; theconst UObject*param accepts it without a wrapper). - [x]
Combat.Event.Killfire-site — Source/CRADL/Enemy/EnemyDeathAbility.cpp: - [x] After
UEnemyDropComponent::ResolveAttributionreturns, fire directly on the tagger's PlayerState ASC viaAttribution.Tagger.Get()->GetCradlASC()— not viaGetOwningController()->GetPawn(). Branching decision (deviation): in CRADL the ASC lives onACradlPlayerState(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.FireReplicatedGameplayEventalready setsInstigator = 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 inInstigatorTags(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 mirroringFTargetClassificationScope's read (interface cast, not the concreteAEnemyCharactermethod, 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_LOGsubscriber inside the ASC'sGenericGameplayEventCallbacks.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.Killfires on the attribution-winner's ASC only. - Gather a
Bronze_Orefrom a tin/copper mixed node: observe the gather event fires withOptionalObjectcontaining 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_LOGsubscriber forEvent.Interacton the local player ASC. Interact with any existingIInteractable(store terminal, bank, gathering node) — observe noEvent.Interactlog line (none of them overrideGetInteractableTag()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) andGatherActionsreturning a singlebDefault = trueaction. Right-click → observeEvent.Interactfires on the player ASC withTarget = the test pawn,InstigatorTagscontainingInteractable.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: addedQuest_TaskType_Gather,Quest_TaskType_Craft,Quest_TaskType_Eliminate(.inialready declared them in Phase 0; Phase 5 is the first reference by name from runtime C++, which is what the rule keys off).Interact/TurnInstay.ini-only until Phase 6 addsQuest_TaskType_InteractforHandleInteractEvent. The editor validator continues to resolve all five viaFGameplayTag::RequestGameplayTag(string-keyed, symbol-independent) — unchanged. - [x] Subscriber registration — Source/CRADL/Quests/QuestComponent.cpp:
- [x] In
BeginPlay(server-side; the existing top-of-functionif (!IsAuthority()) return;already gates everything below it, including the level-up bind), register two handlers on the owning ASC'sGenericGameplayEventCallbacks(ASC resolved via the newGetOwnerASC()helper →ACradlPlayerState::GetCradlASC()):- [x]
Event.Skill.Succeeded→HandleSkillEvent. - [x]
Combat.Event.Kill→HandleKillEvent. - [x] (Phase 6 will add the
Event.Interactsubscription for Interact tasks.Action.Trigger.Quest.TurnInis consumed byUTurnInQuestItemAbility, not subscribed by the component.Action.Trigger.Quest.TalkTois a menu-verb tag — not a subscription channel.)
- [x]
- [x] Unregister on
EndPlay(newly overridden):Find(notFindOrAdd) the channel andRemoveeach storedFDelegateHandle. Handles are members (SkillEventHandle/KillEventHandle). - [x]
HandleSkillEvent(const FGameplayEventData* EventData)— Source/CRADL/Quests/QuestComponent.cpp: (Branching decision — pointer, not reference. The doc'sconst FGameplayEventData&is reconciled toconst FGameplayEventData*: the value type stored inUAbilitySystemComponent::GenericGameplayEventCallbacksisFGameplayEventMulticastDelegate, declaredOneParam(const FGameplayEventData*). The pointer signature is mandatory to bind, and matches the canonical subscriberUCradlVisualStateComponent::HandleSkillRollSucceededEvent. Applies toHandleKillEventhere andHandleInteractEventin Phase 6 — same pointer signature.) - [x] For each active quest, inspect
Tasks[CurrentTaskIndex]. - [x] If
TaskType == Quest.TaskType.GatherandEventData->InstigatorTags.HasTag(Action.Gathering): castEventData->OptionalObjecttoUGatheredItemPayload; ifItemIdmatches the task'sItemId, advance byPayload->Count. - [x] If
TaskType == Quest.TaskType.CraftandEventData->InstigatorTags.HasTag(Action.Crafting): castEventData->OptionalObjecttoURecipeDefinition; ifGetFName()matches the task'sRecipeId, advance by1. - [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.GoblinmatchesEnemy.Family.Goblin.Warrior). - [x] On match: advance by
1. - [x] Mid-iteration mutation guard (refinement, not in the original task list).
AdvanceTaskcan complete a quest, whichRemoveAts it fromActiveQuests— mutating the array being iterated. Both handlers collect matched(QuestId, TaskIndex, Delta)tuples into a smallTInlineAllocator<4>array during the scan, then callAdvanceTask_Authorityin a second pass. BecauseAdvanceTaskre-finds byQuestIdand re-validatesTaskIndex == CurrentTaskIndex, a snapshot index made stale by an earlier advance simply no-ops rather than mis-advancing. CallsAdvanceTask_Authoritydirectly (already on authority — no need to re-route through the publicAdvanceTask'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 toActiveQuests. Implemented asUnlockQuestthenStartQuest(no new component API):UnlockQuest_Authorityalready skips the requirements gate — onlyRunUnlockSweepchecks requirements — so unlock-then-start is the requirement bypass. Both self-route through ordered/reliableServer_*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
TestGatherquest: one Gather task{ItemId = "Bronze_Ore", TargetCount = 5}.Debug_SeedQuest TestGather. Gather from a Bronze-Ore node five times. ObserveOnQuestProgressChangedfires per gather; on the fifth, the task auto-advances →OnQuestCompletedfires → rewards (Phase 3) deliver. - Author
TestCraftquest: one Craft task{RecipeId = "BronzeBar_Recipe", TargetCount = 3}. Craft three Bronze Bars. Observe advance + complete + rewards. - Author
TestKillquest: 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 byAQuestGiverActor's default ContextAction for label routing and future dialog-ability binding. The quest component does not subscribe to this tag — it subscribes toEvent.Interactper Phase 4. Per QUEST_SYSTEM.md "Tag Taxonomy". - [x]
Action.Trigger.Quest.TurnIn—.ini+ C++ symbol (referenced byAQuestGiverActor::GatherActionsandUTurnInQuestItemAbility). This is a non-defaultContextActionand is not observed viaEvent.Interact— it has its own action-tag channel because turn-in is an explicit, distinct verb with item-consumption semantics. - [x]
Interactableroot +Interactable.NPCsubspace added (.inionly; not C++). Per-giver leaves (Interactable.NPC.<NpcName>) land as content does. TheInteractable.*namespace is the generic identity surface for anyIInteractable(per QUEST_SYSTEM.md "Tag Taxonomy");Interactable.NPC.*is the conventional subspace for NPC quest givers. - [x]
Quest_TaskType_InteractC++ symbol — Source/CRADL/CradlGameplayTags.h +.cpp: the.inileaf exists from Phase 0;HandleInteractEventis the first runtime reference by name (Phase 5 added theGather/Craft/Eliminatesiblings the same way). - [x]
Quest_TaskType_TurnInC++ symbol added too (deviation from "TurnIn stays .ini-only"). Why: the Phase-6UQuestRegistryreverse-index must filter Interact/TurnIn tasks, which references the TurnIn task-type tag by name from runtime C++ — exactly the triggerfeedback_gameplay_tag_decl_minimalkeys on. A string-keyedRequestGameplayTagin the registry's hot build path would be the alternative; the symbol is cleaner. (The component still never matchesTurnInby name for progression —UTurnInQuestItemAbilityowns turn-in via the separateAction.Trigger.Quest.TurnInaction channel.) - [x]
IQuestGiverbody — 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 —AQuestGiverActoris the first implementer. - [x]
UQuestRegistryreverse-index — Source/CRADL/Quests/QuestRegistry.h +.cpp: - [x] In
RegisterDefinition(reached fromEnsureBuiltper loaded def), walk the definition'sTasks[].InteractableTagand (for Interact/TurnIn tasks with a valid tag) populateTMap<FGameplayTag, TArray<FName>> QuestsByInteractableTagwithAddUnique(per-quest dedupe). Cleared inDeinitializealongsideDefinitionsByName. - [x] New accessor:
const TArray<FName>& GetQuestsByInteractableTag(FGameplayTag InteractableTag) const;Returns astatic 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 runtimeHandleInteractEventmatch 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]
AQuestGiverActor— Source/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 theInteractable.*namespace (per QUEST_SYSTEM.md "Quest Giver Actor"). A guardedBeginPlaywarning 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 theInteractableTagfield. (This is the runtime identity surface — used byEvent.Interactmatching and by TurnIn host matching.) - [x]
IInteractable::GetPrimaryActionTag()returnsAction.Trigger.Quest.TalkTo(menu-verb tag for label routing — not the quest subscription channel). - [x]
IInteractable::GetSourceLabel()returns a display name (per-instanceFText DisplayNameUPROPERTY);GetInteractLabel()returns "Talk to". Also overridesCanInteractwith anInteractionDistanceSquaredrange gate (parity withAStoreTerminal— 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 theAction.Trigger.Quest.TalkToevent (mirroringAStoreTerminal's primary-verb fire). This is the rawInput_Interact-key path; the primary left-click / queued-interact path funnels throughDispatchContextAction, which fires both the TalkTo event and the secondaryEvent.Interactthat 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
FContextActionwithActionTag = Action.Trigger.Quest.TalkTo,bDefault = true,SourceActor = this. (ThebDefault = trueflag is what licenses the dispatch site to fire the secondaryEvent.Interactfrom Phase 4 — this is the contract glue.) - [x] Read the local pawn's
UQuestComponent::GetActiveQuests(); for the first active quest whose current task isTurnIn,task.InteractableTag == this->GetInteractableTag()(exact), andInventory.CountOf(task.ItemId) >= task.ItemCount, append a single TurnInFContextAction(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] Always append a TalkTo
- [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] Call
- [x]
UTurnInQuestItemAbility— Source/CRADL/Abilities/TurnInQuestItemAbility.h +.cpp(new): - [x] Net policy:
LocalPredictedper the modal/transact pattern inUTransactItemAbility. Mutation is guarded to authority (ActorInfo->IsNetAuthority()): the client predicts activation (responsive menu dismissal) but does not mutate, becauseUQuestComponent::AdvanceTaskself-routes through aServer_RPC off-authority and a predicted client call would double-advance on the server. (Deviation from "predict the mutation like Transact" — Transact'sInv->RemoveItemsis predict-safe because it doesn't RPC;AdvanceTaskdoes.) - [x] Server execution: re-validate by re-deriving the target task from
EventData->Target(the hostIInteractable::GetInteractableTag()) + the player's active TurnIn tasks + inventory — first satisfiable TurnIn task hosted here wins (matches the single entryGatherActionssurfaced). No carried payload object. Mirrors the server-side re-check spirit ofUTransactItemAbility. - [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 theAction.Trigger.Quest.TurnInGameplayEvent trigger resolves — same asUTransactItemAbility/UGatherAbility. - [x] Subscriber wiring — Source/CRADL/Quests/QuestComponent.cpp:
- [x] On
BeginPlay(server-side; the existingif (!IsAuthority()) return;already gates the binds), register one new handler on the owning ASC'sGenericGameplayEventCallbacks:Event.Interact→HandleInteractEvent. Added a thirdFDelegateHandle InteractEventHandlemember, removed inEndPlayalongside the Phase-5 handles. (Action.Trigger.Quest.TurnInis consumed by the ability — the component does not subscribe; the counter advances inside the ability.) - [x]
HandleInteractEvent(const FGameplayEventData* EventData)(pointer signature, mirroringHandleSkillEvent/HandleKillEvent, including the collect-then-advance mid-iteration guard): read the interactable tag(s) fromEventData->InstigatorTags(do not castEventData->TargettoIQuestGiver). For each active quest whose current task isQuest.TaskType.Interact, hierarchical-match the task'sInteractableTagagainst the event's instigator tags (HasTag—Interactable.NPCtask matches anInteractable.NPC.Cookgiver, mirroringEnemy.Family.*); on match,AdvanceTask(QuestId, CurrentTaskIndex, 1). - [x] Validator extension — Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.cpp:
- [x] Interact/TurnIn task
InteractableTagnon-empty and under theInteractable.*namespace (sharedValidateInteractableTaglambda; item-id collection hoisted above the task loop and reused). - [x]
AQuestGiverActortag check is a runtimeBeginPlaywarning, NOT an editor validator (deviation). Why:UEditorValidatorBasevalidates assets (DataAssets/Blueprints), butInteractableTagis anEditAnywhereper-placed-instance value — a BP-CDO validator wouldn't see the per-instance value designers actually set, andEditorValidatordoesn't validate placed level actors. Themeta=(Categories="Interactable")picker constrains authoring to the namespace; a left-default placed instance is caught by the guarded (#if !UE_BUILD_SHIPPING)AQuestGiverActor::BeginPlaywarning. On otherIInteractableimplementers the tag remains optional (default invalid = "not quest-trackable"). A formal Map-Check (CheckForErrors) validator is a possible future hardening. - [x] TurnIn
ItemIdresolves to the items DataTable (gated onbItemsLoaded);ItemCount >= 1.
Footguns.
- Don't subscribe to
Action.Trigger.Quest.TalkTofrom the quest component. That's a menu-verb tag (label routing + future dialog binding). The quest subscription channel isEvent.Interact, which the central dispatch fires automatically alongside anybDefaultaction whose source has a validGetInteractableTag(). 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 viaEvent.Interactand that's all. Event.Interactonly fires for the primary action. Non-defaultContextActions(Turn In, Examine, Prospect) have their ownActionTagchannels and do not double-fire asEvent.Interact. Per QUEST_SYSTEM.md "Interact Task" Footguns.GatherActionsruns on the local pawn. Server re-validation lives insideUTurnInQuestItemAbility. 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 aSourceActoraction the dispatch setsOptionalObject = IInteractable::GetActionPayload(ActionTag), andGetActionPayloadis 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:GatherActionssurfaces a single TurnIn entry (first satisfiable turn-in task at this giver), andUTurnInQuestItemAbilityre-derives the same first-match server-side fromEventData->Target'sGetInteractableTag()+ 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 onEventData->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 = 5means two TurnIn invocations complete the task; gate the action onInventory.HasCount >= ItemCount, not>= TargetCount. - Hierarchical match is intentional in
HandleInteractEvent. A task taggedInteractable.NPC.Goblinmatches a giver withInteractable.NPC.Goblin.Smith. Designers wanting strict-leaf must author the leaf as the task tag (same rule asEnemy.Family.*in Eliminate). Per QUEST_SYSTEM.md "Interact Task" Footguns. GetOfferedQuestsfilters 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'sUQuestComponentand includes only quests whose next-action site is this giver. Per QUEST_SYSTEM.md "Quest Giver Actor" Footguns.
Verification.
- Setup (content step): add
UTurnInQuestItemAbilitytoACradlPlayerState'sDefaultAbilitieslist (BP) so it is granted; otherwise theAction.Trigger.Quest.TurnInevent triggers nothing. Add theInteractable.NPC.CooksAssistantleaf in the editor (theInteractable/Interactable.NPCroots ship in.ini). - Place an
AQuestGiverActorin PIE withInteractableTag = 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.TalkToand the secondaryEvent.Interact(withInstigatorTagscontainingInteractable.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.Interactdoes not fire (non-default action; onlyAction.Trigger.Quest.TurnInfires) per the contract. - Hierarchical match: place a second giver with
InteractableTag = Interactable.NPC.CooksAssistant.Sub; author a quest task onInteractable.NPC.CooksAssistant; interact with the sub-giver → task advances (hierarchical match works). GetOfferedQuestsfilter sanity: complete the Cook's Assistant quest; reopen the cook's menu; mock a UI consumer that callsGetOfferedQuests→ 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
EnemyFamilyTagfor Eliminate resolves underEnemy.Family.*— already satisfied (Phase 1). TheFGameplayTagUPROPERTY can only hold a tag the manager knows, and the existing Eliminate branch already gates onMatchesTag(EnemyFamilyRoot). No change this phase. - [x] Per-task
RecipeIdfor Craft resolves via AssetRegistry scan — NEW this phase. Added a file-localCollectKnownRecipeIds()(mirrors theLoadoutDefinition/LoadoutModifierDefinition/PrerequisiteQuestsbest-effort primary-asset scans —URecipeDefinitionis aUPrimaryDataAssetwhoseAssetName == GetFName(), the FName Phase-5HandleSkillEventmatchesRecipeIdagainst). Craft branch now flags aRecipeIdthat resolves to noRecipeDefinitionasset (no "loaded" gate, consistent with the other primary-asset scans). - [x] Cross-asset best-effort:
PrerequisiteQuestsreferences resolve to otherQuestDefinitionassets — already satisfied (Phase 2) viaCradlValidationHelpers::CollectKnownQuestIds(+ self-prereq +Nonerejection). No change this phase. - [x] One-path-per-reward-axis rule — already satisfied (Phase 3): the reward loop's
SeenRewardIdentitiesset 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 shippedDebug_PrintQuests, which dumps theUnlocked/Active/Completedrosters (with per-task counters). Adding a second roster command would only shadow it.Debug_PrintQuestsis 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'scounter/target+ type with the current task marked*, and (when completed) each reward manifest entry via a file-localDescribeManifestEntry. - [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 publicCompleteQuestalways runs the reward pipeline and requires an active quest — neither fits "complete a not-yet-started prereq with no rewards." Cycle-guarded via aVisitedset seeded with the target. - [x]
Debug_ResetQuest FName QuestId— wipes the quest from all three lifecycle lists + itsPendingRewards. New self-routingUQuestComponent::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_SHIPPINGcheat block per CLAUDE.md (same pattern as every otherDebug_*). - [x] Debug overlay — Source/CRADL/Player/CradlPlayerController.cpp (branching decision — controller, not QuestComponent):
- [x]
Debug_ToggleQuestOverlayflips abShowQuestOverlayflag; a newACradlPlayerController::PlayerTickoverride callsDrawQuestOverlay()while on, posting one stable-keyedGEngine->AddOnScreenDebugMessageline 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-replicatedGetActiveQuests()+ the client-availableUQuestRegistry, so it belongs where the player's input/HUD already lives — the controller, alongside every otherDebug_Toggle*/on-screen-debug cheat (e.g. theAddOnScreenDebugMessagesites at the controller's existing debug helpers). Putting it onUQuestComponentwould have meant enabling per-frame component ticking purely for debug. The flag/helper are#if !UE_BUILD_SHIPPING-guarded; thePlayerTickoverride stays unconditional (vtable) with a guarded body. Per CLAUDE.md'sAddOnScreenDebugMessage-wrapped-in-#ifrule. - [x] Resolve outstanding Open Questions before launch (project-tracked, not Claude-tracked):
- [x] QUEST_SYSTEM.md #1 — RESOLVED (drop) by the user (2026-05-20); implemented in Phase 3's
ApplyPersistedStatedrift check. Contract OQ #1 already reflects this. - [x] QUEST_SYSTEM.md #2 — RESOLVED in this doc: bag → bank →
PendingRewardsqueue + overflow toast; reconciliation drains on next startup. Implemented in Phase 3. - [x] QUEST_SYSTEM.md #7 — RESOLVED 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 inInteractable.*. - [x] QUEST_SYSTEM.md #9 — kept as the v1 sketch:
UGatheredItemPayloadstays aUObjectcarrying{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 CooksAssistant2.Debug_TestEligibility CooksAssistant→ logs pass. 3.Debug_StartQuest CooksAssistant4. Gather + interact + turn in via normal world play. 5.Debug_QuestStatus CooksAssistantafter 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 byUAcceptQuestAbility's trigger and the dialogue widget's dispatch). DevComment perfeedback_gameplay_tag_decl_minimal. - [x]
Action.Trigger.Quest.Advance(.ini + C++ symbol — referenced byUAdvanceQuestTaskAbility's trigger and the dialogue widget's step-gated (DialogueKey) response dispatch). The dialogue-only "talk to NPC" advance verb replacingTalkTo/Progress. - [x] Remove
Action.Trigger.Quest.TalkTo— C++ symbol +.inientry deleted;BeginInteract/GetPrimaryActionTag/GatherActionsno longer fire it. v1 leftover, stripped per DIALOGUE_SYSTEM.md. - [x] (
Action.Modal.Dialogue+Action.Trigger.Modal.Dialogueare declared by the dialogue impl doc; the giver references the latter — consumed, not re-declared here.) - [x]
UQuestAcceptRequest— Source/CRADL/Abilities/QuestAcceptRequest.h (new): - [x]
UQuestAcceptRequest : public UObject { UPROPERTY() FName QuestId; }— transient payload mirroringUTransactRequest(Source/CRADL/Abilities/TransactRequest.h), placed onFGameplayEventData::OptionalObjectby the dialogue widget. - [x]
UQuestAdvanceRequest— Source/CRADL/Abilities/QuestAdvanceRequest.h (new): - [x]
UQuestAdvanceRequest : public UObject { UPROPERTY() FName QuestId; UPROPERTY() FName DialogueKey; }— transient payload for the step-gated talk-to advance;QuestIdaddresses the exact quest,DialogueKeythe talk-to beat. - [x]
UAcceptQuestAbility— Source/CRADL/Abilities/AcceptQuestAbility.{h,cpp} (new): - [x] Trigger:
FAbilityTriggerData { Action.Trigger.Quest.Accept, EGameplayAbilityTriggerSource::GameplayEvent }.NetExecutionPolicy = LocalPredicted(mirrorUTransactItemAbility). - [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): readQuestIdfromEventData.OptionalObject(UQuestAcceptRequest); resolve the source giver fromEventData.Target(IQuestGiver); confirm the quest isOffer-eligible at that giver (reusesEvaluateQuestRole); on pass callQuests->UnlockQuest(QuestId)thenQuests->StartQuest(QuestId). On fail,PostServerMessage(Message.Source.Quest, ...). - [x] Predict, don't mutate. Mutation guarded behind
IsNetAuthority; the component mutators self-route throughServer_*. - [x] Add
UAcceptQuestAbilitytoBP_CradlPlayerState.DefaultAbilities(same asUTurnInQuestItemAbility). - [x]
UAdvanceQuestTaskAbility— Source/CRADL/Abilities/AdvanceQuestTaskAbility.{h,cpp} (new): The dialogue-driven "talk to NPC" advance — the real replacement forTalkToprogression. - [x] Trigger:
FAbilityTriggerData { Action.Trigger.Quest.Advance, EGameplayAbilityTriggerSource::GameplayEvent }.NetExecutionPolicy = LocalPredicted.ActivationRequiredTags = { Action.Modal.Dialogue }. - [x] Payload
UQuestAdvanceRequest { FName QuestId; FName DialogueKey; }(new; mirrorsUQuestAcceptRequest) onEventData.OptionalObject— names the exact quest + talk-to beat. Server body (guardedIsNetAuthority): resolve the giver fromEventData.Target(IInteractable::GetInteractableTag()); resolve the payload's quest byQuestId(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 whoseDialogueKeyequals the payload key; read itsCurrentTaskIndex; callQuests->AdvanceTask(QuestId, CurrentTaskIndex, 1). - [x] Pass
CurrentTaskIndex, never an arbitrary index —AdvanceTaskrejectsTaskIndex != CurrentTaskIndex(per QUEST_SYSTEM.md "Task Chain"). - [x] Predict, don't mutate (same as Accept). Added to
BP_CradlPlayerState.DefaultAbilities. - [x]
AQuestGiverActordialogue wiring — Source/CRADL/Quests/QuestGiverActor.{h,cpp}: - [x] Add
UPROPERTY(EditAnywhere) TObjectPtr<UDialogueDefinition> Dialogue;(level-authored; actor staysbReplicates = false). Also addedGetMenuHeader()returningDisplayName. - [x] Point both
BeginInteractandGetPrimaryActionTag()atAction.Trigger.Modal.Dialogue, mirroringAStoreTerminal. One outcome — dialogue opens — identical across the interact-key path (BeginInteract) and the click path (the defaultbDefaultaction throughDispatchContextAction). PayloadTarget = thissoUDialogueWidget::GetModalContext()->Targetresolves the giver. - [x] Remove
Action.Trigger.Quest.TalkTo— the dead no-op fire is gone fromBeginInteract/GetPrimaryActionTag/GatherActions; symbol +.inientry deleted. - [x] Leave
Event.Interactalone. ThebDefaultaction still funnelsEvent.Interactfor non-dialogue Interact tasks; not duplicated intoBeginInteract. Giver talk-to progression ridesAction.Trigger.Quest.Advanceinstead. - [x] Quest-role predicate for dialogue node visibility — Source/CRADL/Quests/QuestGiverInterface.h / QuestGiverActor.cpp:
- [x]
EvaluateQuestRole(const ACradlPlayerState*, FName QuestId) const → EDialogueQuestRoleadded to theIQuestGiverseam (enum forward-declared in the interface, full include in the impl) and implemented onAQuestGiverActorreusing the exactGetOfferedQuestslifecycle checks + theGatherActionsturn-in predicate. The widget calls this; it does not re-derive lifecycle state. - [x] Validator: no
UCradlQuestDefinitionValidatorchange this phase — the dialogue-asset validation landed asUCradlDialogueDefinitionValidatorin lockstep withUDialogueDefinition.
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
AQuestGiverActorwith an authoredUDialogueDefinitioncontaining anOffer-role node for a startable quest. Walk up, primary-interact → dialogue modal opens (NPC header fromGetMenuHeader). Pick the Accept response →Debug_QuestStatusshows the quest promoted to Active. The same interact still advances any matchingInteracttask (Event.Interactfired 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+ matchingDialogueKey) is visible; pick it →Action.Trigger.Quest.Advancefires, the task advances (Debug_QuestStatusshowscounter↑;Debug_DumpDialogueRolesshows the livestepKey). Confirm the bare interact (just opening dialogue) does not advance it —HandleInteractEventskips 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
UTurnInQuestItemAbilityonAction.Modal.Dialogue. It must activate from both the context menu and the dialogue page; only the newUAcceptQuestAbilitycarries the modal gate. (Contract: DIALOGUE_SYSTEM.md "Turn-In Integration".) Event.Interactstill 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
Offernode when ineligible is owner-local UX, not the gate;UAcceptQuestAbilityre-checksRequirements.IsSatisfiedByon authority (feedback_p2p_replication_audit). UnlockQuestthenStartQuest, in order.StartQuest_Authorityrequires the quest inUnlockedQuests; unlock-then-start is the same requirement-bypassDebug_SeedQuestuses.UnlockQueston 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 == nullptrand 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, notUQuestComponent. 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+UDialogueDefinitionfrom DIALOGUE_SYSTEM.md /DIALOGUE_IMPLEMENTATION.md. Land the dialogue scaffolding before Phase 8 verification. TheAction.Modal.Dialogue/Action.Trigger.Modal.Dialoguetags are owned by the dialogue docs; Phase 8 declares onlyAction.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 theInteractable.*identity surface — contractually possible from Phase 4 onward (each terminal overridesIInteractable::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.