CRADL Slayer Implementation
Companion to SLAYER_SYSTEM.md (the contract), QUEST_IMPLEMENTATION.md (the kill-event + dialogue-binding patterns slayer reuses), DIALOGUE_IMPLEMENTATION.md (the dialogue widget + modal-ability surface), and ARCHITECTURE.md. This doc tracks the build order for v1 Slayer: phased delivery, per-phase rationale, task checklists, and verification gates. The contract doc says what slayer 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/Slayer/ (mirrors Source/CRADL/Quests/). Every system slayer binds to — USkillsComponent, UQuestComponent's kill-subscription pattern, UDialogueWidget, AQuestGiverActor, UEnemyDefinition, UCradlAutoAttackAbilityBase / UCastSpellAbility, UCradlSaveSubsystem — is shipped and gets additive changes only. Per the contract's North Star: slayer "adds one rolled-state component, one roll-table asset, and two thin verbs, not foundation."
Conventions
- Phase status legend:
[ ]not started ·[~]in progress ·[x]done ·[!]blocked / deferred. - Verification gate: every phase ends with a runnable demo / 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
USlayerMasterDefinition,FSlayerTaskEntry,UEnemyDefinition, orFDialogueResponse.SlayerBindingupdates the matching validator under Source/CRADLEditor/Validators/ in the same change. AddUSlayerMasterDefinitionto 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 in Source/CRADL/CradlGameplayTags.h only when referenced by name from C++ code. - Replication audit per phase: any new server-mutated field or replicated property gets a one-line answer in the phase's task list per
feedback_p2p_replication_audit. The contract's Authority & Replication table is the master ledger. - American English spellings per
feedback_american_english.
Phase tracking
| Phase | Title | Status | Unblocks |
|---|---|---|---|
| 0 | Tag & data scaffolding | [x] |
All later phases |
| 1 | Per-player state, persistence, cheat-driven assign | [x] |
2, 3 |
| 2 | Kill subscription & Slayer XP grant | [x] |
4 (auto-completion observable end-to-end) |
| 3 | Slayer master & level-gated roll | [x] |
4 |
| 4 | Dialogue integration & assign ability (end-to-end) | [ ] |
v1 in-world play |
| 5 | Combat modifiers — resilience gate & weakness scope (optional) | [~] |
(optional polish; parallel-able with 4 once 3 lands) |
Phase 0 — Tag & Data Scaffolding
Goal. Compile-clean codebase with the full slayer data shape — definition, struct types, interface, component shell, validator stub, tag declarations — in place. No behavior. After this phase the editor can author a USlayerMasterDefinition asset, a Slayer USkillDefinition asset, and a FDialogueResponse with a SlayerBinding field; the validator catches the malformed cases that data alone can flag.
Rationale. Per the contract's Slayer Component and Slayer Master Definition, every later phase reads from the master asset or writes to the component. Landing the asset class, struct shapes, interface header, component shell, and tag declarations in one low-risk PR avoids cross-phase merge churn (the proven Phase-0 shape from quest and dialogue). The new FDialogueResponse::SlayerBinding field needs a slayer-owned header to break the dialogue↔slayer include cycle — that header lands here so Phase 4's widget edits are pure consumers.
Tasks.
- [x] Gameplay tags — Config/DefaultGameplayTags.ini:
- [x]
Skill.Slayer(.ini + C++ symbol —USlayerComponentreferences it by name inGrantXP; theUSkillDefinitionasset'sSkillTagreads it as data. DevComment perfeedback_gameplay_tag_decl_minimal.) - [x]
Action.Trigger.Slayer(parent, .ini only — namespace root for slayer dialogue verbs, mirrors theAction.Trigger.Questroot.) - [x]
Action.Trigger.Slayer.Assign(.ini + C++ symbol — dispatched byUDialogueWidgetforAssign-gate responses, trigger onUAssignSlayerTaskAbility.) - [x] Each new tag gets a DevComment matching the shape
Action.Trigger.Quest.Acceptuses ("Convert an offered ... — Dispatched by ... — activates ... — Payload: ..."). - [x] C++ tag symbols — Source/CRADL/CradlGameplayTags.h +
.cpp: - [x]
Skill_SlayerandAction_Trigger_Slayer_Assign. (Do NOT add a symbol forAction.Trigger.Slayerparent — it's only namespaced, no C++ reads it by name.) - [x] (
Status_AssignedTarget_MatchingFamilysymbol lands in Phase 5 with its publisher, not here — Phase 0–4 do not name it.) - [x] PrimaryAssetTypesToScan — Config/DefaultGame.ini:
- [x]
+PrimaryAssetTypesToScanentry:PrimaryAssetType="SlayerMasterDefinition",AssetBaseClass=/Script/CRADL.SlayerMasterDefinition,Directories=((Path="/Game/Definitions/Slayer")). Mirror theQuestDefinition/DialogueDefinitionentry shape exactly. (The SlayerUSkillDefinitionrides the existingSkillDefinitionscan entry — do NOT add a second scan path for it.) - [x]
USlayerMasterDefinition—Source/CRADL/Slayer/SlayerMasterDefinition.h+.cpp(new): - [x]
class CRADL_API USlayerMasterDefinition : public UPrimaryDataAsset. OverrideGetPrimaryAssetId()→FPrimaryAssetId(TEXT("SlayerMasterDefinition"), GetFName())(mirrorUSpellDefinition/UQuestDefinition/UDialogueDefinition). - [x] Fields per SLAYER_SYSTEM.md "Slayer Master Definition":
FText DisplayName,TArray<FSkillRequirement> MasterRequirements,TArray<FSlayerTaskEntry> AssignableTasks. - [x]
FSlayerTaskEntry— same header: - [x]
USTRUCT(BlueprintType)shape:{ FGameplayTag FamilyTag (meta Categories="Enemy.Family"); int32 Weight; int32 MinCount; int32 MaxCount; int32 MinSlayerLevel; int64 CompletionXp; }. Field defaults:Weight=1,MinCount=1,MaxCount=1,MinSlayerLevel=1,CompletionXp=0. - [x]
FSlayerAssignment—Source/CRADL/Slayer/SlayerComponent.h(new, struct lands with the component header): - [x]
USTRUCT(BlueprintType)shape per SLAYER_SYSTEM.md "Slayer Component":{ FGameplayTag FamilyTag; int32 TargetCount; int32 KillsDone; int64 CompletionXp; }. InvalidFamilyTag= empty slot. - [x]
USlayerComponentshell —Source/CRADL/Slayer/SlayerComponent.h+.cpp(new): - [x]
class CRADL_API USlayerComponent : public UActorComponent. Constructor:SetIsReplicatedByDefault(true). - [x] Declare (no behavior yet) the two replicated fields:
FSlayerAssignment CurrentAssignment—Replicated, ReplicatedUsing=OnRep_CurrentAssignment, COND_OwnerOnly.int32 TasksCompleted—Replicated, ReplicatedUsing=OnRep_TasksCompleted, COND_OwnerOnly.
- [x]
GetLifetimeReplicatedPropswith the twoDOREPLIFETIME_CONDITION(..., COND_OwnerOnly)lines. - [x] Empty
OnRep_*bodies + the matchingFSimpleMulticastDelegate OnAssignmentChanged/OnTasksCompletedChangeddelegates. Phase 1 fills broadcast + getter bodies. - [x] Public read-only getters:
const FSlayerAssignment& GetCurrentAssignment() const,int32 GetTasksCompleted() const. (Used by save subsystem in Phase 1; lives here so the component never includes the savegame header — same disciplineUQuestComponentfollows per QUEST_IMPLEMENTATION.md Phase 1 structural note.) - [x]
ISlayerMasterinterface —Source/CRADL/Slayer/SlayerMasterInterface.h(new): - [x]
UINTERFACE(MinimalAPI, BlueprintType)+class ISlayerMasterwith two pure virtuals (signatures only; Phase 3 implements them):virtual ESlayerGate EvaluateSlayerGate(const ACradlPlayerState* Player) const = 0.virtual const USlayerMasterDefinition* GetSlayerPool() const = 0.
- [x] Includes
SlayerDialogueBinding.hfor theESlayerGatereturn type (see next bullet). - [x]
ESlayerGate+FDialogueSlayerBinding—Source/CRADL/Slayer/SlayerDialogueBinding.h(new): - [x]
UENUM(BlueprintType) enum class ESlayerGate : uint8 { None, Assign }. (Reportis deferred per SLAYER_SYSTEM.md Open Questions #2 — do NOT park it now perfeedback_dont_symmetrize_speculatively.) - [x]
USTRUCT(BlueprintType) struct CRADL_API FDialogueSlayerBinding { ESlayerGate Gate = ESlayerGate::None; }. (Parallel ofFDialogueQuestBinding; intentionally lives in a slayer-owned header rather than extendingDialogueDefinition.h's closed enum, per SLAYER_SYSTEM.md "Dialogue Integration".) - [x]
FDialogueResponseextension — Source/CRADL/Dialogue/DialogueDefinition.h: - [x]
#include "Slayer/SlayerDialogueBinding.h"at the top. - [x] Add
UPROPERTY(EditAnywhere) FDialogueSlayerBinding SlayerBinding;toFDialogueResponsealongside the existingQuestBindingfield. - [x] Authoring-only at Phase 0 —
UDialogueWidget::IsResponseVisibleignoresSlayerBindinguntil Phase 4. - [x]
UEnemyDefinitionfield additions — Source/CRADL/Enemy/EnemyDefinition.h: - [x]
UPROPERTY(EditDefaultsOnly, meta=(ClampMin=0)) int64 SlayerXp = 0;— per-kill XP awarded when this enemy is the player's slayer task. Consumed in Phase 2. - [x]
UPROPERTY(EditDefaultsOnly, meta=(ClampMin=0)) int32 RequiredSlayerLevel = 0;— minimum attacker Slayer level for the resilience gate.0= no requirement (the default). Consumed in Phase 5. - [x]
AQuestGiverActorfield addition — Source/CRADL/Quests/QuestGiverActor.h +.cpp: - [x]
UPROPERTY(EditAnywhere, Category="Slayer") TObjectPtr<USlayerMasterDefinition> SlayerPool = nullptr;— null = the NPC is not a slayer master. Authoring-only at Phase 0; theISlayerMasterimplementation lands in Phase 3. - [x]
UCradlSlayerMasterDefinitionValidatorstub — Source/CRADLEditor/Validators/CradlSlayerMasterDefinitionValidator.h +.cpp(new): - [x]
class UCradlSlayerMasterDefinitionValidator : public UEditorValidatorBase. Mirror Source/CRADLEditor/Validators/CradlQuestDefinitionValidator.h. - [x] Phase-0 checks (the trivially-derivable surface):
AssignableTasksnon-empty; each entry'sWeight ≥ 1,MinCount ≥ 1,MaxCount ≥ MinCount,MinSlayerLevel ≥ 1,CompletionXp ≥ 0. Deeper checks (tag namespace, reachable-at-level-1, cross-asset family resolution) land in Phase 3. - [x]
CradlEnemyDefinitionValidatorextension — Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h +.cpp: - [x]
SlayerXp ≥ 0(also covered bymeta=(ClampMin=0)defensively, mirrorFQuestRewardManifestEntry::XpAmount). (Upper-bound check forRequiredSlayerLevelagainst the registry max level deferred to Phase 5 alongside the resilience-gate consumer — Phase 0 landsRequiredSlayerLevel >= 0defensively; the ClampMin metadata covers the slate-level path.) - [x]
RequiredSlayerLevelwithin[0, registry max level]— read max viaUSkillRegistry's threshold table, mirror the existingMinSlayerLevel-style range check pattern inCradlValidationHelpers. (Phase 0 lands the>= 0defensive check; the registry-backed upper bound returns in Phase 5 with the gate consumer.) - [x] CLAUDE.md update — same change: add
USlayerMasterDefinitionto the validated-assets list under "Editor-time validators shadow the runtime structs" per CLAUDE.md.
P2P replication audit (Phase 0 declares replicated fields; behavior lands Phase 1):
| New replicated field | Replication | Reason |
|---|---|---|
USlayerComponent::CurrentAssignment |
Replicated + COND_OwnerOnly + OnRep_CurrentAssignment |
Per-player private task; replaced wholesale on each roll. |
USlayerComponent::TasksCompleted |
Replicated + COND_OwnerOnly + OnRep_TasksCompleted |
Per-player private lifetime counter. |
UEnemyDefinition::SlayerXp / RequiredSlayerLevel |
Not replicated — CDO content data | Read-only at runtime per the UEnemyDefinition discipline. |
AQuestGiverActor::SlayerPool |
Not replicated — level-placed content; actor bReplicates = false |
Level-authored direct ref. |
Verification.
- Compile clean (user-side).
- Open the editor. Create a new
USlayerMasterDefinitionasset under/Game/Definitions/Slayer/. AuthorDisplayName = "Test Slayer Master", leaveAssignableTasksempty → save → asset validator flags the empty-AssignableTaskserror. - Add one entry with
Weight=0→ save → validator flagsWeight >= 1. - With one well-formed entry (
FamilyTag = Enemy.Family.Goblin,Weight=1,MinCount=1,MaxCount=3,MinSlayerLevel=1,CompletionXp=50), asset saves clean. - Author a Slayer
USkillDefinitionasset under the existing/Game/Definitions/Skills/path withSkillTag = Skill.Slayer,DisplayName = "Slayer",XpCurvenull (perproject_skill_xp_curve_optional). - Author a
UDialogueDefinitionresponse with aSlayerBinding.Gate = Assignfield set; save → no validator error (Phase 4 adds the no-double-binding and missing-effect-tag checks). - Open a
UEnemyDefinitionasset; confirm the newSlayerXp/RequiredSlayerLevelfields are visible with sensible defaults. - Place an
AQuestGiverActorin a test map and confirm theSlayerPoolfield shows up in the actor's details panel.
Exits. Phase 1 has the component class, the FSlayerAssignment struct, and replicated fields to fill behavior into. Phase 3 has the USlayerMasterDefinition shape + ISlayerMaster interface ready. Phase 4 has the FDialogueSlayerBinding field on responses ready to consume.
Footguns.
- Don't add a C++ symbol for
Action.Trigger.Slayerparent. Perfeedback_gameplay_tag_decl_minimal, symbols exist only for tags named in C++; the parent is namespace-only. The same rule that keeps theEnemy.Family.*leaves out of the header applies here. - Don't park a
Reportvalue inESlayerGate. v1 auto-completes on the final kill so there is noReportconsumer; perfeedback_dont_symmetrize_speculatively,Reportreturns alongside the slayer-points feature, not preemptively. Adding it now would also forceCradlDialogueDefinitionValidatorto either accept a meaningless authored value or warn about it — both wrong. FSlayerAssignmentlives inSlayerComponent.h, not a separate header. Unlike quests (where the manifest needed sharing with the savegame format and earned its ownQuestPersistentTypes.h), slayer's runtime state goes through component getters andApplyPersistedState— the savegame never includes the component header. One header is the minimum dep shape; no leaf header needed.FDialogueSlayerBindinglives inSlayer/, notDialogue/. This is the deliberate alternative to extendingEDialogueQuestRoleper DIALOGUE_SYSTEM.md North Star rule 3 — the dialogue enum stays closed; slayer brings its own. If you find yourself wanting to add the enum toDialogueDefinition.h, stop and re-read the contract.
Phase 1 — Per-Player State, Persistence, Cheat-Driven Assign
Goal. A player can hold a slayer task. USlayerComponent lives on ACradlPlayerState, replicates owner-only with the two-field pattern, round-trips through save/load, and cheats can assign / complete / inspect a task. No kill subscription, no master roll, no dialogue — just the authority entry points and persistence wiring.
Rationale. Per SLAYER_SYSTEM.md "Slayer Component", USlayerComponent is the canonical per-player progression shape (sibling to USkillsComponent / USpellbookComponent / UQuestComponent). Standing the spine up before its verbs lets Phase 2 wire a kill handler onto a known-good store, and Phase 3 wire a roll into an AssignTask that already mutates correctly. Mirrors the Phase-1 shape that QUEST_IMPLEMENTATION.md used.
Tasks.
- [x]
USlayerComponentbehavior —Source/CRADL/Slayer/SlayerComponent.cpp: - [x] Implement
OnRep_CurrentAssignment→ broadcastOnAssignmentChanged;OnRep_TasksCompleted→ broadcastOnTasksCompletedChanged. - [x] Authority broadcasts the delegate directly after each mutation on listen-server (mirror
USkillsComponent::ApplyXPGrant's discipline —OnRepdoes not fire on the host owner, per the Footguns note below). - [x] Server-only mutator API (each routes via
Server_*RPC from a client, mirroringUSkillsComponent::GrantXP):- [x]
AssignTask(FGameplayTag FamilyTag, int32 TargetCount, int64 CompletionXp)— Phase 1 placeholder shape taking explicit params for the cheat path. No-op ifCurrentAssignment.FamilyTag.IsValid()(slot already filled). Writes the new assignment + broadcasts. (Phase 3 replaces this entry point withAssignTask(const TScriptInterface<ISlayerMaster>&)that performs the roll internally — the cheat-shaped overload either gets retired or stays asDebug_*parallel; decision deferred to Phase 3 since it depends on whether the cheat survives the roll wiring.) - [x]
RecordKill(const FGameplayTagContainer& VictimFamilies, int64 SlayerXp)— Phase 1 lands the body that matchesCurrentAssignment.FamilyTagagainstVictimFamilies(HasTagcontained-in), incrementsKillsDone(clamped atTargetCount), grants per-kill XP viaPS->GetSkillsComponent()->GrantXP(CradlTags::Skill_Slayer, SlayerXp), and on reachingTargetCountcallsCompleteCurrentTask. (Caller wiring — theCombat.Event.Killsubscription — lands in Phase 2. This phase exercises it through aDebug_RecordSlayerKillcheat that synthesizes aFGameplayTagContainer.) - [x]
CompleteCurrentTask()— grantsCurrentAssignment.CompletionXpviaGrantXP(CradlTags::Skill_Slayer, ...), incrementsTasksCompleted, clearsCurrentAssignment(default-construct it back to invalid). Broadcasts both delegates on authority.
- [x]
- [x]
Server_*RPC wrappers for off-authority call paths (mirrorUSkillsComponent::Server_GrantXP). Cheat-side calls reach this for free. - [x]
GetSlayerLevel()convenience inline →GetOwner<ACradlPlayerState>()->GetSkillsComponent()->GetLevel(CradlTags::Skill_Slayer)(used by Phase 3's roll and Phase 5's gate; lands here so the component owns the read-through). - [x]
ApplyPersistedState—Source/CRADL/Slayer/SlayerComponent.{h,cpp}: - [x]
void ApplyPersistedState(const FSlayerAssignment& InAssignment, int32 InTasksCompleted)— server-only restore. FilterInAssignment.FamilyTagagainst the gameplay-tag registry (InAssignment.FamilyTag.IsValid()+UGameplayTagsManager::Get().RequestGameplayTag(InAssignment.FamilyTag.GetTagName(), /*ErrorIfNotFound=*/false).IsValid()) — if the tag was renamed or removed, drop the assignment with aUE_LOG(..., Warning, ...)(mirror theIsKnownQuest-style filterUQuestComponent::ApplyPersistedStatedoes, scaled down for slayer's tag-keyed state). - [x]
LogCradlSlayerlog category declared in the component .cpp for the orphan-drop warning + future trace points. - [x] PlayerState wiring — Source/CRADL/Player/CradlPlayerState.h +
.cpp: - [x]
UPROPERTY(VisibleAnywhere) TObjectPtr<USlayerComponent> SlayerComponent;constructed in the constructor alongside the existing components (sibling toQuestComponent,SkillsComponent,SpellbookComponent). - [x]
USlayerComponent* GetSlayerComponent() const { return SlayerComponent; }accessor alongside the existing component getters. - [x] Persistence — Source/CRADL/SaveGame/CradlPlayerProfile.h:
- [x] Bump
CRADL_SAVE_VERSION(current head value + 1) — the additive-fields-rely-on-tagged-property-serialization discipline applies (mirror the QUEST Phase 0 bump pattern; do NOT add a per-fieldSaveVersionguard). - [x] Add two new fields on
UCradlPlayerProfile:FSlayerAssignment SlayerAssignment;(reuses the runtime struct as a value type —FSlayerAssignmentis plain data with no UObject refs, same shape asFLoadoutModifierInstanceround-tripping per QUEST_IMPLEMENTATION.md Phase 1 PendingRewards note) andint32 SlayerTasksCompleted = 0;. - [x] Append after the quest fields (the v10 fields are the latest in the profile; slayer's append after them).
- [x] Save subsystem — Source/CRADL/SaveGame/CradlSaveSubsystem.cpp:
- [x]
SavePlayer: read viaPS->GetSlayerComponent()->GetCurrentAssignment()andGetTasksCompleted(); copy into the profile fields directly. (No snapshot↔runtime conversion needed —FSlayerAssignmentis the canonical shape for both, same asFPendingQuestRewardper QUEST_IMPLEMENTATION.md Phase 1 structural note.) - [x]
LoadPlayer: apply slayer state after quest (the v1 load order is Loadout → Skills → Health → Cold storage → Bank → Spellbook → Modifiers → Death → Quest → Slayer). Slayer reads no other component's state at load time, so its precise slot within "after Skills" doesn't matter functionally; append at the end for consistency with "newest system last." - [x] Call
PS->GetSlayerComponent()->ApplyPersistedState(profile.SlayerAssignment, profile.SlayerTasksCompleted). - [x] Cheat commands — Source/CRADL/Player/CradlPlayerController.h +
.cpp: - [x] Declare unconditionally (UFUNCTION declarations are NOT guarded — per CLAUDE.md "UFUNCTION + #if !UE_BUILD_SHIPPING in headers — never combine"); body guarded by the existing
#if !UE_BUILD_SHIPPINGexec block:Debug_AssignSlayerTask(FName FamilyTag, int32 Count, int64 CompletionXp)— callsGetPlayerState<ACradlPlayerState>()->GetSlayerComponent()->AssignTask(...). Self-routes viaServer_*on a client (mirrorDebug_GrantXP/Debug_UnlockQuest).Debug_RecordSlayerKill(FName FamilyTag, int64 SlayerXp)— synthesizes anFGameplayTagContainer { FamilyTag }and callsRecordKill. Lets Phase 1 exercise the increment-and-auto-complete path without a real kill event.Debug_CompleteSlayerTask()— callsCompleteCurrentTaskdirectly (skips kills; pure state test).Debug_PrintSlayer()— logsCurrentAssignment(FamilyTag / TargetCount / KillsDone / CompletionXp) +TasksCompleted+ the player'sSkill.Slayerlevel.
P2P replication audit (Phase 1 adds behavior to the Phase-0-declared fields; no new replicated fields):
| Mutation | Authority answer | Reason |
|---|---|---|
AssignTask / RecordKill / CompleteCurrentTask writes |
Server-only (guarded by GetOwner()->HasAuthority() or via Server_* RPC) |
Per SLAYER_SYSTEM.md "Authority & Replication", all mutation is server-authoritative; clients see results via the COND_OwnerOnly replicated fields. |
| Listen-server host self-notify | Authority calls OnAssignmentChanged.Broadcast() / OnTasksCompletedChanged.Broadcast() directly after each mutation |
Per feedback_p2p_replication_audit and the contract Footguns: OnRep does not fire on the host owner; relying on it would leave the host's own UI stale. |
Verification.
- Cheat flow on listen-server (single player):
Debug_AssignSlayerTask Enemy.Family.Goblin 3 100→Debug_PrintSlayershows the task.Debug_RecordSlayerKill Enemy.Family.Goblin 0× 3 → on the third call, observe auto-completion:TasksCompleted = 1,CurrentAssignment.FamilyTaginvalid,Skill.Slayerlevel reflects theCompletionXpgrant (and the per-kill grants if non-zero — Phase 1 uses zero per-kill XP to isolate the completion path). Debug_RecordSlayerKill Enemy.Family.Cow 50while a Goblin task is active → no-op (hierarchical mismatch);KillsDoneunchanged.- On a P2P host+client setup, run the same flow on the client: confirm only the owner-client observes the RepNotify fire; the host's other-player observer sees nothing (the
COND_OwnerOnlygate). - Save → close PIE → load → confirm
CurrentAssignmentandTasksCompletedround-trip. Mid-task save (KillsDone = 2 / TargetCount = 3) survives. - Rename
Enemy.Family.Cowbetween save and load (or delete it from the .ini) → on load, the orphan assignment drops with aLogCradlSlayerwarning. - The pre-bump v10 save still loads → slayer fields default-construct (empty assignment, zero counter), the rest of the profile is unchanged.
Exits. Phase 2 can wire Combat.Event.Kill onto RecordKill. Phase 3 can replace the cheat-shaped AssignTask with the master-driven roll. Phase 4's UAssignSlayerTaskAbility calls the same mutator.
Footguns.
OnRepdoes not fire on the listen-server host. Perfeedback_p2p_replication_auditand the contract, authority broadcasts the delegate directly after mutation; do NOT rely onOnRep_CurrentAssignmentto refresh host UI. (Same disciplineUSkillsComponent::ApplyXPGrantfollows — verbatim pattern.)- Don't reseed assignment from any ambient sweep. Per
feedback_loose_tag_writer_bool_no_reseed,AssignTaskis the only writer forCurrentAssignment; don't re-derive it from quest/skill state or a per-tick check. Single writer, one mutation, replicated result. - Two replicated fields, two RepNotifies. Per
feedback_p2p_replication_audit,CurrentAssignmentandTasksCompletedarrive independently — UI must read each via its own getter or accept eventual consistency. Phase 1 ships them as paired RepNotifies; do not fold them into one OnRep. - The placeholder
AssignTasksignature is temporary. Phase 3 replaces it with theISlayerMaster-shaped overload. Don't bake the cheat-shaped (FamilyTag, Count, XP) signature into save/load or UI consumers — only the cheat path uses it. The runtimeAssignTaskreturns to the contract's shape in Phase 3.
Phase 2 — Kill Subscription & Slayer XP Grant
Goal. The kill loop closes. USlayerComponent subscribes Combat.Event.Kill on the owner ASC in BeginPlay; on each kill, matches the victim's classification tags against CurrentAssignment.FamilyTag (hierarchical HasTag), reads the victim's UEnemyDefinition::SlayerXp, grants it via GrantXP(Skill.Slayer, ...), increments KillsDone, and auto-completes on the final kill (granting CompletionXp). The Slayer USkillDefinition asset is now authored and loadable. Player kills (with an assigned task) grant Slayer XP end-to-end without the cheat.
Rationale. Per SLAYER_SYSTEM.md "Kill Tracking", Combat.Event.Kill was designed shared from day one (QUEST_SYSTEM.md "Eliminate Task" — "the future Slayer skill subscribes; the surface is shared from day one"). Adding a second GenericGameplayEventCallbacks subscriber alongside UQuestComponent::HandleKillEvent is the canonical UE pattern. Closing the kill loop now (before the master + dialogue land) means Phase 3's verification can lean on real kills, not synthesized events.
Tasks.
- [x] Kill subscription —
Source/CRADL/Slayer/SlayerComponent.cpp: - [x]
BeginPlay: on authority, resolve the owner's ASC viaCast<IAbilitySystemInterface>(GetOwner())->GetAbilitySystemComponent()(theACradlPlayerState-owned ASC — same pathUQuestComponent::BeginPlayuses). Subscribe:KillEventHandle = ASC->GenericGameplayEventCallbacks.FindOrAdd(CradlTags::Combat_Event_Kill).AddUObject(this, &USlayerComponent::HandleKillEvent). - [x]
EndPlay: remove the handle (ASC->GenericGameplayEventCallbacks.FindOrAdd(CradlTags::Combat_Event_Kill).Remove(KillEventHandle)) — the exact lifecycle shapeUQuestComponent::HandleKillEvent's subscriber uses. - [x] Bind on authority only — same rationale as quest's Phase 2 skill-threshold sweep (
Combat.Event.Killfires on the attribution winner's PS-ASC, which on a remote-attribution client would receive nothing useful; the authority binding is the canonical path). - [x]
HandleKillEvent—Source/CRADL/Slayer/SlayerComponent.cpp: - [x]
void HandleKillEvent(const FGameplayEventData* EventData). GuardEventData != nullptr; early-out if!CurrentAssignment.FamilyTag.IsValid()(no task). - [x] If
EventData->InstigatorTags.HasTag(CurrentAssignment.FamilyTag)(hierarchical contained-in match): read victimint64 VictimSlayerXp = 0; if (const AEnemyCharacter* Victim = Cast<AEnemyCharacter>(EventData->Target)) { if (const UEnemyDefinition* Def = Victim->GetActiveDefinition()) { VictimSlayerXp = Def->SlayerXp; } }. Defensively null-check both casts per the contract's "Read the victim definition before teardown" footgun. - [x] Call
RecordKill(EventData->InstigatorTags, VictimSlayerXp)— re-uses the Phase-1 entry point unchanged. Completion auto-fires from insideRecordKillwhen the final kill lands. - [x] Slayer
USkillDefinitionasset authored under/Game/Definitions/Skills/(the existing scan path picks it up automatically — no+PrimaryAssetTypesToScanchange): - [x]
SkillTag = Skill.Slayer,DisplayName = "Slayer"(American English perfeedback_american_english). - [x]
XpCurvenull perproject_skill_xp_curve_optional—USkillRegistry's algorithmic OSRS fallback supplies the thresholds. - [x] Confirm via PIE:
Debug_GrantXP Skill.Slayer 1154(= level 10 threshold) → player reaches Slayer level 10 through the existing skills pipeline. Slayer XP rides the existingUSkillsComponentreplication, no new wiring. - [x]
UEnemyDefinition::SlayerXpauthoring sweep — set non-zeroSlayerXpon at least one authored archetype (e.g.DA_Enemy_Goblin→SlayerXp = 15) so the Phase-2 verification has data to land. (The validator already enforcesSlayerXp >= 0from Phase 0; this task is content-side authoring, not code.)
P2P replication audit (no new replicated state):
| Surface | Authority answer | Reason |
|---|---|---|
Combat.Event.Kill subscription |
Authority-only bind (GetOwner()->HasAuthority()) |
Per QUEST_SYSTEM.md "Eliminate Task", the event fires on the attribution winner's PS-ASC on authority; a client bind would never fire. Same discipline UQuestComponent::BeginPlay follows. |
RecordKill from the handler |
Already server-only from Phase 1 | The handler runs on authority; the mutator is server-only; the resulting CurrentAssignment / TasksCompleted mutations replicate via the Phase-0 RepNotifies. |
| Slayer XP grant | Existing USkillsComponent replication; no new field |
XP delta rides the existing Skills replication path — no slayer-specific replication is introduced. |
Verification.
- Place an
AEnemyCharacterof familyEnemy.Family.Goblinin a test map withSlayerXp = 15on its definition. Debug_AssignSlayerTask Enemy.Family.Goblin 2 50(Phase 1 cheat).- Engage and kill the enemy via the standard combat path (no cheats post-assign):
KillsDone = 1, Slayer XP rises by 15. - Kill a second Goblin: auto-complete fires —
TasksCompleted = 1,CurrentAssignmentcleared, Slayer XP rises by another 15 + 50 (kill + completion). - Assign
Enemy.Family.Goblinand kill anEnemy.Family.Cow: no XP, noKillsDoneincrement (hierarchical mismatch). - Assign
Enemy.Family.Goblinand kill anEnemy.Family.Goblin.Warrior(a leaf under the parent):KillsDoneincrements — hierarchical match per the contract's "Hierarchical match is intentional" footgun. - Null-attribution kill (engineered via a
Debug_KillUnattributedroute if one exists, or skipped if not — the contract notes it's a non-event, not a regression).KillsDoneunchanged. - P2P host+client: client-side kill grants the client's slayer state (authority-side ASC is the client's PS on authority — same path as quest Eliminate tasks).
Exits. Phase 3 can replace the cheat-driven AssignTask call with the master roll; Phase 4's UAssignSlayerTaskAbility will then dispatch via the dialogue widget. The full assign → kill → XP → complete loop is now cheat-driven end-to-end.
Footguns.
- Slayer is a second subscriber on the same event. Per SLAYER_SYSTEM.md "Kill Tracking", the event channel is shared; do not duplicate
Combat.Event.Killinto a slayer-only channel. TheUQuestComponentsubscriber and theUSlayerComponentsubscriber both fire from the same authority broadcast. - Read the victim definition before teardown.
UEnemyDeathAbilityfiresCombat.Event.Killbefore the pawn is destroyed (the handler runs synchronously on the authority frame), soCast<AEnemyCharacter>(EventData->Target)->GetActiveDefinition()is safe — but defensively null-check both casts per the contract. - Don't double-grant XP.
GrantXPself-routes throughServer_GrantXPon a client — butRecordKillis already server-side (the kill handler binds on authority, and the Phase-1 mutator is server-only). CallingGrantXPfrom the authority body is the single grant; no client RPC needed, no risk of double-grant via prediction. - Hierarchical match cuts both ways. An assignment on
Enemy.Family.Goblincounts everyGoblin.*leaf. Authors who want strict-leaf must name the leaf in the master's table (Phase 3 authoring concern).
Phase 3 — Slayer Master & Level-Gated Roll
Goal. A slayer master can hand out a task. USlayerMasterDefinition carries the full AssignableTasks table with MinSlayerLevel filters; AQuestGiverActor implements ISlayerMaster when SlayerPool is set; USlayerComponent::AssignTask(const TScriptInterface<ISlayerMaster>&) performs the filter-then-weight server-only roll and snapshots CompletionXp into the assignment. UCradlSlayerMasterDefinitionValidator runs at full depth. A cheat assigns from a placed master actor (no dialogue yet — Phase 4 closes that loop).
Rationale. Per SLAYER_SYSTEM.md "Task Assignment" and Level Gating, the roll is server-only and non-deterministic (a LocalPredicted client cannot roll without desyncing). Filter-then-weight is the correct order — dropping over-level entries before the weighted pick avoids an over-level entry skewing the weight total. Mirrors AQuestGiverActor shipping as the canonical IQuestGiver (DIALOGUE_SYSTEM.md "Quest-Binding Responses") — the actor that already owns the conversation owns the slayer-master role too, with the interface as the seam per feedback_interface_rule_reading.
Tasks.
- [x]
AQuestGiverActorimplementsISlayerMaster— Source/CRADL/Quests/QuestGiverActor.h +.cpp: - [x] Add
class ISlayerMasterto the inheritance list.#include "Slayer/SlayerMasterInterface.h". - [x]
virtual ESlayerGate EvaluateSlayerGate(const ACradlPlayerState* Player) const override:- Returns
ESlayerGate::NoneifSlayerPool == nullptr(the null = "not a master" sentinel). - Returns
ESlayerGate::NoneifPlayer == nullptrorPlayer->GetSlayerComponent() == nullptr. - Returns
ESlayerGate::NoneifPlayer->GetSlayerComponent()->HasActiveTask()(slot full — no new assign offered; the boolean accessor is single source of truth for "is the slot full"). - Returns
ESlayerGate::Noneif!SlayerPool->MasterRequirements.IsEmpty() && !Player->GetSkillsComponent()->MeetsRequirements(SlayerPool->MasterRequirements)(master tier not met). - Otherwise returns
ESlayerGate::Assign.
- Returns
- [x]
virtual const USlayerMasterDefinition* GetSlayerPool() const override { return SlayerPool; }. - [x] Per CLAUDE.md "interfaces over concrete types", the dialogue widget + assign ability bind to
ISlayerMaster, never toAQuestGiverActor. The cast-site discipline mirrors howIQuestGiveris reached. - [x]
USlayerComponent::AssignTask— replace Phase-1 signature —Source/CRADL/Slayer/SlayerComponent.cpp: - [x] New canonical signature:
void AssignTask(const TScriptInterface<ISlayerMaster>& Master). Server-only body (guardGetOwnerRole() == ROLE_Authority; off-authority callers route viaServer_AssignTask(AActor*)—TScriptInterfacedoesn't replicate cleanly in UE 5.4 UFUNCTION RPCs, so the wire carriesAActor*and the server casts back toISlayerMaster*). - [x] No-op if
CurrentAssignment.FamilyTag.IsValid()(slot full — per contract). - [x] Re-validate the gate server-side:
if (Master->EvaluateSlayerGate(GetOwner<ACradlPlayerState>()) != ESlayerGate::Assign) return;(visibility is owner-local UX; authority re-checks per DIALOGUE_SYSTEM.md "Quest-Binding Responses" — same rule as quest accept). - [x] Read pool:
const USlayerMasterDefinition* Pool = Master->GetSlayerPool();(null-guard). - [x] Filter-then-weight roll:
- Read
const int32 PlayerSlayerLevel = GetSlayerLevel();. - Build a filtered list
TArray<const FSlayerTaskEntry*> Eligible;containing entries whereEntry.MinSlayerLevel <= PlayerSlayerLevel(andEntry.Weight >= 1, defensively against scripted edits past the validator). - If
Eligible.IsEmpty(): post an owner-client message viaUCradlAbilitySystemComponent::PostClientMessage(source =Message.Source.System, level =Warning, body = "No slayer tasks available at your current Slayer level.") and return without writing — per contract's "Empty filtered pool is a valid outcome" footgun. Do NOT write an invalidCurrentAssignment. - Compute
int64 TotalWeight = sum(Eligible[i]->Weight);and a server-side RNG roll:int64 Pick = FMath::RandRange(1, TotalWeight);(usingFMath::RandRange— server-only RNG; do not seed from any predicted state). - Walk
Eligibleaccumulating weights until the threshold is hit; the entry that crosses it is the picked entry. - Roll count:
int32 Count = FMath::RandRange(Picked->MinCount, Picked->MaxCount);(defensively clamped against inverted band).
- Read
- [x] Build and assign:
CurrentAssignment = FSlayerAssignment { Picked->FamilyTag, Count, /*KillsDone=*/0, Picked->CompletionXp };. Mark dirty + broadcastOnAssignmentChangedon authority. - [x] (The Phase-1 cheat-shaped
AssignTask(FamilyTag, Count, CompletionXp)becomesAssignTaskRaw(FamilyTag, Count, CompletionXp)on the component; the cheat inACradlPlayerControlleris renamedDebug_AssignSlayerTaskRaw. Phase-1 cheats keep the raw shape for content-free testing.) - [x] Added
Server_AssignTask(AActor* MasterActor)RPC (UFUNCTION(Server, Reliable)) that forwards to the server-only body. The raw-shapeServer_AssignTaskRawkeeps the Phase-1 wire format for the cheat path. - [x] Validator depth — Source/CRADLEditor/Validators/CradlSlayerMasterDefinitionValidator.cpp:
- [x] Each
FamilyTagis valid and underEnemy.Family.*— uses theMatchesTagparent check patternCradlEnemyDefinitionValidatoralready does forUEnemyDefinition::FamilyTag. (Tag-by-name via the tag tree, NOT via a C++ symbol — perfeedback_gameplay_tag_decl_minimal; only the system verbs/markers earn symbols.) - [x]
MinSlayerLevelwithin[1, Slayer MaxLevel]— reads the SlayerUSkillDefinitionasset'sMaxLeveldirectly viaUAssetManager::GetPrimaryAssetDataList("SkillDefinition"). (Validators run editor-side without a GameInstance, so the runtimeUSkillRegistryGameInstanceSubsystem isn't reachable — but eachUSkillDefinitionasset carries its ownMaxLevel, which is the same value the runtime registry uses to size its threshold table. Falls back to 99 (theUSkillDefinitiondefault + OSRS cap) when no Slayer asset is authored yet.) - [x] At least one entry reachable at level 1 (else a fresh character can never be assigned a task) — warning.
- [x] Best-effort: each
FamilyTagmatches theFamilyTagof at least one knownUEnemyDefinition(or a descendant; hierarchical assignment is intentional per SLAYER_SYSTEM.md "Kill Tracking") — warning. (No family collector exists inCradlValidationHelperstoday; scoped local helperCollectKnownEnemyFamilyTagsscansUEnemyDefinitionassets directly viaUAssetManager::GetPrimaryAssetDataList. Best-effort, likePrerequisiteQuestsresolution.) - [x]
MasterRequirementsskill tags resolve viaCradlValidationHelpers::CollectKnownSkillTags— reused directly.Level >= 1defensive check beyond the slateClampMin. - [x] Cheat addition — Source/CRADL/Player/CradlPlayerController.cpp:
- [x]
Debug_AssignFromMaster(FName ActorLabel)— finds the namedAQuestGiverActorin the level (iterator-scoped lookup; matches actor label OR FName; filters to actors withSlayerPoolset — NOT the production path), wraps it inTScriptInterface<ISlayerMaster>, and callsSlayer->AssignTask(...). Lets Phase 3 verify the roll end-to-end without the dialogue widget. An emptyActorLabelpicks the first slayer-master in the world (useful for one-master test maps). - [x]
Debug_PrintSlayer(Phase 1) — the empty-slot hint now points atDebug_AssignFromMasterfirst, falling back to the raw cheat. TheAssignTask_AuthorityUE_LOG(LogCradlSlayer, Log, ...)line on a successful roll shows the picked family + count + completion XP — observable via the Output Log immediately after the cheat fires.
P2P replication audit:
| New surface | Replication | Reason |
|---|---|---|
Server_AssignTask(TScriptInterface<ISlayerMaster>) RPC |
Server, Reliable |
The roll is server-only and non-deterministic — clients must not roll (per contract Footguns). RPC reaches authority; authority writes CurrentAssignment; the COND_OwnerOnly RepNotify carries the result back. |
AQuestGiverActor::ISlayerMaster impl |
const virtuals on a non-replicated actor |
The predicate reads Player's replicated state and the asset CDO — no actor state involved; no replication concern. |
Verification.
- Author a
USlayerMasterDefinitionDA_SlayerMaster_Testwith three entries:(Enemy.Family.Goblin, Weight=2, Min=1, Max=3, MinSlayerLevel=1, CompletionXp=50),(Enemy.Family.Cow, Weight=1, Min=2, Max=4, MinSlayerLevel=1, CompletionXp=30),(Enemy.Family.Goblin.Wizard, Weight=1, Min=1, Max=2, MinSlayerLevel=10, CompletionXp=100). - Place an
AQuestGiverActorBP_SlayerMaster_Testin the test map withSlayerPool = DA_SlayerMaster_Test. - Fresh character (Slayer level 1):
Debug_AssignFromMaster BP_SlayerMaster_Test× 10 (withDebug_CompleteSlayerTaskbetween each) → log shows only Goblin / Cow picks; never Wizard (the level-10 gate filters it). Debug_GrantXP Skill.Slayer 99999to push Slayer level to ≥10; rerun the cheat 10×; observe Wizard now appears in the rolled pool.- Author a master with all entries at
MinSlayerLevel=99and assign at level 1 →Debug_PrintSlayershows the empty slot, owner-client receives the "No tasks available" message (no invalid write). - Set
MasterRequirements = [{Skill.Combat.Defense, 20}]on the master and callEvaluateSlayerGateat lower Defense (viaDebug_PrintSlayerGateor the Phase-4 dialogue UI when it lands) → returnsNone; raise Defense, returnsAssign. - Validator: author a master with an
Enemy.Family.NonexistentSpeciestag → save → warning (best-effort cross-asset miss). Author withMinSlayerLevel = 0→ error (>= 1). Author with all entries atMinSlayerLevel = 10→ warning (no entry reachable at level 1). - P2P host+client: client
Debug_AssignFromMaster→ clientServer_AssignTaskRPC reaches authority → authority rolls → COND_OwnerOnly RepNotify lands on the client; the other peer's state is unchanged.
Exits. Phase 4 can wire the dialogue widget + ability to call Server_AssignTask instead of the cheat. Phase 5 can read GetSlayerLevel() from the same component to gate combat.
Footguns.
- Only authority rolls. Per the contract's "The roll is the one thing that must never predict" footgun,
AssignTask's body is server-only; a predicted client roll would produce a different task and desync on replication. The Phase-4UAssignSlayerTaskAbilitypredicts activation, never the roll. - Re-validate the gate server-side. The player's state may have changed between widget render and click (level-up, competing assignment, master requirements drift) —
EvaluateSlayerGateis consulted again insideAssignTaskper the DIALOGUE_SYSTEM.md rule "visibility is advisory, not a gate." - Filter-then-weight, never weight-then-filter. Drop over-level entries before summing weights. Otherwise an over-level entry skews
TotalWeightand the pick walk can fail to produce a valid entry — per SLAYER_SYSTEM.md "Level Gating". - Empty filtered pool is a valid outcome — post a message, don't write garbage. Per the contract, an empty
Eligibleset posts an owner-client message and returns without mutation. Writing an invalidCurrentAssignmentwould leak through the COND_OwnerOnly replication and the UI would render nonsense. - The cheat lookup
Debug_AssignFromMaster ActorLabelis NOT production. It's a cheat-onlyTActorIteratorscan. The production path isEventData->Targetfrom the dialogue widget's dispatch in Phase 4 — never a label scan.
Phase 4 — Dialogue Integration & Assign Ability (End-to-End)
Goal. A player can walk up to a placed AQuestGiverActor with a SlayerPool, open the dialogue modal, see "Get a task" only when the gate allows, click it, and watch the assignment server-roll and replicate back. The full in-world loop closes — slayer is playable without cheats. Validator warns on the dialogue-binding misconfigurations.
Rationale. Per SLAYER_SYSTEM.md "Dialogue Integration", the assign verb is Action.Trigger.Slayer.Assign, dispatched from UDialogueWidget exactly as the quest verbs are dispatched (DIALOGUE_IMPLEMENTATION.md Phase 3). UAssignSlayerTaskAbility mirrors UAcceptQuestAbility's LocalPredicted shape but the roll is server-only (per Phase 3). The dialogue widget remains a pure tag publisher; it never calls USlayerComponent. The validator gains the missing-binding-pair checks that complete the dialogue contract per CLAUDE.md "validators in lockstep."
Depends on: Phase 3 (ISlayerMaster impl + Server_AssignTask), Phase 2 (kill-loop closes auto-completion observably), QUEST Phase 8 (the dialogue + quest-binding pattern this mirrors — already shipped), DIALOGUE Phase 3 (the dispatch hook on UDialogueWidget — already shipped).
Tasks.
- [x]
UAssignSlayerTaskAbility—Source/CRADL/Abilities/AssignSlayerTaskAbility.h+.cpp(new): - [x]
class CRADL_API UAssignSlayerTaskAbility : public UCradlGameplayAbility. Mirror Source/CRADL/Abilities/AcceptQuestAbility.h verbatim where the contracts match. - [x] Constructor:
NetExecutionPolicy = LocalPredicted(predicts activation; never predicts the roll).InstancingPolicy = InstancedPerActor. - [x]
AbilityTriggers.Add(FAbilityTriggerData { CradlTags::Action_Trigger_Slayer_Assign, EGameplayAbilityTriggerSource::GameplayEvent });. - [x]
ActivationRequiredTags.AddTag(CradlTags::Action_Modal_Dialogue);— declarative gate: only ever dispatched from an open conversation, same shapeUAcceptQuestAbilityuses. - [x]
ActivateAbilitybody:- Pull
EventDatafrom the trigger context. - On authority (
ActorInfo->IsNetAuthority()): resolve the master viaEventData->Target→ISlayerMaster* Master = Cast<ISlayerMaster>(TargetActor);(null-guard →PostServerMessage+EndAbility(false)). - Re-validate
EvaluateSlayerGate(PS) == ESlayerGate::Assign; on failurePostServerMessage+EndAbility(false). (The component re-validates again internally; the ability posts the user-facing rejection toast — the component logs but doesn't post for the gate-rejection case.) - Call
PS->GetSlayerComponent()->AssignTask(TScriptInterface<ISlayerMaster>(TargetActor)). The component handles the roll; the replicatedCurrentAssignmentreturns to the owner client. EndAbility(false /*WasCancelled*/).
- Pull
- [x] On non-authority: the LocalPredicted client predicts activation; the ability body is a no-op on the client (
EndAbilityimmediately after theIsNetAuthorityearly-out). No request payload object — per SLAYER_SYSTEM.md "Task Assignment", the master is theTargetand the roll is server-side, so the ability re-derives rather than carrying a parameter struct (same shape asUTurnInQuestItemAbility). - [x] Default-abilities wiring —
BP_CradlPlayerState: - [x] Add
UAssignSlayerTaskAbilitytoBP_CradlPlayerState.DefaultAbilities. (BP edit — the default-abilities array isTArray<TSubclassOf<UGameplayAbility>>on Source/CRADL/Player/CradlPlayerState.h; the default list lives on the BP CDO. Mirror howUAcceptQuestAbilitywas added in QUEST Phase 8.) - [x]
UDialogueWidgetvisibility hook — Source/CRADL/UI/DialogueWidget.h +.cpp: - [x] In
IsResponseVisible(const FDialogueResponse& Response), add the slayer check alongside the existingQuestBindingcheck:- If
Response.SlayerBinding.Gate == ESlayerGate::None, skip the slayer check (gate is "always visible from the slayer axis"). - Else: cast
SourceActortoISlayerMaster*; if null, returnfalse(master interface missing — response shouldn't render). Otherwise checkMaster->EvaluateSlayerGate(PS) == Response.SlayerBinding.Gate.
- If
- [x] Visibility and dispatch stay independent axes per the contract — the existing
DispatchDialogueEffect(Response.EffectEventTag, /*payload*/ nullptr)path firesAction.Trigger.Slayer.Assignfor free when the response'sEffectEventTagis set to that value. No new dispatch code. - [x] Don't gate the local node advance on the roll's success — the assign event is fire-and-forget from the widget's view; the assigned task arrives via
CurrentAssignmentreplication (per the contract's "Effect-tag then advance — order matters" footgun). (Behavior already correct —HandleResponseClickedcallsDispatchDialogueEffectthen unconditionally advances/closes; no change needed.) - [x] Defensive non-AND composition for a malformed response that slips past the validator: if a response carries BOTH a quest binding and a slayer binding, the widget hides it (the first failing gate decides; no ambiguous AND). Validator catches this as an error, but the widget refuses to render rather than guess.
- [x]
CradlDialogueDefinitionValidatorextension — Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.h +.cpp: - [x] Error: a response may not set both
QuestBinding(Role OR DialogueKey step gate) andSlayerBinding.Gate != None(the two gates would AND ambiguously — see SLAYER_SYSTEM.md "Dialogue Integration" Footguns). - [x] Warning: a
SlayerBinding.Gate == Assignresponse with a non-emptyEffectEventTagshould pair withAction.Trigger.Slayer.Assign(mirroring the existing quest verb-pairing warnings in the validator — empty effect tag is allowed). - [x] Validators-in-lockstep per CLAUDE.md — same change as the
SlayerBindingfield consumer. - [x] Authored test dialogue:
- [x] Add to
DA_Dialogue_Test_SlayerMastera response on the root node:Text = "Get a task",SlayerBinding.Gate = ESlayerGate::Assign,EffectEventTag = Action.Trigger.Slayer.Assign,NextNodeId = root(no branch — the modal stays open and the response simply hides post-assign because the gate now evaluates toNone). - [x] A second response:
Text = "Goodbye", no bindings,NextNodeId = NAME_None(closes modal). - [x] Place a
BP_QuestGiver_SlayerMasteractor with this dialogue + the Phase-3DA_SlayerMaster_Testslayer pool.
P2P replication audit:
| New surface | Replication | Reason |
|---|---|---|
UAssignSlayerTaskAbility |
LocalPredicted activation; server-authoritative roll body |
Predicts the activation of the ability (so the input chord is responsive); the roll itself never predicts. Mirrors UAcceptQuestAbility's shape. |
UDialogueWidget visibility evaluation |
Owner-local UX; advisory only | Per contract, visibility is owner-local — the server-side EvaluateSlayerGate in AssignTask is the authoritative check. |
| Validator additions | Editor-only | No runtime replication. |
Verification.
- In PIE, walk up to
BP_QuestGiver_SlayerMaster, press the Interact key → dialogue modal opens. With no active task, the "Get a task" response renders. - Click "Get a task" → modal stays open (no branch), the response hides (gate now
Nonebecause slot is full), Slayer task appears via the existing task-tracker UI binding toOnAssignmentChanged. - Engage the assigned family in the world; KillsDone increments per kill (Phase 2 loop); auto-complete fires on the final kill; the response re-appears in dialogue (slot now empty).
- Re-open dialogue, click "Get a task" again → a new roll lands (different family or count, depending on the weighted pick).
- Validator: author a response with
QuestBinding.Role = OfferANDSlayerBinding.Gate = Assign→ save → error. Author a response withSlayerBinding.Gate = AssignandEffectEventTag = None→ save → warning. - P2P host+client: client clicks "Get a task" →
UAssignSlayerTaskAbilityactivates LocalPredicted; the server-side roll lands; the client'sCurrentAssignmentRepNotify fires. The other peer's slayer state is unchanged (COND_OwnerOnly). - Listen-server host: host clicks "Get a task" → the authority broadcast (not the OnRep) refreshes the host's task UI per Phase 1's
OnAssignmentChangeddirect-broadcast. - Edge: lower the player's
Skill.Slayerlevel (Debug_GrantXP Skill.Slayer -big) so no entry is reachable; click "Get a task" → owner-client message "No tasks available for your Slayer level." appears; no assignment written; the response stays visible (slot still empty).
Exits. Slayer v1 core loop is in-world playable. Phase 5 is optional polish — gear weakness scope + resilience gate. Open Questions #2 (Report / slayer points) and #5 (spectator-visible slayer state) are deferred per the contract.
Footguns.
- Visibility is owner-local and advisory. Hiding the "Get a task" response is UX, not enforcement —
UAssignSlayerTaskAbilityre-validates the gate server-side. Per DIALOGUE_SYSTEM.md, restated here. - A response carries at most one binding. Setting both
QuestBinding.Role != NoneandSlayerBinding.Gate != Noneis an authoring error — the validator now flags it as an error. Don't AND the gates ambiguously at the widget level either; if the validator passes a malformed response through, the widget'sIsResponseVisibleshould evaluate both and the first failing gate hides the response (defensive: doesn't compose, just rejects). - Effect-tag then advance — order matters. The assign event is fire-and-forget from the widget's view; the ability rolls server-side. Don't gate the local node advance on the roll's success — the conversation is cosmetic, and the assigned task arrives via
CurrentAssignmentreplication (same as the quest accept path). - Avoid
Slotas a local inUDialogueWidget(feedback_slot_shadows_uwidget); bind any tooltip delegate inNativeOnInitialized(feedback_umg_tooltip_delegate_init_timing). The widget already follows these — Phase 4's edits inherit the discipline. - Don't carry a request payload struct. Per SLAYER_SYSTEM.md "Task Assignment", the master is the
Targetand the roll is server-side; noUSlayerAssignRequestobject — the ability re-derives. Adding one would invite client-supplied parameters into a non-deterministic roll, which is exactly the desync risk the contract forbids.
Phase 5 — Combat Modifiers: Resilience Gate & Weakness Scope (Optional)
Goal. The two optional combat flavors land: (1) a resilience gate that blocks attacks when the attacker's Slayer level is below the target's RequiredSlayerLevel, and (2) a weakness scope that publishes Status.AssignedTarget.MatchingFamily on the attacker ASC during damage resolution so slayer-gear EquipEffects can compose into the damage formula. Optional, parallel-able with Phase 4 once Phase 3 lands.
Rationale. Per SLAYER_SYSTEM.md "Combat Modifiers", the two flavors use different mechanisms by design — weakness is a graded, compositional gear bonus (tag + GE, mirrors FTargetClassificationScope); resilience is a binary eligibility verdict (explicit gate, no tag, no GE). Modeling resilience as a GE would require a per-damage-type gated GE on every resilient monster and a cross-ASC tag dance. A direct gate is simpler, OSRS-faithful, and matches how combat already early-outs in the swing path.
This phase is optional and last because the core slayer loop (assign → kill → XP → complete) closes at Phase 4 without it. Slayer is shippable without Phase 5; Phase 5 adds OSRS-flavor combat texture.
Depends on: Phase 3 (the engagement scope reads USlayerComponent::CurrentAssignment.FamilyTag; the resilience gate reads GetSlayerLevel()), and the existing combat damage-resolution sites in UCradlAutoAttackAbilityBase and UCastSpellAbility (shipped).
Tasks.
- [x] C++ tag symbol — Source/CRADL/CradlGameplayTags.h +
.cpp: - [x]
Status_AssignedTarget_MatchingFamily— the .ini stub already existed; the symbol earns its place now becauseFSlayerEngagementScopereferences it by name inAddLooseGameplayTag. Perfeedback_gameplay_tag_decl_minimal— the publisher arriving is exactly the trigger the stub's DevComment named. - [x] DevComment on the
Status.AssignedTargetparent + leaf rewritten to remove the "No publisher today" caveat and point toFSlayerEngagementScope. - [x]
FSlayerEngagementScope—Source/CRADL/Slayer/SlayerEngagementScope.h+.cpp(new): - [x] Lives in
namespace CradlCombatalongsideFTargetClassificationScopeso the two scopes are constructed as sibling locals at every damage site. - [x] Constructor:
FSlayerEngagementScope(UAbilitySystemComponent* AttackerASC, const AActor* Target). Resolves attacker viaCast<ACradlPlayerState>(AttackerASC->GetOwnerActor())->GetSlayerComponent(); reads target classification viaCast<ICombatStatsProvider>(Target)->GetClassificationTags(OutTags); on hierarchicalHasTag(Assignment.FamilyTag)match, pushesStatus.AssignedTarget.MatchingFamilyviaAddLooseGameplayTagand setsbApplied = true. Inert for non-player attackers (noUSlayerComponent) and empty-slot players (invalidAssignment.FamilyTag). - [x] Destructor: if
bApplied, callsRemoveLooseGameplayTagon the cachedAttackerASC. Raw pointer (notTWeakObjectPtr) is fine because the scope is stack-only and the ASC outlives the resolution frame; copy + move are deleted to enforce single-frame lifetime. - [x]
SlayerCombat::IsAttackPermitted—Source/CRADL/Slayer/SlayerCombat.h+.cpp(new): - [x] Free function in
namespace SlayerCombatmirroringnamespace CradlCombatMath. - [x] Null-guards:
Attacker == nullptr || Target == nullptr→ returntrue(let other failure paths surface naturally). - [x] Target read:
Cast<AEnemyCharacter>(Target)->GetActiveDefinition()->RequiredSlayerLevel. Non-enemy /Required <= 0→ returntrue(the contract's "non-enemy / no-requirement targets return true"). - [x] Attacker resolution: try
Cast<ACradlPlayerState>(Attacker)first (matchesGetDamageInstigator's player path), fall back toCast<APawn>(Attacker)->GetPlayerState<ACradlPlayerState>()for callers that pass an avatar. Non-player attackers (enemy pawn, target dummy) → returntrue. Read level viaPS->GetSlayerComponent()->GetSlayerLevel(). - [x] Return
AttackerLevel >= Required. - [x] Wire scope + gate into the swing path — Source/CRADL/Abilities/CradlAutoAttackAbilityBase.cpp:
- [x] Located via the
FTargetClassificationScopesymbol (per the spec's "NOT a line number" rule). - [x] Before the existing
FTargetClassificationScopeconstruction (and the defense/early-out that follows):SlayerCombat::IsAttackPermitted(GetDamageInstigator(), Target)→PostServerMessage(Message.Source.Ability, Warning, "Your Slayer level is too low to harm this.")+EndSelf(false)+return. Per contract: "no damage, owner-client message, end the action." - [x] After the gate passes:
FSlayerEngagementScope SlayerScope(Info->AbilitySystemComponent.Get(), Target);constructed immediately after the existingFTargetClassificationScope ClassificationScope(...)so both scopes live exactly one damage resolution. - [x] Wire scope + gate into the cast path — Source/CRADL/Abilities/CastSpellAbility.cpp:
- [x] Same insertion shape at the spell-cast
FTargetClassificationScopesite:SlayerCombat::IsAttackPermitted(PS, Target)(casterPSalready resolved upstream asCast<ACradlPlayerState>(Info->OwnerActor.Get())) →PostServerMessage+EndSelf(false); then the existing classification scope; thenFSlayerEngagementScope SlayerScope(OwnerASC, Target);alongside it. - [x]
UEnemyDefinition::RequiredSlayerLevelis consumed — the field was added Phase 0; the consumer is now live. Content task — set non-zeroRequiredSlayerLevelon resilient enemies (mirror theSlayerXpauthoring sweep from Phase 2). No code change. - [x] Slayer-gear
EquipEffectsGE authoring — content task: - [x] Author a test
UGameplayEffect(e.g.GE_Equip_SlayerHelm_DamageBonus) withOngoingTagRequirements = Status.AssignedTarget.MatchingFamilyand aCombat.Modifier.DamageMultmodifier (the existing target-conditional gear pattern STAT_PIPELINE ships — see STAT_PIPELINE.md "Target-Conditional Modifiers"). Slayer adds zero new combat math. - [x] Author a test item (
DA_Item_SlayerHelm) with this GE inEquipEffects; equip in PIE to verify the bonus activates only against the assigned family.
P2P replication audit:
| New surface | Replication | Reason |
|---|---|---|
FSlayerEngagementScope push of Status.AssignedTarget.MatchingFamily |
AddLooseGameplayTag (NOT the replicated variant) |
Authority-side damage-resolution state with no replicated consumer — mirrors FTargetClassificationScope. Per feedback_replicated_loose_tag_ue54: the replicated variant only writes the mirror in UE 5.4; don't introduce it without a paired local add. The contract is explicit on this. |
SlayerCombat::IsAttackPermitted |
Server-side check, writes no state | Reads live attacker Skill.Slayer level and target RequiredSlayerLevel; the verdict is consumed inline (early-out vs. continue). No tag, no GE, no replicated field. |
UEnemyDefinition::RequiredSlayerLevel |
CDO data, not replicated | Read-only at runtime per the UEnemyDefinition discipline. |
Verification.
- Author
DA_Enemy_GoblinElitewithRequiredSlayerLevel = 10. At Slayer level 1: engage it → swing fires → owner-client message "Your Slayer level is too low to harm this." → damage GE never applies → enemy HP unchanged.Debug_GrantXP Skill.Slayer 99999to ≥ level 10, retry → swing damages normally. - Author a
DA_Item_SlayerHelmwithGE_Equip_SlayerHelm_DamageBonus(+10%). Without a task assigned: equip + attack a Goblin → no bonus (tag never pushed). AssignEnemy.Family.Goblin→ attack a Goblin → bonus applies (tag pushed byFSlayerEngagementScope). Attack a Cow (not the task family) → no bonus (tag not pushed for non-assigned family). Stop attacking — tag clears on scope destructor. - For-the-record: combine resilience + weakness on one target (
RequiredSlayerLevel = 10, family is assigned, helm equipped). At under-level: blocked (gate trumps weakness — the order matters). At over-level + assigned: full weakness bonus. - Cast path: spell at an under-level resilient target → blocked the same way as the swing.
- AoE-safe: confirm
FSlayerEngagementScopelives inside any future per-target loop body (Phase 5 has no AoE today; this is forward discipline per the contract's "Push/pop spans exactly one damage resolution" footgun — flagged in the GE author note so future AoE work doesn't leak tags between targets). - P2P host+client: client attacks a resilient enemy under-level → client-side prediction shows the swing animation; the server's gate blocks the damage → damage GE never applies → enemy HP unchanged on both peers. (LocalPredicted ability + server-authoritative damage; the gate is on the same authority side as the existing damage application.)
Exits. Slayer v1 is complete: spine (Phase 1), kill loop (Phase 2), roll (Phase 3), dialogue (Phase 4), combat modifiers (Phase 5). Open Questions #2 (Report / slayer points), #4 (multi-family tasks), #5 (spectator-visible state), and #6's graded-resilience variant are explicitly deferred per the contract.
Footguns.
AddLooseGameplayTag, not the replicated variant. Per the contract andfeedback_replicated_loose_tag_ue54: the UE 5.4AddReplicatedLooseGameplayTagonly writes the mirror; without a paired local add the server-side read won't see its own write.FTargetClassificationScopeuses the local variant;FSlayerEngagementScopedoes the same.- Push/pop spans exactly one damage resolution. The scope is RAII and per-target. v1 has no AoE here, but any future AoE must construct the scope inside the per-target loop body so one target's tags don't leak into another's roll (the STAT_PIPELINE AoE rule, restated).
- Don't model resilience as a tag-gated GE. The contract is explicit: a binary "can I damage this" is not a composable modifier, and a tag-gated enemy GE would need the lock tag on the enemy ASC while the condition is attacker knowledge. The gate computes attacker-side and decides directly. If a graded resilience penalty is ever wanted (a soft accuracy nudge instead of a hard wall), that's the moment to revisit per Open Questions #6 — and it returns as a target-side tag-gated GE then.
- The gate runs server-side at resolution, not at target acquisition. Mirror OSRS: let the player swing and tell them no, rather than making the monster untargetable. The check sits at the swing/cast site (authority), not in the targeting UI. Don't be tempted to hide the target — that's a UI lie.
UEnemyDefinition::RequiredSlayerLevel = 0is the "no requirement" sentinel. The gate returnstrueforRequired == 0; do not interpret 0 as "everyone is under-level." The contract sets the field default to 0 explicitly for this reason; the validator's[0, max]range check in Phase 0 honors that.- No new C++ symbol for the parent
Status.AssignedTargetnamespace. Only the leafStatus.AssignedTarget.MatchingFamilyearns a symbol (Phase 5); the parent is .ini-only perfeedback_gameplay_tag_decl_minimal. Adding the parent symbol would be ceremony.