0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI DIALOGUE_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
DIALOGUE_IMPLEMENTATION.md 3009 words ~14 min read Updated 2026-07-03

CRADL Dialogue Implementation

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

New leaf system under Source/CRADL/Dialogue/ (mirrors Source/CRADL/Quests/), reusing the modal-ability machinery (ARCH #15) and the widget→ASC dispatch pattern (ARCH #18) wholesale — it adds a data asset, one modal ability, one widget, and a validator, not new foundation.

Cross-doc split (read this first). The dialogue feature spans two docs by domain: - This doc owns the dialogue domain: UDialogueDefinition + validator, the Action.Modal.Dialogue tag pair, the CBA_Modal_Dialogue modal ability, and UDialogueWidget. - QUEST_IMPLEMENTATION.md Phase 8 owns the quest domain: UAcceptQuestAbility, UAdvanceQuestTaskAbility, UQuestAcceptRequest, the Action.Trigger.Quest.Accept / Action.Trigger.Quest.Advance verbs, the AQuestGiverActor wiring, and the EvaluateQuestRole predicate.

The interlock: dialogue Phase 0 unblocks quest Phase 8's compile (the giver's TObjectPtr<UDialogueDefinition> and the abilities' ActivationRequiredTags = { Action.Modal.Dialogue } both need this doc's Phase 0 symbols), and dialogue Phase 3 depends on quest Phase 8 (the quest verbs + abilities + EvaluateQuestRole). Phases 1–2 are verifiable standalone via a Debug_OpenDialogue cheat, so the two tracks only rendezvous at Phase 3.

Conventions

  • Phase status legend: [ ] not started · [~] in progress · [x] done · [!] blocked / deferred.
  • Verification gate: every phase ends with a runnable demo or observable behavior. If a phase can't be verified end-to-end, it's split.
  • Cheat commands: test fixtures land under ACradlPlayerController exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded.
  • Per CLAUDE.md "validators in lockstep": any phase that touches UDialogueDefinition, FDialogueNode, FDialogueResponse, or FDialogueQuestBinding updates UCradlDialogueDefinitionValidator under Source/CRADLEditor/Validators/ in the same change. Add UDialogueDefinition to the validated-assets list in CLAUDE.md.
  • Per CLAUDE.md "no UBT here": Claude does not build. After C++ edits, the user compiles and reports back.
  • Tag declaration rule (per feedback_gameplay_tag_decl_minimal): declare in Config/DefaultGameplayTags.ini with DevComment; add a C++ symbol in Source/CRADL/CradlGameplayTags.h only when referenced by name from C++ code.
  • Replication audit per phase: dialogue introduces no replicated state (conversation is cosmetic/local; the quest mutations are server-authoritative through the existing UQuestComponent per DIALOGUE_SYSTEM.md "Authority & Replication"). Each phase that touches a mutation restates this.
  • American English spellings per feedback_american_english.

Phase tracking

Phase Title Status Unblocks
0 Tags & data scaffolding [x] 1, 2, 3, 4 · unblocks QUEST Phase 8 compile
1 Dialogue modal engagement [x] verified in PIE 2
2 Conversation tree walk [x] verified in PIE (non-quest) 3
3 Quest-binding responses (end-to-end) [x] verified in PIE — (terminal) · depends on QUEST Phase 8
4 Validator depth, portrait & polish [x] v1 ready

