CRADL Dialogue System
Companion to ARCHITECTURE.md (the foundation — esp. #15 modal-as-ability and #18 widget-dispatch), and QUEST_SYSTEM.md (the first consumer). This document is the contract the Dialogue subsystem must satisfy — its data shape, the modal it engages, how its widget dispatches actions, how quest offers/turn-ins surface inside a conversation, and its replication/authority story. Implementation patterns and phase ordering live in DIALOGUE_IMPLEMENTATION.md; what's here does not change without a deliberate edit to this file.
Unreal ships no canonical dialogue framework. CRADL already owns every primitive a simple conversation needs: modals are GAS abilities (ARCHITECTURE.md #15), widgets dispatch via ASC gameplay events (ARCHITECTURE.md #18), content is UPrimaryDataAsset + validator-in-lockstep, and the quest layer already exposes the offer/turn-in surface (IQuestGiver::GetOfferedQuests, UTurnInQuestItemAbility). Dialogue is therefore almost entirely content + one widget + two thin abilities — it adds verbs, not foundation.
North Star
Dialogue is OSRS-derived: a static, authored tree of NPC lines and player responses. No scripting language, no runtime branching beyond "pick a response," no per-word conditionals. A conversation is a UDialogueDefinition asset the NPC actor references directly; talking to the NPC opens a modal page that walks the tree client-side. The only authoritative things a conversation can do are dispatch tagged gameplay events ("accept this quest," "turn in these items") — dialogue fires tags, GAS abilities receive them. This keeps dialogue decoupled from quest internals and reusable for any future tagged verb (shops, lore unlocks, teleports).
Three rules carry the weight:
- Dialogue reuses foundation; it does not build it. The conversation modal is a
UCradlModalAbilitysubclass — one tag pair, one BP ability, one widget, three registrations, zero new modal C++ (per ARCHITECTURE.md #15). The widget dispatches every action as aFGameplayEventDatathroughASC->HandleGameplayEvent(per ARCHITECTURE.md #18) — it never callsUQuestComponentor a controller RPC. - Dialogue is cosmetic; effects are authoritative. Which node you're on is local UI state — never replicated, never server-authoritative. Every consequential action leaves the widget as a gameplay event and lands in a server-authoritative ability that re-validates. The conversation is a view over authoritative state, not a holder of it.
- Quests surface through quest-binding responses, not a condition language. A
FDialogueResponsemay carry aFDialogueQuestBinding { QuestId, Role }. Its visibility is driven by the existing quest-state surface (IQuestGiver::GetOfferedQuests/EvaluateQuestRole/UQuestComponent), and the response'sEffectEventTagfires the matchingAction.Trigger.Quest.*event. The gate sits on the choice the author writes — not on a downstream node — so visibility is local to the option and its action is an independent field. Authors annotate the option; the system decides which are live. No general-purpose predicate vocabulary (the rejected "authored + conditions" fork — see Quest-Binding Responses Footguns).
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Definition asset | UDialogueDefinition : UPrimaryDataAsset — FName identity, matches UQuestDefinition pattern |
Dialogue Definition |
| Node shape | FDialogueNode flat struct — NodeId + speaker line + TArray<FDialogueResponse>; the optional quest binding lives on the response, not the node |
Dialogue Definition |
| Branching | Responses name a NextNodeId within the same asset; None ends the conversation |
Dialogue Definition |
| Registry | None. The giver holds a direct TObjectPtr<UDialogueDefinition>; no FName lookup, so no registry subsystem |
Dialogue Definition, Open Questions |
| Engagement | Action.Modal.Dialogue + Action.Trigger.Modal.Dialogue (new) — a UCradlModalAbility BP subclass |
Dialogue Modal |
| Widget | UCradlActivatableWidget page on UI.Layer.GameMenu; reads source actor via GetModalContext() |
Dialogue Widget & Dispatch |
| Dispatch | Widget fires ASC->HandleGameplayEvent(Action.Trigger.X, &Payload) only (ARCH #18) |
Dialogue Widget & Dispatch |
| Effects | A response carries an optional FGameplayTag EventTag; dialogue fires it, an ability receives it |
Dialogue Widget & Dispatch |
| Quest offer | Quest-binding response Role=Offer; dispatches Action.Trigger.Quest.Accept (new) |
Quest-Binding Responses |
| Accept ability | UAcceptQuestAbility (new, LocalPredicted) — re-validates eligibility, calls UQuestComponent::StartQuest server-side |
Accept Quest Ability |
| Quest turn-in | Quest-binding response Role=TurnIn; dispatches the existing Action.Trigger.Quest.TurnIn → UTurnInQuestItemAbility |
Turn-In Integration |
| Talk-to advance | Step-gated response (Role=None + DialogueKey); dispatches Action.Trigger.Quest.Advance (new) → UAdvanceQuestTaskAbility. The key disambiguates multiple talk-to beats at one giver; replaces the stripped TalkTo/Progress concepts |
Advance Quest Task Ability |
| Giver opens dialogue | AQuestGiverActor's bDefault action ActionTag = Action.Trigger.Modal.Dialogue (the AStoreTerminal pattern) — one outcome, key and click; Event.Interact still fires orthogonally |
Quest Giver Integration |
| Authority | Conversation state local/cosmetic; accept/turn-in are server-authoritative abilities | Authority & Replication |
| Validator | New UCradlDialogueDefinitionValidator mirroring UCradlQuestDefinitionValidator |
Validator |
| Tag namespaces | Action.Modal.Dialogue, Action.Trigger.Modal.Dialogue, Action.Trigger.Quest.Accept, Action.Trigger.Quest.Advance (all new); Action.Trigger.Quest.TalkTo removed |
Tag Taxonomy |
| Open questions | Dialogue registry, voice/portrait assets, Event.Dialogue.* listeners, multi-NPC speakers, localization round-trip |
Open Questions |
Dialogue Definition
Rule: A conversation is authored as a single UDialogueDefinition : UPrimaryDataAsset instance. Identity is the asset FName, resolved via FPrimaryAssetId(TEXT("DialogueDefinition"), GetFName()) — the same pattern UQuestDefinition and USpellDefinition use. The definition is immutable at runtime; the quest giver holds a direct reference to it and the widget reads it as read-only data.
Why: Per the data-asset grounding, every CRADL definition follows UPrimaryDataAsset + FName identity + validator-in-lockstep (UQuestDefinition, USpellDefinition, USkillDefinition). Dialogue grows with content and is never referenced by individual leaf from C++, so FName identity is the right cost profile (matches the QUEST_SYSTEM.md "Quest Definition" fork rationale). No runtime registry: unlike quests (looked up by FName from save data and reverse-indexed by interactable tag), a dialogue asset is reached only through the actor's direct reference. A UDialogueRegistry subsystem would be ceremony with no consumer; it is deliberately omitted (flagged in Open Questions #1 so a future cross-reference need can reintroduce it).
Implementation surface:
- File: Source/CRADL/Dialogue/DialogueDefinition.{h,cpp} (new).
- Class: UDialogueDefinition : UPrimaryDataAsset. Overrides GetPrimaryAssetId() to return FPrimaryAssetId(TEXT("DialogueDefinition"), GetFName()) (mirrors UQuestDefinition::GetPrimaryAssetId).
- Fields (the contract; type names sketch the shape, not verbatim signatures):
- FText SpeakerName — default NPC name shown in the header (per-node override optional). Present-tense American English per CLAUDE.md and feedback_american_english.
- TSoftObjectPtr<UTexture2D> Portrait — optional chat-head art; soft ref so the definition doesn't pull art at load (mirrors USpellDefinition::Icon).
- FName RootNodeId — entry node (default: the first node's id).
- TArray<FDialogueNode> Nodes — the tree, addressed by NodeId.
- FDialogueNode (flat struct, mirrors the FQuestTask flat-discriminator decision in QUEST_SYSTEM.md "Task Chain"):
- FName NodeId — unique within this asset; the branch target key.
- EDialogueSpeaker Speaker — { NPC, Player }; drives header/alignment only (cosmetic).
- FText Line — the spoken text (meta=(MultiLine=true)).
- TArray<FDialogueResponse> Responses — player choices. Zero responses = terminal node (a "Continue"/close button ends the conversation). One response with empty label = linear continue. The node carries no quest binding; the binding lives on each response (see Quest-Binding Responses).
- FDialogueResponse:
- FText Text — the choice label.
- FName NextNodeId — target node; NAME_None ends the conversation.
- FDialogueQuestBinding QuestBinding — optional visibility gate (see Quest-Binding Responses). Empty Role = always shown. When Role != None, this response renders only while the giver's live role for QuestId matches. The gate lives on the choice itself, so the author reads a node's Responses array and sees the whole menu's logic inline — no chasing a downstream node.
- FGameplayTag EffectEventTag — optional action, an axis independent of the gate above; when set and the response is chosen, the widget dispatches this tag as a gameplay event (see Dialogue Widget & Dispatch). This is the general replacement for the stripped TalkTo concept — a hook fired by selecting a dialogue option. It can carry a generic tag (even Event.Interact), a quest verb, or be left empty (a quest-gated branch that only navigates to flavor, no verb). For a quest-binding response the conventional pairing is Offer↔Accept, Progress↔Advance, TurnIn↔TurnIn — validator-warned, not forced, since gate and action are separate fields.
- +PrimaryAssetTypesToScan entry in DefaultGame.ini for DialogueDefinition over /Game/Definitions/Dialogues — mirrors the existing spell/quest/recipe entries.
Footguns:
- The CDO is shared. Treat the definition as read-only at runtime; never mutate a UDialogueDefinition* from gameplay code (same rule as UQuestDefinition / UEnemyDefinition).
- NextNodeId is intra-asset only. A response cannot jump into a different dialogue asset. Cross-dialogue links are out of v1 scope; they would be the trigger to reintroduce a registry (see Open Questions #1).
- Don't add a per-quest leaf tag for dialogue identity. Identity is the asset FName. Per feedback_push_back_on_duplicate_identity, a Dialogue.<Name> tag would pay the tag-bookkeeping tax for nothing FName doesn't already cover.
Related: Source/CRADL/Quests/QuestDefinition.h, Source/CRADL/Combat/SpellDefinition.h, feedback_gameplay_tag_decl_minimal.
Dialogue Modal
Rule: A conversation is a modal engagement, implemented as a UCradlModalAbility Blueprint subclass with ModalTag = Action.Modal.Dialogue and a trigger on Action.Trigger.Modal.Dialogue. Opening it cancels skills and other modals; movement / combat / skill / stun / death cancel it — all inherited from the base, zero new C++. The modal is LocalPredicted (the base's policy), so Action.Modal.Dialogue reaches the server peer for any server-authoritative gating.
Why: ARCHITECTURE.md #15 names "NPC dialogue" explicitly as a UCradlModalAbility case. The grounding confirmed UCradlModalAbility is fully polymorphic over its tag — banking, shop, crafting, loadout-swap, and skill-lineage are all the same machinery branched only on tag. A dialogue modal is a content-only addition: one tag pair, one BP ability, one widget, and two config-map entries.
Implementation surface:
- New BP ability CBA_Modal_Dialogue subclassing UCradlModalAbility (Source/CRADL/Abilities/CradlModalAbility.h) — set ModalTag = Action.Modal.Dialogue, add trigger Action.Trigger.Modal.Dialogue. Added to BP_CradlPlayerState.DefaultAbilities.
- New widget class registered in WBP_HUDLayout.ModalPageClasses: Action.Modal.Dialogue → WBP_DialoguePage (Source/CRADL/UI/CradlHUDLayout.h).
- Optional hotkey/route: dialogue is opened by interacting with an NPC, not by a hotkey, so no InputToModalRoute entry is needed in v1 (unlike Action.Modal.QuestLog). The route map (Source/CRADL/Player/CradlPlayerController.h) is untouched.
- The modal's trigger FGameplayEventData carries Target = AQuestGiverActor (the NPC) so the widget can recover it via GetModalContext() (see below).
Footguns:
- Cache the trigger payload before publishing the modal tag. Per the grounding, UCradlModalAbility::ActivateAbility caches CachedTriggerData before AddLooseGameplayTag(ModalTag) fires the HUD router synchronously. This is base-class behavior; a dialogue subclass must not reorder it (it shouldn't override ActivateAbility at all).
- HUD subscribes per-leaf. Per ARCHITECTURE.md #15 and feedback_perception_service_on_parent_composite's sibling lesson, the router binds Action.Modal.Dialogue directly, not the Action.Modal parent — registering on the parent loses the leaf identity.
- Don't model dialogue as a parallel state machine on the controller or HUD. The modal is the state. Conversation node position lives in the widget; engagement lives in the ability tag.
Related: ARCHITECTURE.md #15, Source/CRADL/Abilities/CradlModalAbility.h, Source/CRADL/Store/StoreTerminal.h (the Action.Trigger.Modal.Shop precedent).
Dialogue Widget & Dispatch
Rule: WBP_DialoguePage is a UCradlActivatableWidget subclass on UI.Layer.GameMenu. On activation it recovers its source NPC via GetModalContext()->Target, reads the UDialogueDefinition off that actor, and walks the node tree locally. Every consequential player choice is dispatched as a gameplay event: the widget packages a FGameplayEventData and calls ASC->HandleGameplayEvent(Response.EffectEventTag, &Payload). The widget never calls UQuestComponent, AQuestGiverActor, or a controller RPC directly. Its entire outward surface is {APlayerState, UAbilitySystemComponent, FGameplayTag} per ARCHITECTURE.md #18.
Why: ARCHITECTURE.md #18 is the load-bearing rule the user called out: authority lives in one place per verb, reachable from any tag publisher (UI click, hotkey, debug, automation). The grounding confirmed the established shape — UStoreWidget::DispatchTransact, UCraftingMenuWidget::DispatchCraft, UBankWidget::DispatchDepositInventory all package context into FGameplayEventData and fire HandleGameplayEvent against a LocalPredicted ability that owns validation + authority. Dialogue is the same dispatcher pattern with a different set of trigger tags.
Implementation surface:
- File: Source/CRADL/UI/DialogueWidget.{h,cpp} (new). Class UDialogueWidget : UCradlActivatableWidget (Source/CRADL/UI/CradlActivatableWidget.h).
- Source recovery: in NativeOnActivated, const FGameplayEventData* Ctx = GetModalContext(); then Cast<AQuestGiverActor>(Ctx->Target.Get()) and read its UDialogueDefinition. Header paints from IInteractable::GetMenuHeader() via the base's OnHeaderRefresh (same as the crafting/store pages). Handle the null context case (debug-opened modals have no source).
- Dispatch method (per the DispatchX naming convention in ARCHITECTURE.md #18): DispatchDialogueEffect(FGameplayTag EventTag, UObject* RequestPayload) builds FGameplayEventData { EventTag, Instigator = owning Pawn, Target = source NPC, OptionalObject = RequestPayload } and calls ASC->HandleGameplayEvent(EventTag, &Payload). Private button handlers route to it; nothing else fires events.
- Payload conventions follow ARCHITECTURE.md #18: Target = the NPC actor, Instigator = the Pawn, OptionalObject = a transient request UObject for parameterized verbs (e.g. UQuestAcceptRequest carrying QuestId — see Accept Quest Ability), mirroring UTransactRequest (Source/CRADL/Abilities/TransactRequest.h).
- Node walking is pure widget logic: render Line, render Responses as a choice list, on selection dispatch the optional EffectEventTag then advance to NextNodeId (or end on NAME_None).
Footguns:
- Pages are initialized pre-activation. Per ARCHITECTURE.md #15, any field read in NativeOnActivated (the source NPC, the bound PlayerState) is set inside the router's AddWidget InitFunc, not after. Reading the definition in NativeOnActivated is correct because GetModalContext() resolves the cached trigger data set pre-activation.
- Avoid Slot as a local. Per feedback_slot_shadows_uwidget, any local in this UUserWidget subclass must not be named Slot (use ResponseSlot/NodeSlot). The choice list will likely use UCommonButtonGroupBase — per ARCHITECTURE.md #17, append to any parallel index before AddWidget so the auto-select-first broadcast indexes a valid entry.
- Effect dispatch then advance — order matters for re-derivation. The effect event (accept/turn-in) is fire-and-forget from the widget's view; the ability re-validates server-side. Don't gate the local node advance on the ability's success — the widget has no authoritative result to wait on (conversation is cosmetic per North Star rule 2). If a quest-binding response's server-side action fails (e.g. inventory changed), the next reopen of the dialogue reflects the corrected state via the quest component's replicated arrays.
- Bind any tooltip delegate in NativeOnInitialized. Per feedback_umg_tooltip_delegate_init_timing, ToolTipWidgetDelegate is snapshotted during TakeWidget; binding in NativeConstruct is too late.
Related: ARCHITECTURE.md #17, #18, Source/CRADL/UI/StoreWidget.h, Source/CRADL/UI/CraftingMenuWidget.h, Source/CRADL/UI/CradlActivatableWidget.h.
Quest-Binding Responses
Rule: A FDialogueResponse may carry a FDialogueQuestBinding { FName QuestId; EDialogueQuestRole Role; FName DialogueKey; } where Role ∈ { None, Offer, TurnIn }. The binding has two visibility modes, both evaluated against the existing quest surface (IQuestGiver / UQuestComponent):
- Lifecycle gate (Role = Offer | TurnIn) — shown only when the bound quest is offerable / turn-in-ready at this NPC. These two carry semantics a key can't: Offer is pre-start (no current task to key), and TurnIn carries the item-in-bag check + the consuming ability shared with the context menu.
- Step gate (Role = None + non-empty DialogueKey) — an "advance this talk-to step" option, shown only when the bound quest is active here and its current Interact task's DialogueKey equals this key. This replaces the old coarse Progress role: the key, not a role, says which talk-to beat is live — so one giver can host many distinct talk-to conversations across a quest's chain. A single-beat quest still names a key (e.g. talk); there is no "match any talk-to" mode, so visibility is always exact.
(Role = None + empty DialogueKey = a plain, always-shown narrative response. The non-empty key is precisely what marks a response as a step gate.) Visibility and action (EffectEventTag) remain independent axes: the gate decides whether the option renders; the effect tag decides what selecting it fires. Authors annotate the option; the system decides which are live.
Why bind on the response, not the node (the authoring-locality decision): The gate belongs on the choice the author is writing. Binding it on a downstream node (an earlier draft) forced the gate to act by hiding "the response that leads to the bound node" — a backward, non-local inference with four authoring costs: (1) cause and effect live on different nodes (a hidden option's reason sits on its target); (2) every gated path needs a dedicated structural node just to carry the binding; (3) the always-entered root node can't be gated, so the model needs a carve-out; (4) two responses can't share a target node without both inheriting its gate, so shared/flavor nodes and gated nodes become mutually exclusive. Putting the binding ({QuestId, Role, DialogueKey}) on the response erases all four — a node's Responses array reads as the complete, inline menu logic, and the Accept/Advance payload's QuestId comes off the response rather than "whatever node we're standing on."
Why this model over the rejected forks: It keeps OSRS-grade authored narrative (the NPC says specific lines per quest state) while reusing the already-built IQuestGiver::GetOfferedQuests / EvaluateQuestRole / UQuestComponent state surface for visibility — so there is no general-purpose predicate vocabulary to design, validate, or let metastasize into a scripting language. The role set stays closed; moving it onto the response changes where the closed-set annotation sits, not what it can express.
- Don't reach for "Dynamic injection." (Rejected fork: auto-append "Quest: X" options from GetOfferedQuests onto a generic greeting.) It minimizes authoring but the dialogue asset no longer describes the conversation, and quest phrasing is generic — at odds with the OSRS North Star.
- Don't reach for "Authored + condition predicates." (Rejected fork: every response carries a show-condition from a quest-state predicate set.) It is the most flexible but costs a predicate type + heavier validator and tends to grow toward a mini-language; the closed EDialogueQuestRole set covers the v1 OSRS cases (offer / talk-to advance / turn-in) without that surface.
Implementation surface:
- EDialogueQuestRole enum ({ None, Offer, TurnIn }) + FDialogueQuestBinding struct ({ QuestId, Role, DialogueKey }) in Source/CRADL/Dialogue/DialogueDefinition.h; the struct is a field on FDialogueResponse.
- Visibility evaluation lives in the widget (client, owner-visible state only) and reads Response.QuestBinding directly — no walk to the response's target node. It consults the giver's quest surface, which reuses the per-player lifecycle checks AQuestGiverActor::GetOfferedQuests already performs against UQuestComponent (Source/CRADL/Quests/QuestComponent.h) and FQuestRequirements::IsSatisfiedBy (Source/CRADL/Quests/QuestRequirements.h):
- Lifecycle gate via IQuestGiver::EvaluateQuestRole(player, QuestId) → EDialogueQuestRole:
- Offer — quest not started, not completed, eligible (IsSatisfiedBy), and its chain begins at this NPC's InteractableTag.
- TurnIn — an active TurnIn task at this NPC has its item requirement met (the same predicate GatherActions uses to surface the context-menu turn-in).
- Step gate via the current Interact task's DialogueKey: the giver exposes the active quest's current talk-to key here (IQuestGiver::GetCurrentDialogueKey(player, QuestId) → FName, returning NAME_None when the current task isn't an Interact step hosted here). A response with a non-empty Response.QuestBinding.DialogueKey is shown when that key equals the current task's key. Talk-to steps progress exclusively inside the tree, never on the bare interact. (Plain "come back later" flavor is just an unbound None response.)
- To avoid duplicating lifecycle logic in the widget, both predicates live on the giver/quest surface (IQuestGiver), implemented on AQuestGiverActor by reusing GetOfferedQuests' checks + the turn-in/current-task predicates. Per CLAUDE.md "interfaces over concrete types" and feedback_interface_rule_reading, the interface seam lets a non-AQuestGiverActor quest giver work without re-threading.
- Conventional EffectEventTag the author sets on the response: a lifecycle Offer response ↔ Action.Trigger.Quest.Accept (new); a lifecycle TurnIn response ↔ Action.Trigger.Quest.TurnIn (existing); a step-gated (DialogueKey) response ↔ Action.Trigger.Quest.Advance (new). Because gate and action are independent, the author sets the effect tag explicitly; the validator warns on a mismatch. A gated response with no effect tag is legal (gated flavor that only navigates).
Footguns:
- Visibility is owner-local and advisory, not a gate. The widget hiding an "Accept" response does not enforce eligibility — the UAcceptQuestAbility re-validates server-side (Accept Quest Ability). Hiding is UX, not security; never treat response visibility as the authority check (mirrors the QUEST_SYSTEM.md "requirements are eligibility, not enforcement" rule).
- The gate is on the response, not its target node. Visibility reads Response.QuestBinding; the response's NextNodeId target is a plain narrative node and may be shared by other (unbound or differently-bound) responses without inheriting any gate. Don't reintroduce node-level bindings — that was the rejected non-local model.
- Single turn-in entry, first-match. The existing UTurnInQuestItemAbility re-derives the first satisfiable TurnIn task at the giver (the standard dispatch can't carry per-task identity). A dialogue TurnIn response inherits this v1 limitation unchanged — see QUEST_SYSTEM.md "TurnIn Task".
- Don't re-implement GetOfferedQuests in the widget. Re-deriving lifecycle state in UI risks drift from the giver's enumeration. Call EvaluateQuestRole; if a predicate is missing, add it on the quest/giver side, not the widget.
- A step-gated (DialogueKey) response fires the dedicated Action.Trigger.Quest.Advance verb, not the generic Event.Interact shape. The dialogue effect hook can carry any tag (including Event.Interact), but talk-to progression must use the dedicated verb because Event.Interact already fires orthogonally on the bare interact that opened the dialogue (Quest Giver Integration) — reusing it from a dialogue option would double-count the same task. The two channels stay clean: Event.Interact = "the player interacted" (generic, fires on open, drives Interact tasks for non-dialogue world objects); Action.Trigger.Quest.Advance = "the player advanced this keyed step in conversation" (dialogue-only). This is the "flagged specific to the dialogue tree" half of the effect hook.
- Two talk-to beats at one giver in one quest need distinct DialogueKeys. Without them both surface as "a talk-to step is current" and render the same option — the Progress-coarseness bug the key exists to kill. Single-beat quests may leave the key empty. The validator warns on the ambiguous case.
Related: QUEST_SYSTEM.md "Quest Giver Actor", Source/CRADL/Quests/QuestGiverActor.h, Source/CRADL/Quests/QuestGiverInterface.h, feedback_interface_rule_reading.
Accept Quest Ability
Rule: UAcceptQuestAbility (new, LocalPredicted) is triggered by Action.Trigger.Quest.Accept. It is the missing seam that converts an offered quest into a started one. Mutation is server-authoritative (guarded by IsNetAuthority): it re-derives the QuestId from the payload, re-validates eligibility (FQuestRequirements::IsSatisfiedBy + that the quest is offerable at the source NPC), and calls UQuestComponent::UnlockQuest then UQuestComponent::StartQuest (both already self-route to authority). The client predicts activation but does not mutate, because the component's Server_* RPC would otherwise double-apply.
Why: Per the quest-seam grounding, UQuestComponent::StartQuest only promotes a quest already in UnlockedQuests, and UnlockQuest_Authority skips the requirements gate (only the unlock sweeps check eligibility). So the eligibility check must live in the accept ability, not the component — exactly mirroring how UTurnInQuestItemAbility re-derives + re-validates server-side rather than trusting the client. This is the ARCHITECTURE.md #18 contract: one ability owns the "accept quest" verb, reachable from dialogue today and any future publisher (a quest-board UI, a debug menu) tomorrow.
Implementation surface:
- Files: Source/CRADL/Abilities/AcceptQuestAbility.{h,cpp} (new); Source/CRADL/Abilities/QuestAcceptRequest.h (new) — UQuestAcceptRequest : UObject { FName QuestId; }, a transient payload mirroring UTransactRequest (Source/CRADL/Abilities/TransactRequest.h).
- Trigger: FAbilityTriggerData { Action.Trigger.Quest.Accept, EGameplayAbilityTriggerSource::GameplayEvent }. NetExecutionPolicy = LocalPredicted (matches UTransactItemAbility).
- Modal gating: ActivationRequiredTags = { Action.Modal.Dialogue } — accept is only ever dispatched from an open conversation; failed activation short-circuits before the body (the declarative gate pattern from UTransactItemAbility, not in-body checks).
- Server body: read QuestId from EventData.OptionalObject (UQuestAcceptRequest); resolve the source NPC from EventData.Target (IQuestGiver); confirm the quest is Offer-eligible at that giver (reusing the Quest-Binding Responses predicate); on pass, Quests->UnlockQuest(QuestId); Quests->StartQuest(QuestId);. On fail, drop with a Message.Source.Quest log (no new reason tag needed for v1).
- Must be added to BP_CradlPlayerState.DefaultAbilities to be granted (same as UTurnInQuestItemAbility).
Footguns:
- Predict, don't mutate. Per the grounding + the QUEST_SYSTEM.md "TurnIn Task" footgun, a LocalPredicted ability whose component calls self-route through Server_* will double-advance if the predicted client also mutates. Guard the UnlockQuest/StartQuest calls behind IsNetAuthority.
- Re-validate eligibility server-side. The client may have lost a required item / level between opening the dialogue and clicking Accept. The hide-the-node visibility (owner-local) is not the gate; IsSatisfiedBy on the server is.
- UnlockQuest then StartQuest, in that order. StartQuest_Authority requires the quest in UnlockedQuests; unlock-then-start is the requirement bypass the Debug_SeedQuest cheat already uses (per QUEST_IMPLEMENTATION.md Phase 5). Dedupe is at the component write site — calling UnlockQuest on an already-unlocked quest is a no-op.
Related: QUEST_SYSTEM.md "Authority & Replication", Source/CRADL/Abilities/TurnInQuestItemAbility.h, Source/CRADL/Abilities/TransactItemAbility.h, Source/CRADL/Quests/QuestComponent.h, feedback_p2p_replication_audit.
Advance Quest Task Ability
Rule: UAdvanceQuestTaskAbility (new, LocalPredicted) is triggered by Action.Trigger.Quest.Advance — the dialogue-driven verb that advances a "talk to NPC" (Interact-type) step. It is the replacement for the stripped TalkTo/Progress concepts: progression that happens in conversation, never on the bare interact. It carries a UQuestAdvanceRequest { FName QuestId; FName DialogueKey; } payload (mirroring UQuestAcceptRequest). Mutation is server-authoritative (guarded by IsNetAuthority): it resolves the active quest by the payload's QuestId, confirms its current task is an Interact step hosted at the source giver whose DialogueKey matches the payload (and that the giver hosts it), reads that record's CurrentTaskIndex, and calls UQuestComponent::AdvanceTask(QuestId, CurrentTaskIndex, +1). The client predicts activation but does not mutate.
Why: "Talk to NPC" steps must progress exclusively inside the dialogue tree (per Quest Giver Integration — bare interaction only opens dialogue). A dedicated verb keeps this off the orthogonal Event.Interact channel that already fires on the bare interact, so the step can't double-advance. The {QuestId, DialogueKey} payload is what lets a giver host distinct talk-to beats: the key picks the right step, and addressing the quest by id (rather than first-active-match) means two quests with a talk-to step current at the same giver don't collide. Server-side re-derivation of CurrentTaskIndex (rather than trusting a client-carried index) respects AdvanceTask's contract, which only advances the current task and rejects a stale TaskIndex.
Implementation surface:
- Files: Source/CRADL/Abilities/AdvanceQuestTaskAbility.{h,cpp} (new); Source/CRADL/Abilities/QuestAdvanceRequest.h (new) — UQuestAdvanceRequest : UObject { FName QuestId; FName DialogueKey; }, mirroring UQuestAcceptRequest.
- Trigger: FAbilityTriggerData { Action.Trigger.Quest.Advance, EGameplayAbilityTriggerSource::GameplayEvent }. NetExecutionPolicy = LocalPredicted.
- Modal gating: ActivationRequiredTags = { Action.Modal.Dialogue } — only ever dispatched from an open conversation.
- Server body: read QuestId + DialogueKey from EventData.OptionalObject (UQuestAdvanceRequest); resolve the source giver from EventData.Target (IInteractable::GetInteractableTag()); confirm the named quest is active, its current task is an Interact step whose InteractableTag matches the giver and whose DialogueKey equals the payload key; read its CurrentTaskIndex; call Quests->AdvanceTask(QuestId, CurrentTaskIndex, 1).
- Must be added to BP_CradlPlayerState.DefaultAbilities.
Footguns:
- Predict, don't mutate. Same as the Accept ability — guard the AdvanceTask call behind IsNetAuthority; the component self-routes through Server_AdvanceTask and a predicted client call would double-advance.
- Pass CurrentTaskIndex, never an arbitrary index. Per QUEST_SYSTEM.md "Task Chain", AdvanceTask rejects any TaskIndex != CurrentTaskIndex. Read the active record's current index and pass that.
- Don't reuse Event.Interact for this. It already fires on the bare interact that opened the dialogue — advancing on it too would double-count. The dedicated Action.Trigger.Quest.Advance channel is the whole point. (See Quest-Binding Responses Footguns.)
Related: Source/CRADL/Abilities/TurnInQuestItemAbility.h, Source/CRADL/Quests/QuestComponent.h, QUEST_SYSTEM.md "Interact Task", feedback_p2p_replication_audit.
Turn-In Integration
Rule: A dialogue TurnIn quest-binding response dispatches the existing Action.Trigger.Quest.TurnIn event — the same tag AQuestGiverActor::GatherActions already surfaces as a context-menu verb. UTurnInQuestItemAbility is reused unchanged: it re-derives the first satisfiable TurnIn task at the giver from EventData.Target + active quests + inventory, consumes items, and advances the task server-side. No new turn-in ability, no new turn-in tag.
Why: Turn-in is already a correct, server-authoritative verb reachable by tag. Per ARCHITECTURE.md #18, a second publisher (the dialogue widget) of the same verb is exactly the intended shape — authority stays in one place. The only change is where the click comes from.
Implementation surface:
- The dialogue widget's DispatchDialogueEffect(Action.Trigger.Quest.TurnIn, /*RequestPayload*/ nullptr) with Target = NPC — UTurnInQuestItemAbility carries no payload object by contract (it re-derives), so the existing dispatch shape is unchanged.
- v1 keeps the context-menu turn-in too. AQuestGiverActor::GatherActions continues to surface the TurnIn FContextAction; UTurnInQuestItemAbility is not gated on Action.Modal.Dialogue (it must activate from both the context menu and the dialogue page). This is deliberate: turn-in is verb-source-agnostic.
Footguns:
- Do not add ActivationRequiredTags = Action.Modal.Dialogue to UTurnInQuestItemAbility. That would break the existing context-menu turn-in path. Only the new UAcceptQuestAbility (dialogue-only) carries the modal gate.
- Turn-in is authoritative regardless of source. The dialogue node showing a TurnIn option is owner-local UX; the ability re-checks inventory on the server (feedback_p2p_replication_audit).
Related: QUEST_SYSTEM.md "TurnIn Task", Source/CRADL/Abilities/TurnInQuestItemAbility.h, Source/CRADL/Quests/QuestGiverActor.h.
Quest Giver Integration
Rule: AQuestGiverActor is wired exactly like every other modal-opening interactable (AStoreTerminal, ABankTerminal, ACraftingStation, ALoadoutTerminal): both its BeginInteract and its GetPrimaryActionTag() yield Action.Trigger.Modal.Dialogue, and the actor gains a TObjectPtr<UDialogueDefinition> Dialogue reference. Interacting with the giver has one outcome — the dialogue modal opens — and that outcome is identical whether the player used the interact key (→ BeginInteract) or a click (→ the default bDefault action through DispatchContextAction), because both ends emit the same trigger tag. The retired Action.Trigger.Quest.TalkTo no-op is replaced wholesale.
Why: This is the established CRADL interactable pattern, not a new mechanism. AStoreTerminal::BeginInteract fires Action.Trigger.Modal.Shop and AStoreTerminal::GetPrimaryActionTag returns the same tag; the interface header documents this as the intended shape ("primary interact still flows through BeginInteract"). There is no "context action vs direct interact" distinction to reconcile for any terminal, and there is none here — the giver opens dialogue the same way the bank opens banking. Event.Interact is not a second outcome: it is the orthogonal Interact-task observation tag the dispatch funnel already publishes for any interactable whose GetInteractableTag() is valid (quest givers opted into that when Interact tasks shipped). It is unchanged by this contract and needs no handling on the giver.
Implementation surface:
- AQuestGiverActor (Source/CRADL/Quests/QuestGiverActor.h) gains UPROPERTY(EditAnywhere) TObjectPtr<UDialogueDefinition> Dialogue;. The actor is content-only (bReplicates = false); the reference is level-authored.
- BeginInteract fires Action.Trigger.Modal.Dialogue (Target = this) — a one-line swap of the old TalkTo fire, mirroring AStoreTerminal::BeginInteract.
- GetPrimaryActionTag() returns Action.Trigger.Modal.Dialogue; the default GatherActions entry built from it carries bDefault = true, SourceActor = this. (The giver's GatherActions override still appends the TurnIn verb on top — see Turn-In Integration.)
- GetMenuHeader() (already implemented for the IInteractable header strip) supplies the NPC name/portrait the dialogue page paints on activation.
- The dialogue modal's trigger payload Target = this, so UDialogueWidget::GetModalContext()->Target resolves back to the giver and reads Dialogue.
Footguns:
- Action.Trigger.Quest.TalkTo is removed, not re-parked. Per feedback_no_clean_up_later, the swap to Action.Trigger.Modal.Dialogue in both BeginInteract and GetPrimaryActionTag() is part of this change — delete the dead TalkTo fire (and its symbol + .ini entry); don't leave it behind.
- Event.Interact is unchanged orthogonal plumbing — leave it alone. The dispatch funnel keeps publishing it for the giver's bDefault action exactly as today; Interact-task progression rides it as before. Don't duplicate it into BeginInteract, and don't add a second in-dialogue progression path for the same task (per QUEST_SYSTEM.md "Interact Task", it fires from the dispatch site, once).
Related: QUEST_SYSTEM.md "Quest Giver Actor", Source/CRADL/Interaction/InteractableInterface.h, Source/CRADL/Store/StoreTerminal.cpp, feedback_no_clean_up_later.
Authority & Replication
Rule: Conversation state is local, cosmetic, and unreplicated. Authoritative consequences flow through abilities. The P2P audit:
| State | Owner | Replication | Rationale |
|---|---|---|---|
| Current dialogue node (which line you're on) | UDialogueWidget (local client) |
None | Pure UI walk over read-only asset data. Each player's conversation is private and local; no peer needs it. |
Action.Modal.Dialogue (engagement tag) |
UCradlModalAbility (LocalPredicted) |
Loose tag, present on owning client + server peer | Base-class behavior; reaches the server so UAcceptQuestAbility::ActivationRequiredTags can gate. Not relevant to non-owning peers. |
| Quest accept (start) | UAcceptQuestAbility → UQuestComponent |
Server-authoritative; quest arrays already COND_OwnerOnly + RepNotify |
Mutation guarded to authority; predicted client does not mutate. No new replicated field. |
| Quest task advance (talk-to) | UAdvanceQuestTaskAbility → UQuestComponent::AdvanceTask |
Server-authoritative; rides the existing ActiveQuests COND_OwnerOnly + RepNotify |
Mutation guarded to authority; predicted client does not mutate. Resolves the payload's quest by id + matches the current task's DialogueKey server-side. No new replicated field. |
| Quest turn-in | UTurnInQuestItemAbility → UQuestComponent / UInventoryComponent |
Server-authoritative (existing) | Unchanged; re-derives + consumes on the server. |
UDialogueDefinition |
content asset / CDO | Not replicated | Immutable shared data; read identically on every peer. |
AQuestGiverActor.Dialogue ref |
level actor (bReplicates = false) |
Not replicated | Level-placed content; no server-mutated state. |
No new replicated properties are introduced by the dialogue system. The new abilities (UAcceptQuestAbility, UAdvanceQuestTaskAbility) and the reused UTurnInQuestItemAbility all route through the existing UQuestComponent replication story (COND_OwnerOnly arrays with paired RepNotify per QUEST_SYSTEM.md "Per-Player State").
Footguns:
- Never make the conversation authoritative. Putting "current node" on the server (or replicating it) would invent a state machine the North Star explicitly rejects, and create the "two replicated fields, divergent arrival" hazard from feedback_p2p_replication_audit for zero benefit. The conversation is a view.
- The new abilities are LocalPredicted with authority-guarded mutation. Per feedback_p2p_replication_audit, every server-mutated path gets a deliberate answer — here it is "the ability predicts activation, the component mutates only on authority via its existing Server_* self-routing." Applies to both UAcceptQuestAbility and UAdvanceQuestTaskAbility.
Related: ARCHITECTURE.md #1, #15, #18, feedback_p2p_replication_audit, QUEST_SYSTEM.md "Per-Player State".
Validator
Rule: A new editor validator UCradlDialogueDefinitionValidator : UEditorValidatorBase lands in the same change as UDialogueDefinition, per CLAUDE.md's "validators in lockstep" rule. It enforces:
- Nodes non-empty; RootNodeId resolves to an existing node (or defaults to Nodes[0]).
- Each NodeId non-empty and unique within the asset.
- Each FDialogueResponse.NextNodeId resolves to an existing NodeId in this asset, or is NAME_None (end).
- No unreachable nodes (best-effort graph walk from RootNodeId) — warning, not error.
- Each FDialogueResponse.EffectEventTag, when set, is under Action.Trigger.*.
- Each FDialogueResponse.QuestBinding.QuestId, when Role != None, resolves to a known quest via CradlValidationHelpers::CollectKnownQuestIds (the same best-effort scan UCradlQuestDefinitionValidator uses for PrerequisiteQuests).
- Verb pairing is a warning, not an error (gate and action are independent axes): a bound response whose EffectEventTag is set to a quest verb other than its gate's convention is flagged as a likely authoring mistake; a gated response with no effect tag is allowed (gated flavor that only navigates). Convention: Offer ↔ Action.Trigger.Quest.Accept / step-gated (DialogueKey) ↔ Action.Trigger.Quest.Advance / TurnIn ↔ Action.Trigger.Quest.TurnIn.
- DialogueKey ambiguity (warning): if a single quest has ≥2 Interact tasks sharing one giver's InteractableTag with no distinct DialogueKeys, flag it — the dialogue can't tell those beats apart. (Cross-asset, best-effort like the quest-resolution scan.)
Implementation surface:
- File: Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.{h,cpp} (new).
- Pattern mirrors Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h; reuses CradlValidationHelpers collectors.
- Add UDialogueDefinition to the validated-assets list in CLAUDE.md.
Footguns:
- Cross-asset quest resolution is best-effort. If a dialogue binds a quest that is later renamed, the validator catches it on the dialogue asset's next save; a runtime miss simply hides the node (the giver predicate returns no role). Acceptable — mirrors the PrerequisiteQuests best-effort note in QUEST_SYSTEM.md "Validator".
- Tag namespace, not C++ symbol, for authored effect tags. Per feedback_gameplay_tag_decl_minimal, the validator requests tags by name (RequestGameplayTag(..., /*ErrorIfNotFound*/false)); only the three new system tags below earn C++ symbols.
Related: CLAUDE.md "validators in lockstep", Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h, CradlValidationHelpers.
Tag Taxonomy
All new tags land in Config/DefaultGameplayTags.ini with DevComment per feedback_gameplay_tag_decl_minimal. C++ symbols in Source/CRADL/CradlGameplayTags.h are added only for tags referenced by name in C++.
| Tag | Existing? | Where declared | Where referenced |
|---|---|---|---|
Action.Modal.Dialogue |
new | .ini + C++ symbol | ModalTag on CBA_Modal_Dialogue; ActivationRequiredTags on UAcceptQuestAbility (C++ ctor) |
Action.Trigger.Modal.Dialogue |
new | .ini + C++ symbol | ActionTag of AQuestGiverActor's bDefault action (C++); modal ability trigger |
Action.Trigger.Quest.Accept |
new | .ini + C++ symbol | dispatched by UDialogueWidget for Offer nodes; trigger on UAcceptQuestAbility |
Action.Trigger.Quest.Advance |
new | .ini + C++ symbol | dispatched by UDialogueWidget for step-gated (DialogueKey) responses, payload UQuestAdvanceRequest { QuestId, DialogueKey }; trigger on UAdvanceQuestTaskAbility. The dialogue-only "talk to NPC" advance verb that replaces the stripped TalkTo/Progress concepts |
Action.Trigger.Quest.TurnIn |
existing | .ini + C++ symbol | reused — dispatched by UDialogueWidget for TurnIn nodes (also still the context-menu verb) |
Action.Trigger.Quest.TalkTo |
existing (removed) | .ini + C++ symbol | the v1 leftover, deleted as a root concept — interaction opens dialogue, and talk-to progression is Action.Trigger.Quest.Advance. Strip the symbol + .ini entry once no code references it |
Event.Interact |
existing | .ini + C++ symbol | unchanged and orthogonal — fires from the dispatch funnel on the bare interact of any interactable (the giver included). Drives Quest.TaskType.Interact for non-dialogue world objects; quest-giver talk-to does not ride it (uses Action.Trigger.Quest.Advance instead, to avoid double-counting) |
Quest.TaskType.Interact / Quest.TaskType.TurnIn |
existing | .ini + C++ symbol | unchanged — quest-side discriminators |
Event.Dialogue.* |
not added | — | reserved for future dialogue-event listeners (no v1 consumer); see Open Questions #3 |
DefaultGame.ini gains one +PrimaryAssetTypesToScan entry for DialogueDefinition over /Game/Definitions/Dialogues.
Forward Code References
Future PRs land in named, predictable places:
Source/CRADL/Dialogue/DialogueDefinition.{h,cpp}—UDialogueDefinition,FDialogueNode,FDialogueResponse,FDialogueQuestBinding,EDialogueSpeaker,EDialogueQuestRole.Source/CRADL/UI/DialogueWidget.{h,cpp}—UDialogueWidget : UCradlActivatableWidget,DispatchDialogueEffect.Source/CRADL/Abilities/AcceptQuestAbility.{h,cpp}—UAcceptQuestAbility(server-authoritative quest start).Source/CRADL/Abilities/QuestAcceptRequest.h—UQuestAcceptRequest : UObject(transient{QuestId}payload).Source/CRADL/Abilities/AdvanceQuestTaskAbility.{h,cpp}—UAdvanceQuestTaskAbility(server-authoritative talk-to advance; addresses the quest by payloadQuestId+ matches the current task'sDialogueKey).Source/CRADL/Abilities/QuestAdvanceRequest.h—UQuestAdvanceRequest : UObject(transient{QuestId, DialogueKey}payload).Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.{h,cpp}— validator.- Modifications to existing files (each a small surface change, not a refactor):
Source/CRADL/Quests/QuestGiverActor.{h,cpp}— addTObjectPtr<UDialogueDefinition> Dialogue; point bothBeginInteractandGetPrimaryActionTag()atAction.Trigger.Modal.Dialogue(theAStoreTerminalpattern); removeAction.Trigger.Quest.TalkTo.Source/CRADL/Quests/QuestGiverInterface.h(orQuestGiverActor) — expose the per-role predicate (EvaluateQuestRole) the dialogue widget consumes, or reuseGetOfferedQuests+ the turn-in predicate.Source/CRADL/CradlGameplayTags.{h,cpp}— C++ symbols for the dialogue-domain modal tagsAction.Modal.Dialogue+Action.Trigger.Modal.Dialogue. (The quest verbsAction.Trigger.Quest.Accept/Action.Trigger.Quest.Advanceare quest-domain — declared in QUEST_IMPLEMENTATION.md Phase 8, consumed here.Action.Trigger.Quest.TalkTois removed there too.)Config/DefaultGameplayTags.ini— the four new tag declarations with DevComment; delete theAction.Trigger.Quest.TalkToentry.Config/DefaultGame.ini—+PrimaryAssetTypesToScanentry forDialogueDefinition.CLAUDE.md— addUDialogueDefinitionto the validated-assets list.- Reciprocal edits in the quest docs (companion change, derived in the quest docs' own iterate pass, not here): resolve QUEST_SYSTEM.md Open Question #3 (quest-log/dialogue UI) — accept/turn-in are now in v1 scope — and add the
UAcceptQuestAbility+ dialogue-giver wiring as new phases/todos inQUEST_IMPLEMENTATION.md.
Open Questions
- No dialogue registry (RESOLVED for v1 — direct reference). The giver holds a
TObjectPtr<UDialogueDefinition>and the asset is never looked up by FName, so aUDialogueRegistrysubsystem is omitted. The trigger to reintroduce one is cross-dialogue links (NextNodeIdjumping into another asset) or save-data referencing a dialogue by FName — neither exists in v1. Flagged so a future need reopens it deliberately rather than by accident. - Voice / portrait assets.
Portraitis aTSoftObjectPtr<UTexture2D>stub. Voice lines, animated chat-heads, and per-node portrait overrides are deferred; the soft-ref slot is the seam. Event.Dialogue.*listeners. A conversation could fire one-shot events (dialogue started/ended/node-reached) for cue/quest listeners (e.g. a "talk to X" Interact task that should only advance after a specific dialogue branch, not the bare interact). Reserved namespace, no v1 consumer — v1 Interact tasks rideEvent.Interactas today.- Multi-speaker conversations. v1 is one NPC per
UDialogueDefinition(the giver the modal opened against). Scenes with multiple speakers (an NPC and a companion) would need a per-node speaker actor reference; deferred. - Localization round-trip.
FTextfields are localization-ready, but the authored-tree shape's interaction with the localization pipeline (string-table extraction from nested struct arrays) is unverified. Flag for the localization pass. - Repeatable / state-varying greetings beyond quest roles. v1 visibility is driven by quest roles + plain narrative nodes. Non-quest conditional greetings (time-of-day, reputation) would need the rejected predicate vocabulary; deferred until a concrete need, and explicitly not the OSRS-grade v1 surface.
Contract drafted at DIALOGUE_SYSTEM.md. Review and tell me when aligned, or run /design-system Dialogue again with iteration feedback. Run /design-system --phase=derive-implementation Dialogue to derive the phased implementation doc from it.