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
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
UDialogueDefinition,FDialogueNode,FDialogueResponse, orFDialogueQuestBindingupdatesUCradlDialogueDefinitionValidatorunder Source/CRADLEditor/Validators/ in the same change. AddUDialogueDefinitionto 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 inConfig/DefaultGameplayTags.iniwith DevComment; add a C++ symbol inSource/CRADL/CradlGameplayTags.honly 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
UQuestComponentper 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 byNextNodeId, and closes; quest-binding responses gate correctly (lifecycleOffer/TurnInviaEvaluateQuestRole, talk-to via theDialogueKeystep gate), and Accept / Advance / TurnIn drive the quest server-authoritatively. The response row is aUCommonButtonBasesubclass (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-2UCommonButtonGroupBasesuggestion, 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
Progressrole was replaced by a per-stepFName DialogueKey(content identity, not a tag) carried onFQuestTask+ the binding + theUQuestAdvanceRequestpayload, so one giver hosts multiple distinct talk-to beats.UQuestComponent::HandleInteractEventskips 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 —ModalTagonCBA_Modal_Dialogue;ActivationRequiredTagson the quest-side accept/advance abilities). DevComment perfeedback_gameplay_tag_decl_minimal. - [x]
Action.Trigger.Modal.Dialogue(.ini + C++ symbol —ActionTagofAQuestGiverActor'sbDefaultaction + modal ability trigger). DevComment. - [x] (The quest verbs
Action.Trigger.Quest.Accept/Action.Trigger.Quest.Advanceand theAction.Trigger.Quest.TalkToremoval are quest-domain — QUEST_IMPLEMENTATION.md Phase 8. Done there in this same pass.) - [x]
UDialogueDefinition— Source/CRADL/Dialogue/DialogueDefinition.h (new): - [x]
class UDialogueDefinition : public UPrimaryDataAsset. OverrideGetPrimaryAssetId()→FPrimaryAssetId(TEXT("DialogueDefinition"), GetFName())(mirrorUSpellDefinition::GetPrimaryAssetIdin Source/CRADL/Combat/SpellDefinition.h). - [x] Fields per DIALOGUE_SYSTEM.md "Dialogue Definition":
FText SpeakerName,TSoftObjectPtr<UTexture2D> Portrait(soft ref, mirrorUSpellDefinition::Icon),FName RootNodeId,TArray<FDialogueNode> Nodes. (Plus read-onlyFindNode/GetRootNodehelpers.) - [x] Node structs — Source/CRADL/Dialogue/DialogueDefinition.h:
- [x]
FDialogueNode { FName NodeId; EDialogueSpeaker Speaker; FText Line (meta=MultiLine); TArray<FDialogueResponse> Responses; }— flat shape, mirroring theFQuestTaskflat-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; }—QuestBindingis the per-response visibility gate;EffectEventTagis the independent action (generalTalkToreplacement hook — generic tag, quest verb, or empty). - [x]
FDialogueQuestBinding { FName QuestId; EDialogueQuestRole Role; FName DialogueKey; }(a field onFDialogueResponse) and enumsEDialogueSpeaker { NPC, Player },EDialogueQuestRole { None, Offer, TurnIn }.Progresswas dropped — talk-to advance is the step gate (Role=None+ non-emptyDialogueKey, matched against the current Interact task'sFName DialogueKey). See DIALOGUE_SYSTEM.md "Quest-Binding Responses". - [x]
UCradlDialogueDefinitionValidator— Source/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]
Nodesnon-empty;RootNodeIdresolves to a node (or defaults toNodes[0]). - [x] Each
NodeIdnon-empty and unique; eachFDialogueResponse.NextNodeIdresolves to aNodeIdor isNAME_None. - [x] Add
UDialogueDefinitionto the validated-assets list in CLAUDE.md (same change). - [x] PrimaryAssetTypesToScan —
Config/DefaultGame.ini:+PrimaryAssetTypesToScanentry forDialogueDefinitionover/Game/Definitions/Dialogues. Mirrors theQuestDefinitionentry shape exactly.
Verification.
- Compile clean (user-side).
- Author a
DA_Dialogue_Testasset in/Game/Definitions/Dialogueswith 2–3 nodes. Save → no validator errors. - Author a malformed asset (dangling
NextNodeId, duplicateNodeId) → 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: setModalTag = Action.Modal.Dialogue, add ability triggerAction.Trigger.Modal.Dialogue. No C++ override. Add toBP_CradlPlayerState.DefaultAbilities. - [x] Register page —
WBP_HUDLayout.ModalPageClasses:Action.Modal.Dialogue → WBP_DialoguePage(UCradlHUDLayout::ModalPageClasses). - [x]
UDialogueWidget— Source/CRADL/UI/DialogueWidget.{h,cpp} (new),: UCradlActivatableWidget: - [x]
NativeOnActivated: resolveconst FGameplayEventData* Ctx = GetModalContext();then read theUDialogueDefinition— fromCast<AQuestGiverActor>(Ctx->Target)'sDialogueref in production, falling back toCtx->OptionalObjectfor the debug path (below). Handles null context. - [x] Header: base
OnHeaderRefresh(giverGetMenuHeader) + the page's ownOnSpeakerInfo(SpeakerName, Portrait)paint. - [x] Render the
RootNodeIdnode'sLine. - [x]
Debug_OpenDialogue— ACradlPlayerController exec (guarded body): firesAction.Trigger.Modal.DialoguewithTarget =the nearestAQuestGiverActor(if any) andOptionalObject =a console-namedUDialogueDefinition, so the modal opens without the giver'sDialogueref.
Verification.
Debug_OpenDialogue DA_Dialogue_Test→ modal pushes ontoUI.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()inNativeOnActivated; the router setsOwningAbilityTag+ the cached trigger data in itsAddWidgetInitFunc, before activation. Reading the definition any later is "works on the second open." - Header broadcasts once.
BroadcastHeaderfires once on activation; if the page rebinds to a different source, re-fire manually. - Avoid
Slotas a local in thisUUserWidgetsubclass (feedback_slot_shadows_uwidget) — useNodeSlot/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]
UDialogueWidgetresponse rendering — Source/CRADL/UI/DialogueWidget.cpp: renders each visibleResponseas aUDialogueResponseButtonrow in the boundResponseListpanel. Deviation from the task note: independentUCommonButtonBaserows rather than aUCommonButtonGroupBase— 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
EffectEventTagis set, callDispatchDialogueEffect, then navigate toNextNodeId(end onNAME_None). - [x]
DispatchDialogueEffect(FGameplayTag EventTag, UObject* RequestPayload)— Source/CRADL/UI/DialogueWidget.cpp: buildsFGameplayEventData { EventTag, Instigator = owning Pawn, Target = source NPC, OptionalObject = RequestPayload }and callsASC->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 byNextNodeId, reach an end node, conversation closes. A response with a generic authoredEffectEventTaglogs the dispatch (the event fires to no ability yet — expected).
Footguns.
- ARCH #17 re-entrant button-group broadcast. With
bSelectionRequired=true,AddWidgetauto-selects the first button synchronously — append to any parallel index beforeAddWidgetso 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) viaEvaluateQuestRole; step gate (non-emptyDialogueKey) viaGetCurrentDialogueKeymatching 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]
Offer→DispatchDialogueEffect(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]
TurnIn→DispatchDialogueEffect(Action.Trigger.Quest.TurnIn, nullptr)→ the existingUTurnInQuestItemAbility. - [x] Replication audit: no new replicated state. All three verbs mutate through
UQuestComponent's existingCOND_OwnerOnlyarrays, 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
Offernode is visible for a startable quest → pick Accept →Debug_QuestStatusshows 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 (matchingDialogueKey) is visible → pick it → the task counter advances (Action.Trigger.Quest.Advance). The bare interact (just opening dialogue) does not advance it (HandleInteractEventskips keyed tasks). - TurnIn: with items in bag, a
TurnInnode is visible → pick it → items consumed, task advances. The context-menu turn-in still works too. - Completed: finish the quest → reopen → the
Offernode 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, neverEvent.Interact. Reusing the generic interact channel would double-count against the bare interact that opened the dialogue — which is whyHandleInteractEventskips 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
TurnInnode 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 underAction.Trigger.*. - [x] Each
FDialogueResponse.QuestBinding.QuestId(when bound —Role != NoneORDialogueKeyset) resolves viaCradlValidationHelpers::CollectKnownQuestIds(Source/CRADLEditor/Validators/CradlValidationHelpers.h). - [x] Verb-fit (warning): a bound response's
EffectEventTag, if set, matches its gate's convention (Offer↔Accept/ step-gateDialogueKey↔Advance/TurnIn↔TurnIn); error if a response sets bothRoleandDialogueKey. TheDialogueKey-ambiguity warning (≥2 Interact tasks at one giver without distinct keys) lives inUCradlQuestDefinitionValidator. - [x] Portrait — Source/CRADL/UI/DialogueWidget.cpp: async-loads the
TSoftObjectPtr<UTexture2D> PortraitviaFStreamableManagerand paints it throughOnSpeakerInfoonce resolved. - [x] Cheat polish — ACradlPlayerController:
Debug_OpenDialoguetargets the nearest giver + a named asset;Debug_DumpDialogueRoles <QuestId>logs the liveEvaluateQuestRoleandGetCurrentDialogueKey(role=… stepKey=…) for QA.
Verification.
- Author assets hitting each validator failure mode (unreachable node, wrong-namespace
EffectEventTag, unknownQuestId, both-gates-set, verb mismatch, and a quest with ≥2 same-giver Interact tasks sharing aDialogueKey) → 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
PrerequisiteQuestsnote 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
EvaluateQuestRoleare 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 declaresAction.Trigger.Quest.Accept/Action.Trigger.Quest.Advanceand removesAction.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).