Build note. v1 dialogue is complete and verified in PIE: the modal opens against a placed NPC / Debug_OpenDialogue, walks the node tree, renders responses, branches by NextNodeId, and closes; quest-binding responses gate correctly (lifecycle Offer/TurnIn via EvaluateQuestRole, talk-to via the DialogueKey step gate), and Accept / Advance / TurnIn drive the quest server-authoritatively. The response row is a UCommonButtonBase subclass (UDialogueResponseButton) — click is handled in C++ (NativeOnClicked), so the WBP carries no Event Graph logic and gets CommonUI focus-nav for free (a deliberate deviation from the Phase-2 UCommonButtonGroupBase suggestion, sidestepping the ARCH #17 re-entrant-broadcast footgun).

Design iteration captured in the contract: the quest binding moved from the node to the response; the coarse Progress role was replaced by a per-step FName DialogueKey (content identity, not a tag) carried on FQuestTask + the binding + the UQuestAdvanceRequest payload, so one giver hosts multiple distinct talk-to beats. UQuestComponent::HandleInteractEvent skips keyed Interact tasks so talk-to progresses only in conversation.


Phase 0 — Tags & Data Scaffolding

Goal. Compile-clean codebase with the dialogue data shape and modal tags in place. After this phase the editor can author a UDialogueDefinition asset (nodes, responses, quest bindings) and the validator catches malformed trees. No runtime behavior. Critically, this phase is the compile prerequisite for QUEST Phase 8 — the giver's Dialogue ref and the accept/advance abilities' modal gate both reference symbols introduced here.

Rationale. Per ARCH #14 (registry-free here — DIALOGUE_SYSTEM.md "Dialogue Definition" deliberately omits a registry) and the UPrimaryDataAsset + validator-in-lockstep pattern, every later phase reads from an authored asset. The class + tags must exist before the modal ability, the widget, or the quest abilities can compile.

Tasks.

  • [x] Modal tags — Config/DefaultGameplayTags.ini + Source/CRADL/CradlGameplayTags.{h,cpp}:
  • [x] Action.Modal.Dialogue (.ini + C++ symbol — ModalTag on CBA_Modal_Dialogue; ActivationRequiredTags on the quest-side accept/advance abilities). DevComment per feedback_gameplay_tag_decl_minimal.
  • [x] Action.Trigger.Modal.Dialogue (.ini + C++ symbol — ActionTag of AQuestGiverActor's bDefault action + modal ability trigger). DevComment.
  • [x] (The quest verbs Action.Trigger.Quest.Accept / Action.Trigger.Quest.Advance and the Action.Trigger.Quest.TalkTo removal are quest-domain — QUEST_IMPLEMENTATION.md Phase 8. Done there in this same pass.)
  • [x] UDialogueDefinitionSource/CRADL/Dialogue/DialogueDefinition.h (new):
  • [x] class UDialogueDefinition : public UPrimaryDataAsset. Override GetPrimaryAssetId()FPrimaryAssetId(TEXT("DialogueDefinition"), GetFName()) (mirror USpellDefinition::GetPrimaryAssetId in Source/CRADL/Combat/SpellDefinition.h).
  • [x] Fields per DIALOGUE_SYSTEM.md "Dialogue Definition": FText SpeakerName, TSoftObjectPtr<UTexture2D> Portrait (soft ref, mirror USpellDefinition::Icon), FName RootNodeId, TArray<FDialogueNode> Nodes. (Plus read-only FindNode / GetRootNode helpers.)
  • [x] Node structs — Source/CRADL/Dialogue/DialogueDefinition.h:
  • [x] FDialogueNode { FName NodeId; EDialogueSpeaker Speaker; FText Line (meta=MultiLine); TArray<FDialogueResponse> Responses; } — flat shape, mirroring the FQuestTask flat-discriminator decision in Source/CRADL/Quests/QuestTask.h. No quest binding on the node — it lives on the response (contract iterated; see DIALOGUE_SYSTEM.md "Quest-Binding Responses").
  • [x] FDialogueResponse { FText Text; FName NextNodeId; FDialogueQuestBinding QuestBinding; FGameplayTag EffectEventTag; }QuestBinding is the per-response visibility gate; EffectEventTag is the independent action (general TalkTo replacement hook — generic tag, quest verb, or empty).
  • [x] FDialogueQuestBinding { FName QuestId; EDialogueQuestRole Role; FName DialogueKey; } (a field on FDialogueResponse) and enums EDialogueSpeaker { NPC, Player }, EDialogueQuestRole { None, Offer, TurnIn }. Progress was dropped — talk-to advance is the step gate (Role=None + non-empty DialogueKey, matched against the current Interact task's FName DialogueKey). See DIALOGUE_SYSTEM.md "Quest-Binding Responses".
  • [x] UCradlDialogueDefinitionValidatorSource/CRADLEditor/Validators/CradlDialogueDefinitionValidator.{h,cpp} (new): full-depth checks landed (the Phase 4 deepening came in the same pass). Mirrors Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h:
  • [x] Nodes non-empty; RootNodeId resolves to a node (or defaults to Nodes[0]).
  • [x] Each NodeId non-empty and unique; each FDialogueResponse.NextNodeId resolves to a NodeId or is NAME_None.
  • [x] Add UDialogueDefinition to the validated-assets list in CLAUDE.md (same change).
  • [x] PrimaryAssetTypesToScan — Config/DefaultGame.ini: +PrimaryAssetTypesToScan entry for DialogueDefinition over /Game/Definitions/Dialogues. Mirrors the QuestDefinition entry shape exactly.

Verification.

  • Compile clean (user-side).
  • Author a DA_Dialogue_Test asset in /Game/Definitions/Dialogues with 2–3 nodes. Save → no validator errors.
  • Author a malformed asset (dangling NextNodeId, duplicate NodeId) → validator flags it at save.

Exits. UDialogueDefinition + Action.Modal.Dialogue pair exist → QUEST Phase 8 can now compile (giver Dialogue ref + ability modal gate). Phases 1–4 have a data shape to build against.


Phase 1 — Dialogue Modal Engagement

Goal. Talking-as-a-modal works: a CBA_Modal_Dialogue ability opens a UDialogueWidget page on UI.Layer.GameMenu, the widget recovers its source actor via GetModalContext(), paints the NPC header, and renders the root node's line. ESC closes it. Driven by a debug cheat so it's verifiable before the giver wiring (QUEST Phase 8) exists.

Rationale. Per ARCH #15 a new modal is content-only over UCradlModalAbility. Standing the modal + widget shell up first isolates the notorious modal-wiring footguns (cache-before-tag, pre-activation init, per-leaf HUD subscription) from the tree-walk logic that follows.

Tasks.

  • [x] CBA_Modal_Dialogue (BP) — subclass of UCradlModalAbility: set ModalTag = Action.Modal.Dialogue, add ability trigger Action.Trigger.Modal.Dialogue. No C++ override. Add to BP_CradlPlayerState.DefaultAbilities.
  • [x] Register page — WBP_HUDLayout.ModalPageClasses: Action.Modal.Dialogue → WBP_DialoguePage (UCradlHUDLayout::ModalPageClasses).
  • [x] UDialogueWidgetSource/CRADL/UI/DialogueWidget.{h,cpp} (new), : UCradlActivatableWidget:
  • [x] NativeOnActivated: resolve const FGameplayEventData* Ctx = GetModalContext(); then read the UDialogueDefinition — from Cast<AQuestGiverActor>(Ctx->Target)'s Dialogue ref in production, falling back to Ctx->OptionalObject for the debug path (below). Handles null context.
  • [x] Header: base OnHeaderRefresh (giver GetMenuHeader) + the page's own OnSpeakerInfo(SpeakerName, Portrait) paint.
  • [x] Render the RootNodeId node's Line.
  • [x] Debug_OpenDialogueACradlPlayerController exec (guarded body): fires Action.Trigger.Modal.Dialogue with Target = the nearest AQuestGiverActor (if any) and OptionalObject = a console-named UDialogueDefinition, so the modal opens without the giver's Dialogue ref.

Verification.

  • Debug_OpenDialogue DA_Dialogue_Test → modal pushes onto UI.Layer.GameMenu, header shows the speaker name, the root line renders. ESC pops the page and ends the ability (tag count → 0). Re-open works (no stale fields).

Footguns.

  • Pages initialize pre-activation. Per ARCH #15, read GetModalContext() in NativeOnActivated; the router sets OwningAbilityTag + the cached trigger data in its AddWidget InitFunc, before activation. Reading the definition any later is "works on the second open."
  • Header broadcasts once. BroadcastHeader fires once on activation; if the page rebinds to a different source, re-fire manually.
  • Avoid Slot as a local in this UUserWidget subclass (feedback_slot_shadows_uwidget) — use NodeSlot/ResponseSlot.
  • The modal cancellation/ESC coherence is base-class behavior — do not reimplement it.

Phase 2 — Conversation Tree Walk

Goal. The full OSRS-style conversation: render a node's line, render its Responses as a choice list, navigate NextNodeId on selection, end on NAME_None. A response carrying a generic EffectEventTag dispatches it via ASC->HandleGameplayEvent (logged for now — quest semantics arrive in Phase 3). No quest dependency.

Rationale. This is the dialogue engine proper. It's pure widget logic over read-only asset data (conversation state is local/cosmetic per the North Star), so it carries no replication and no authority concerns.

Tasks.

  • [x] UDialogueWidget response rendering — Source/CRADL/UI/DialogueWidget.cpp: renders each visible Response as a UDialogueResponseButton row in the bound ResponseList panel. Deviation from the task note: independent UCommonButtonBase rows rather than a UCommonButtonGroupBase — click is handled in C++ (NativeOnClicked), keeping the WBP graph-free and sidestepping the ARCH #17 re-entrant-broadcast footgun. Zero visible responses → a synthetic "Continue" row (INDEX_NONE) that closes.
  • [x] On select: if EffectEventTag is set, call DispatchDialogueEffect, then navigate to NextNodeId (end on NAME_None).
  • [x] DispatchDialogueEffect(FGameplayTag EventTag, UObject* RequestPayload)Source/CRADL/UI/DialogueWidget.cpp: builds FGameplayEventData { EventTag, Instigator = owning Pawn, Target = source NPC, OptionalObject = RequestPayload } and calls ASC->HandleGameplayEvent (ARCH #18). Private handlers route here; nothing else fires events.
  • [x] Author a branching DA_Dialogue_TreeTest (a choice that forks to two distinct sub-nodes, each ending). (verified in PIE)

Verification.

  • Debug_OpenDialogue DA_Dialogue_TreeTest → walk lines, pick a choice, observe the branch route by NextNodeId, reach an end node, conversation closes. A response with a generic authored EffectEventTag logs the dispatch (the event fires to no ability yet — expected).

Footguns.

  • ARCH #17 re-entrant button-group broadcast. With bSelectionRequired=true, AddWidget auto-selects the first button synchronously — append to any parallel index before AddWidget so the callback indexes a valid entry.
  • Don't gate node-advance on the dispatch result. The conversation is a cosmetic view; the effect event is fire-and-forget (the receiving ability re-validates server-side). Advance locally regardless. Per DIALOGUE_SYSTEM.md "Dialogue Widget & Dispatch".
  • Bind any tooltip delegate in NativeOnInitialized (feedback_umg_tooltip_delegate_init_timing).

Phase 3 — Quest-Binding Responses (End-to-End)

Goal. The quest loop runs through dialogue with no cheats: quest-binding responses — lifecycle gates (Offer / TurnIn) and step gates (Role=None + DialogueKey) — show only in the matching quest state and dispatch the matching Action.Trigger.Quest.* verb. Accept starts a quest, a step-gated response advances a "talk to" step, TurnIn consumes items and advances — all server-authoritative through the existing UQuestComponent.

Depends on. QUEST_IMPLEMENTATION.md Phase 8: UAcceptQuestAbility, UAdvanceQuestTaskAbility, UQuestAcceptRequest, the Action.Trigger.Quest.Accept / Action.Trigger.Quest.Advance tags, the AQuestGiverActor wiring (so the giver opens the modal in-world), and the EvaluateQuestRole predicate. Land quest Phase 8 before this phase's verification; the widget code below compiles against the quest verbs once those tags exist, but the loop only closes with the abilities present.

Rationale. This is the rendezvous of the two tracks. The widget gains quest awareness purely as a reader of the quest state surface — it never mutates quest state directly (ARCH #18); it dispatches verbs.

Tasks.

  • [x] Quest-binding visibility — Source/CRADL/UI/DialogueWidget.cpp: for a response with a binding, query the giver per DIALOGUE_SYSTEM.md "Quest-Binding Responses": lifecycle gate (Role=Offer|TurnIn) via EvaluateQuestRole; step gate (non-empty DialogueKey) via GetCurrentDialogueKey matching the current Interact task's key. Show only on match. Do not re-derive lifecycle state in the widget — call the existing surface.
  • [x] Gate → verb dispatch — Source/CRADL/UI/DialogueWidget.cpp:
  • [x] OfferDispatchDialogueEffect(Action.Trigger.Quest.Accept, NewObject<UQuestAcceptRequest>{ QuestId })UAcceptQuestAbility.
  • [x] step gate (DialogueKey) → DispatchDialogueEffect(Action.Trigger.Quest.Advance, NewObject<UQuestAdvanceRequest>{ QuestId, DialogueKey })UAdvanceQuestTaskAbility (resolves the quest by id + matches the current task's key server-side).
  • [x] TurnInDispatchDialogueEffect(Action.Trigger.Quest.TurnIn, nullptr) → the existing UTurnInQuestItemAbility.
  • [x] Replication audit: no new replicated state. All three verbs mutate through UQuestComponent's existing COND_OwnerOnly arrays, authority-guarded inside the abilities (quest Phase 8). Per DIALOGUE_SYSTEM.md "Authority & Replication".

Verification.

  • Place an AQuestGiverActor (wired in quest Phase 8) with an authored quest dialogue. Talk to it:
  • Offer: an Offer node is visible for a startable quest → pick Accept → Debug_QuestStatus shows the quest Active.
  • Talk-to advance: for a quest whose current task is a "talk to me" Interact step here (with a DialogueKey), the step-gated response (matching DialogueKey) is visible → pick it → the task counter advances (Action.Trigger.Quest.Advance). The bare interact (just opening dialogue) does not advance it (HandleInteractEvent skips keyed tasks).
  • TurnIn: with items in bag, a TurnIn node is visible → pick it → items consumed, task advances. The context-menu turn-in still works too.
  • Completed: finish the quest → reopen → the Offer node is gone (role no longer matches).
  • Server re-validation: drop a required item after opening the dialogue, then pick Accept → server rejects the start; node visibility was advisory only.

Footguns.

  • Visibility is advisory, not a gate. Hiding a node is owner-local UX; UAcceptQuestAbility / the turn-in ability re-validate on the server (feedback_p2p_replication_audit).
  • A step-gated response fires Action.Trigger.Quest.Advance, never Event.Interact. Reusing the generic interact channel would double-count against the bare interact that opened the dialogue — which is why HandleInteractEvent skips keyed Interact tasks. Per DIALOGUE_SYSTEM.md "Quest-Binding Responses".
  • First-match turn-in (and single TurnIn entry) — the ability re-derives the first satisfiable task; a dialogue TurnIn node inherits this v1 limitation.

Phase 4 — Validator Depth, Portrait & Polish

Goal. The validator is deep enough to catch authoring mistakes pre-runtime, the portrait soft-ref loads for the header, and the cheat surface is complete enough to drive any scenario from console. v1 ready for content authoring.

Rationale. Most validator surface lands in lockstep at Phase 0; this is the consolidation pass for the checks that need cross-asset resolution or a graph walk, plus the cosmetic loose ends.

Tasks.

  • [x] Validator depth — Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.cpp: (landed with Phase 0 in the same pass)
  • [x] Unreachable-node check: graph-walk from RootNodeId; warn (not error) on nodes no response can reach.
  • [x] Each FDialogueResponse.EffectEventTag, when set, is under Action.Trigger.*.
  • [x] Each FDialogueResponse.QuestBinding.QuestId (when bound — Role != None OR DialogueKey set) resolves via CradlValidationHelpers::CollectKnownQuestIds (Source/CRADLEditor/Validators/CradlValidationHelpers.h).
  • [x] Verb-fit (warning): a bound response's EffectEventTag, if set, matches its gate's convention (OfferAccept / step-gate DialogueKeyAdvance / TurnInTurnIn); error if a response sets both Role and DialogueKey. The DialogueKey-ambiguity warning (≥2 Interact tasks at one giver without distinct keys) lives in UCradlQuestDefinitionValidator.
  • [x] Portrait — Source/CRADL/UI/DialogueWidget.cpp: async-loads the TSoftObjectPtr<UTexture2D> Portrait via FStreamableManager and paints it through OnSpeakerInfo once resolved.
  • [x] Cheat polish — ACradlPlayerController: Debug_OpenDialogue targets the nearest giver + a named asset; Debug_DumpDialogueRoles <QuestId> logs the live EvaluateQuestRole and GetCurrentDialogueKey (role=… stepKey=…) for QA.

Verification.

  • Author assets hitting each validator failure mode (unreachable node, wrong-namespace EffectEventTag, unknown QuestId, both-gates-set, verb mismatch, and a quest with ≥2 same-giver Interact tasks sharing a DialogueKey) → each flagged at save.
  • Portrait renders in the header for an asset with one set.
  • Full content pass: a complete quest authored end-to-end through dialogue (offer → talk-to advance → turn-in) with no code changes.

Footguns.

  • Cross-asset quest resolution is best-effort — a renamed quest is caught on the dialogue asset's next save; a runtime miss just hides the node. Mirrors the PrerequisiteQuests note in QUEST_SYSTEM.md "Validator".
  • Validators in lockstep — the structural checks already shipped in Phase 0; this phase only deepens. Don't let a Phase-0 struct change skip its validator update.

Cross-doc dependencies

  • Dialogue Phase 0 → QUEST Phase 8 (compile). The giver's TObjectPtr<UDialogueDefinition> and the accept/advance abilities' ActivationRequiredTags = { Action.Modal.Dialogue } both reference Phase-0 symbols. Land dialogue Phase 0 first.
  • Dialogue Phase 3 → QUEST Phase 8 (runtime). The quest verbs, the two new abilities, the giver wiring, and EvaluateQuestRole are quest-domain. Dialogue Phase 3 closes the loop only once they exist.
  • Tag ownership. Dialogue declares Action.Modal.Dialogue + Action.Trigger.Modal.Dialogue; quest Phase 8 declares Action.Trigger.Quest.Accept / Action.Trigger.Quest.Advance and removes Action.Trigger.Quest.TalkTo. Neither doc re-declares the other's tags.
  • No registry. Per DIALOGUE_SYSTEM.md Open Question #1, there is intentionally no UDialogueRegistry — the giver holds a direct asset ref. If a future cross-dialogue link or save-by-FName need arises, that's the trigger to add one (a new Phase), not a v1 task.

Out-of-doc deferrals

Per DIALOGUE_SYSTEM.md "Open Questions", these do not ship in v1: voice lines / animated portraits (OQ #2), Event.Dialogue.* listeners (OQ #3), multi-speaker scenes (OQ #4), localization string-table round-trip verification (OQ #5), and non-quest conditional greetings / the rejected predicate vocabulary (OQ #6).