0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI SLAYER_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
SLAYER_IMPLEMENTATION.md 8271 words ~38 min read Updated 2026-07-03

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 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 USlayerMasterDefinition, FSlayerTaskEntry, UEnemyDefinition, or FDialogueResponse.SlayerBinding updates the matching validator under Source/CRADLEditor/Validators/ in the same change. Add USlayerMasterDefinition 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: 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 — USlayerComponent references it by name in GrantXP; the USkillDefinition asset's SkillTag reads it as data. DevComment per feedback_gameplay_tag_decl_minimal.)
  • [x] Action.Trigger.Slayer (parent, .ini only — namespace root for slayer dialogue verbs, mirrors the Action.Trigger.Quest root.)
  • [x] Action.Trigger.Slayer.Assign (.ini + C++ symbol — dispatched by UDialogueWidget for Assign-gate responses, trigger on UAssignSlayerTaskAbility.)
  • [x] Each new tag gets a DevComment matching the shape Action.Trigger.Quest.Accept uses ("Convert an offered ... — Dispatched by ... — activates ... — Payload: ...").
  • [x] C++ tag symbols — Source/CRADL/CradlGameplayTags.h + .cpp:
  • [x] Skill_Slayer and Action_Trigger_Slayer_Assign. (Do NOT add a symbol for Action.Trigger.Slayer parent — it's only namespaced, no C++ reads it by name.)
  • [x] (Status_AssignedTarget_MatchingFamily symbol lands in Phase 5 with its publisher, not here — Phase 0–4 do not name it.)
  • [x] PrimaryAssetTypesToScan — Config/DefaultGame.ini:
  • [x] +PrimaryAssetTypesToScan entry: PrimaryAssetType="SlayerMasterDefinition", AssetBaseClass=/Script/CRADL.SlayerMasterDefinition, Directories=((Path="/Game/Definitions/Slayer")). Mirror the QuestDefinition / DialogueDefinition entry shape exactly. (The Slayer USkillDefinition rides the existing SkillDefinition scan entry — do NOT add a second scan path for it.)
  • [x] USlayerMasterDefinitionSource/CRADL/Slayer/SlayerMasterDefinition.h + .cpp (new):
  • [x] class CRADL_API USlayerMasterDefinition : public UPrimaryDataAsset. Override GetPrimaryAssetId()FPrimaryAssetId(TEXT("SlayerMasterDefinition"), GetFName()) (mirror USpellDefinition / 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] FSlayerAssignmentSource/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; }. Invalid FamilyTag = empty slot.
  • [x] USlayerComponent shell — 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 CurrentAssignmentReplicated, ReplicatedUsing=OnRep_CurrentAssignment, COND_OwnerOnly.
    • int32 TasksCompletedReplicated, ReplicatedUsing=OnRep_TasksCompleted, COND_OwnerOnly.
  • [x] GetLifetimeReplicatedProps with the two DOREPLIFETIME_CONDITION(..., COND_OwnerOnly) lines.
  • [x] Empty OnRep_* bodies + the matching FSimpleMulticastDelegate OnAssignmentChanged / OnTasksCompletedChanged delegates. 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 discipline UQuestComponent follows per QUEST_IMPLEMENTATION.md Phase 1 structural note.)
  • [x] ISlayerMaster interface — Source/CRADL/Slayer/SlayerMasterInterface.h (new):
  • [x] UINTERFACE(MinimalAPI, BlueprintType) + class ISlayerMaster with 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.h for the ESlayerGate return type (see next bullet).
  • [x] ESlayerGate + FDialogueSlayerBindingSource/CRADL/Slayer/SlayerDialogueBinding.h (new):
  • [x] UENUM(BlueprintType) enum class ESlayerGate : uint8 { None, Assign }. (Report is deferred per SLAYER_SYSTEM.md Open Questions #2 — do NOT park it now per feedback_dont_symmetrize_speculatively.)
  • [x] USTRUCT(BlueprintType) struct CRADL_API FDialogueSlayerBinding { ESlayerGate Gate = ESlayerGate::None; }. (Parallel of FDialogueQuestBinding; intentionally lives in a slayer-owned header rather than extending DialogueDefinition.h's closed enum, per SLAYER_SYSTEM.md "Dialogue Integration".)
  • [x] FDialogueResponse extension — Source/CRADL/Dialogue/DialogueDefinition.h:
  • [x] #include "Slayer/SlayerDialogueBinding.h" at the top.
  • [x] Add UPROPERTY(EditAnywhere) FDialogueSlayerBinding SlayerBinding; to FDialogueResponse alongside the existing QuestBinding field.
  • [x] Authoring-only at Phase 0 — UDialogueWidget::IsResponseVisible ignores SlayerBinding until Phase 4.
  • [x] UEnemyDefinition field 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] AQuestGiverActor field 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; the ISlayerMaster implementation lands in Phase 3.
  • [x] UCradlSlayerMasterDefinitionValidator stub — 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): AssignableTasks non-empty; each entry's Weight ≥ 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] CradlEnemyDefinitionValidator extension — Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h + .cpp:
  • [x] SlayerXp ≥ 0 (also covered by meta=(ClampMin=0) defensively, mirror FQuestRewardManifestEntry::XpAmount). (Upper-bound check for RequiredSlayerLevel against the registry max level deferred to Phase 5 alongside the resilience-gate consumer — Phase 0 lands RequiredSlayerLevel >= 0 defensively; the ClampMin metadata covers the slate-level path.)
  • [x] RequiredSlayerLevel within [0, registry max level] — read max via USkillRegistry's threshold table, mirror the existing MinSlayerLevel-style range check pattern in CradlValidationHelpers. (Phase 0 lands the >= 0 defensive check; the registry-backed upper bound returns in Phase 5 with the gate consumer.)
  • [x] CLAUDE.md update — same change: add USlayerMasterDefinition to 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 USlayerMasterDefinition asset under /Game/Definitions/Slayer/. Author DisplayName = "Test Slayer Master", leave AssignableTasks empty → save → asset validator flags the empty-AssignableTasks error.
  • Add one entry with Weight=0 → save → validator flags Weight >= 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 USkillDefinition asset under the existing /Game/Definitions/Skills/ path with SkillTag = Skill.Slayer, DisplayName = "Slayer", XpCurve null (per project_skill_xp_curve_optional).
  • Author a UDialogueDefinition response with a SlayerBinding.Gate = Assign field set; save → no validator error (Phase 4 adds the no-double-binding and missing-effect-tag checks).
  • Open a UEnemyDefinition asset; confirm the new SlayerXp / RequiredSlayerLevel fields are visible with sensible defaults.
  • Place an AQuestGiverActor in a test map and confirm the SlayerPool field 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.Slayer parent. Per feedback_gameplay_tag_decl_minimal, symbols exist only for tags named in C++; the parent is namespace-only. The same rule that keeps the Enemy.Family.* leaves out of the header applies here.
  • Don't park a Report value in ESlayerGate. v1 auto-completes on the final kill so there is no Report consumer; per feedback_dont_symmetrize_speculatively, Report returns alongside the slayer-points feature, not preemptively. Adding it now would also force CradlDialogueDefinitionValidator to either accept a meaningless authored value or warn about it — both wrong.
  • FSlayerAssignment lives in SlayerComponent.h, not a separate header. Unlike quests (where the manifest needed sharing with the savegame format and earned its own QuestPersistentTypes.h), slayer's runtime state goes through component getters and ApplyPersistedState — the savegame never includes the component header. One header is the minimum dep shape; no leaf header needed.
  • FDialogueSlayerBinding lives in Slayer/, not Dialogue/. This is the deliberate alternative to extending EDialogueQuestRole per 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 to DialogueDefinition.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] USlayerComponent behavior — Source/CRADL/Slayer/SlayerComponent.cpp:
  • [x] Implement OnRep_CurrentAssignment → broadcast OnAssignmentChanged; OnRep_TasksCompleted → broadcast OnTasksCompletedChanged.
  • [x] Authority broadcasts the delegate directly after each mutation on listen-server (mirror USkillsComponent::ApplyXPGrant's discipline — OnRep does not fire on the host owner, per the Footguns note below).
  • [x] Server-only mutator API (each routes via Server_* RPC from a client, mirroring USkillsComponent::GrantXP):
    • [x] AssignTask(FGameplayTag FamilyTag, int32 TargetCount, int64 CompletionXp)Phase 1 placeholder shape taking explicit params for the cheat path. No-op if CurrentAssignment.FamilyTag.IsValid() (slot already filled). Writes the new assignment + broadcasts. (Phase 3 replaces this entry point with AssignTask(const TScriptInterface<ISlayerMaster>&) that performs the roll internally — the cheat-shaped overload either gets retired or stays as Debug_* 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 matches CurrentAssignment.FamilyTag against VictimFamilies (HasTag contained-in), increments KillsDone (clamped at TargetCount), grants per-kill XP via PS->GetSkillsComponent()->GrantXP(CradlTags::Skill_Slayer, SlayerXp), and on reaching TargetCount calls CompleteCurrentTask. (Caller wiring — the Combat.Event.Kill subscription — lands in Phase 2. This phase exercises it through a Debug_RecordSlayerKill cheat that synthesizes a FGameplayTagContainer.)
    • [x] CompleteCurrentTask() — grants CurrentAssignment.CompletionXp via GrantXP(CradlTags::Skill_Slayer, ...), increments TasksCompleted, clears CurrentAssignment (default-construct it back to invalid). Broadcasts both delegates on authority.
  • [x] Server_* RPC wrappers for off-authority call paths (mirror USkillsComponent::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] ApplyPersistedStateSource/CRADL/Slayer/SlayerComponent.{h,cpp}:
  • [x] void ApplyPersistedState(const FSlayerAssignment& InAssignment, int32 InTasksCompleted) — server-only restore. Filter InAssignment.FamilyTag against 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 a UE_LOG(..., Warning, ...) (mirror the IsKnownQuest-style filter UQuestComponent::ApplyPersistedState does, scaled down for slayer's tag-keyed state).
  • [x] LogCradlSlayer log 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 to QuestComponent, 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-field SaveVersion guard).
  • [x] Add two new fields on UCradlPlayerProfile: FSlayerAssignment SlayerAssignment; (reuses the runtime struct as a value type — FSlayerAssignment is plain data with no UObject refs, same shape as FLoadoutModifierInstance round-tripping per QUEST_IMPLEMENTATION.md Phase 1 PendingRewards note) and int32 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 via PS->GetSlayerComponent()->GetCurrentAssignment() and GetTasksCompleted(); copy into the profile fields directly. (No snapshot↔runtime conversion needed — FSlayerAssignment is the canonical shape for both, same as FPendingQuestReward per 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_SHIPPING exec block:
    • Debug_AssignSlayerTask(FName FamilyTag, int32 Count, int64 CompletionXp) — calls GetPlayerState<ACradlPlayerState>()->GetSlayerComponent()->AssignTask(...). Self-routes via Server_* on a client (mirror Debug_GrantXP / Debug_UnlockQuest).
    • Debug_RecordSlayerKill(FName FamilyTag, int64 SlayerXp) — synthesizes an FGameplayTagContainer { FamilyTag } and calls RecordKill. Lets Phase 1 exercise the increment-and-auto-complete path without a real kill event.
    • Debug_CompleteSlayerTask() — calls CompleteCurrentTask directly (skips kills; pure state test).
    • Debug_PrintSlayer() — logs CurrentAssignment (FamilyTag / TargetCount / KillsDone / CompletionXp) + TasksCompleted + the player's Skill.Slayer level.

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 100Debug_PrintSlayer shows the task. Debug_RecordSlayerKill Enemy.Family.Goblin 0 × 3 → on the third call, observe auto-completion: TasksCompleted = 1, CurrentAssignment.FamilyTag invalid, Skill.Slayer level reflects the CompletionXp grant (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 50 while a Goblin task is active → no-op (hierarchical mismatch); KillsDone unchanged.
  • 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_OwnerOnly gate).
  • Save → close PIE → load → confirm CurrentAssignment and TasksCompleted round-trip. Mid-task save (KillsDone = 2 / TargetCount = 3) survives.
  • Rename Enemy.Family.Cow between save and load (or delete it from the .ini) → on load, the orphan assignment drops with a LogCradlSlayer warning.
  • 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.

  • OnRep does not fire on the listen-server host. Per feedback_p2p_replication_audit and the contract, authority broadcasts the delegate directly after mutation; do NOT rely on OnRep_CurrentAssignment to refresh host UI. (Same discipline USkillsComponent::ApplyXPGrant follows — verbatim pattern.)
  • Don't reseed assignment from any ambient sweep. Per feedback_loose_tag_writer_bool_no_reseed, AssignTask is the only writer for CurrentAssignment; 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, CurrentAssignment and TasksCompleted arrive 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 AssignTask signature is temporary. Phase 3 replaces it with the ISlayerMaster-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 runtime AssignTask returns 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 via Cast<IAbilitySystemInterface>(GetOwner())->GetAbilitySystemComponent() (the ACradlPlayerState-owned ASC — same path UQuestComponent::BeginPlay uses). 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 shape UQuestComponent::HandleKillEvent's subscriber uses.
  • [x] Bind on authority only — same rationale as quest's Phase 2 skill-threshold sweep (Combat.Event.Kill fires 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] HandleKillEventSource/CRADL/Slayer/SlayerComponent.cpp:
  • [x] void HandleKillEvent(const FGameplayEventData* EventData). Guard EventData != nullptr; early-out if !CurrentAssignment.FamilyTag.IsValid() (no task).
  • [x] If EventData->InstigatorTags.HasTag(CurrentAssignment.FamilyTag) (hierarchical contained-in match): read victim int64 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 inside RecordKill when the final kill lands.
  • [x] Slayer USkillDefinition asset authored under /Game/Definitions/Skills/ (the existing scan path picks it up automatically — no +PrimaryAssetTypesToScan change):
  • [x] SkillTag = Skill.Slayer, DisplayName = "Slayer" (American English per feedback_american_english).
  • [x] XpCurve null per project_skill_xp_curve_optionalUSkillRegistry'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 existing USkillsComponent replication, no new wiring.
  • [x] UEnemyDefinition::SlayerXp authoring sweep — set non-zero SlayerXp on at least one authored archetype (e.g. DA_Enemy_GoblinSlayerXp = 15) so the Phase-2 verification has data to land. (The validator already enforces SlayerXp >= 0 from 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 AEnemyCharacter of family Enemy.Family.Goblin in a test map with SlayerXp = 15 on 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, CurrentAssignment cleared, Slayer XP rises by another 15 + 50 (kill + completion).
  • Assign Enemy.Family.Goblin and kill an Enemy.Family.Cow: no XP, no KillsDone increment (hierarchical mismatch).
  • Assign Enemy.Family.Goblin and kill an Enemy.Family.Goblin.Warrior (a leaf under the parent): KillsDone increments — hierarchical match per the contract's "Hierarchical match is intentional" footgun.
  • Null-attribution kill (engineered via a Debug_KillUnattributed route if one exists, or skipped if not — the contract notes it's a non-event, not a regression). KillsDone unchanged.
  • 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.Kill into a slayer-only channel. The UQuestComponent subscriber and the USlayerComponent subscriber both fire from the same authority broadcast.
  • Read the victim definition before teardown. UEnemyDeathAbility fires Combat.Event.Kill before the pawn is destroyed (the handler runs synchronously on the authority frame), so Cast<AEnemyCharacter>(EventData->Target)->GetActiveDefinition() is safe — but defensively null-check both casts per the contract.
  • Don't double-grant XP. GrantXP self-routes through Server_GrantXP on a client — but RecordKill is already server-side (the kill handler binds on authority, and the Phase-1 mutator is server-only). Calling GrantXP from 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.Goblin counts every Goblin.* 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] AQuestGiverActor implements ISlayerMasterSource/CRADL/Quests/QuestGiverActor.h + .cpp:
  • [x] Add class ISlayerMaster to the inheritance list. #include "Slayer/SlayerMasterInterface.h".
  • [x] virtual ESlayerGate EvaluateSlayerGate(const ACradlPlayerState* Player) const override:
    • Returns ESlayerGate::None if SlayerPool == nullptr (the null = "not a master" sentinel).
    • Returns ESlayerGate::None if Player == nullptr or Player->GetSlayerComponent() == nullptr.
    • Returns ESlayerGate::None if Player->GetSlayerComponent()->HasActiveTask() (slot full — no new assign offered; the boolean accessor is single source of truth for "is the slot full").
    • Returns ESlayerGate::None if !SlayerPool->MasterRequirements.IsEmpty() && !Player->GetSkillsComponent()->MeetsRequirements(SlayerPool->MasterRequirements) (master tier not met).
    • Otherwise returns ESlayerGate::Assign.
  • [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 to AQuestGiverActor. The cast-site discipline mirrors how IQuestGiver is 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 (guard GetOwnerRole() == ROLE_Authority; off-authority callers route via Server_AssignTask(AActor*)TScriptInterface doesn't replicate cleanly in UE 5.4 UFUNCTION RPCs, so the wire carries AActor* and the server casts back to ISlayerMaster*).
  • [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 where Entry.MinSlayerLevel <= PlayerSlayerLevel (and Entry.Weight >= 1, defensively against scripted edits past the validator).
    • If Eligible.IsEmpty(): post an owner-client message via UCradlAbilitySystemComponent::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 invalid CurrentAssignment.
    • Compute int64 TotalWeight = sum(Eligible[i]->Weight); and a server-side RNG roll: int64 Pick = FMath::RandRange(1, TotalWeight); (using FMath::RandRange — server-only RNG; do not seed from any predicted state).
    • Walk Eligible accumulating 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).
  • [x] Build and assign: CurrentAssignment = FSlayerAssignment { Picked->FamilyTag, Count, /*KillsDone=*/0, Picked->CompletionXp };. Mark dirty + broadcast OnAssignmentChanged on authority.
  • [x] (The Phase-1 cheat-shaped AssignTask(FamilyTag, Count, CompletionXp) becomes AssignTaskRaw(FamilyTag, Count, CompletionXp) on the component; the cheat in ACradlPlayerController is renamed Debug_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-shape Server_AssignTaskRaw keeps the Phase-1 wire format for the cheat path.
  • [x] Validator depth — Source/CRADLEditor/Validators/CradlSlayerMasterDefinitionValidator.cpp:
  • [x] Each FamilyTag is valid and under Enemy.Family.* — uses the MatchesTag parent check pattern CradlEnemyDefinitionValidator already does for UEnemyDefinition::FamilyTag. (Tag-by-name via the tag tree, NOT via a C++ symbol — per feedback_gameplay_tag_decl_minimal; only the system verbs/markers earn symbols.)
  • [x] MinSlayerLevel within [1, Slayer MaxLevel] — reads the Slayer USkillDefinition asset's MaxLevel directly via UAssetManager::GetPrimaryAssetDataList("SkillDefinition"). (Validators run editor-side without a GameInstance, so the runtime USkillRegistry GameInstanceSubsystem isn't reachable — but each USkillDefinition asset carries its own MaxLevel, which is the same value the runtime registry uses to size its threshold table. Falls back to 99 (the USkillDefinition default + 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 FamilyTag matches the FamilyTag of at least one known UEnemyDefinition (or a descendant; hierarchical assignment is intentional per SLAYER_SYSTEM.md "Kill Tracking") — warning. (No family collector exists in CradlValidationHelpers today; scoped local helper CollectKnownEnemyFamilyTags scans UEnemyDefinition assets directly via UAssetManager::GetPrimaryAssetDataList. Best-effort, like PrerequisiteQuests resolution.)
  • [x] MasterRequirements skill tags resolve via CradlValidationHelpers::CollectKnownSkillTags — reused directly. Level >= 1 defensive check beyond the slate ClampMin.
  • [x] Cheat addition — Source/CRADL/Player/CradlPlayerController.cpp:
  • [x] Debug_AssignFromMaster(FName ActorLabel) — finds the named AQuestGiverActor in the level (iterator-scoped lookup; matches actor label OR FName; filters to actors with SlayerPool set — NOT the production path), wraps it in TScriptInterface<ISlayerMaster>, and calls Slayer->AssignTask(...). Lets Phase 3 verify the roll end-to-end without the dialogue widget. An empty ActorLabel picks the first slayer-master in the world (useful for one-master test maps).
  • [x] Debug_PrintSlayer (Phase 1) — the empty-slot hint now points at Debug_AssignFromMaster first, falling back to the raw cheat. The AssignTask_Authority UE_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 USlayerMasterDefinition DA_SlayerMaster_Test with 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 AQuestGiverActor BP_SlayerMaster_Test in the test map with SlayerPool = DA_SlayerMaster_Test.
  • Fresh character (Slayer level 1): Debug_AssignFromMaster BP_SlayerMaster_Test × 10 (with Debug_CompleteSlayerTask between each) → log shows only Goblin / Cow picks; never Wizard (the level-10 gate filters it).
  • Debug_GrantXP Skill.Slayer 99999 to 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=99 and assign at level 1 → Debug_PrintSlayer shows the empty slot, owner-client receives the "No tasks available" message (no invalid write).
  • Set MasterRequirements = [{Skill.Combat.Defense, 20}] on the master and call EvaluateSlayerGate at lower Defense (via Debug_PrintSlayerGate or the Phase-4 dialogue UI when it lands) → returns None; raise Defense, returns Assign.
  • Validator: author a master with an Enemy.Family.NonexistentSpecies tag → save → warning (best-effort cross-asset miss). Author with MinSlayerLevel = 0 → error (>= 1). Author with all entries at MinSlayerLevel = 10 → warning (no entry reachable at level 1).
  • P2P host+client: client Debug_AssignFromMaster → client Server_AssignTask RPC 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-4 UAssignSlayerTaskAbility predicts 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) — EvaluateSlayerGate is consulted again inside AssignTask per 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 TotalWeight and 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 Eligible set posts an owner-client message and returns without mutation. Writing an invalid CurrentAssignment would leak through the COND_OwnerOnly replication and the UI would render nonsense.
  • The cheat lookup Debug_AssignFromMaster ActorLabel is NOT production. It's a cheat-only TActorIterator scan. The production path is EventData->Target from 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] UAssignSlayerTaskAbilitySource/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 shape UAcceptQuestAbility uses.
  • [x] ActivateAbility body:
    • Pull EventData from the trigger context.
    • On authority (ActorInfo->IsNetAuthority()): resolve the master via EventData->TargetISlayerMaster* Master = Cast<ISlayerMaster>(TargetActor); (null-guard → PostServerMessage + EndAbility(false)).
    • Re-validate EvaluateSlayerGate(PS) == ESlayerGate::Assign; on failure PostServerMessage + 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 replicated CurrentAssignment returns to the owner client.
    • EndAbility(false /*WasCancelled*/).
  • [x] On non-authority: the LocalPredicted client predicts activation; the ability body is a no-op on the client (EndAbility immediately after the IsNetAuthority early-out). No request payload object — per SLAYER_SYSTEM.md "Task Assignment", the master is the Target and the roll is server-side, so the ability re-derives rather than carrying a parameter struct (same shape as UTurnInQuestItemAbility).
  • [x] Default-abilities wiring — BP_CradlPlayerState:
  • [x] Add UAssignSlayerTaskAbility to BP_CradlPlayerState.DefaultAbilities. (BP edit — the default-abilities array is TArray<TSubclassOf<UGameplayAbility>> on Source/CRADL/Player/CradlPlayerState.h; the default list lives on the BP CDO. Mirror how UAcceptQuestAbility was added in QUEST Phase 8.)
  • [x] UDialogueWidget visibility hook — Source/CRADL/UI/DialogueWidget.h + .cpp:
  • [x] In IsResponseVisible(const FDialogueResponse& Response), add the slayer check alongside the existing QuestBinding check:
    • If Response.SlayerBinding.Gate == ESlayerGate::None, skip the slayer check (gate is "always visible from the slayer axis").
    • Else: cast SourceActor to ISlayerMaster*; if null, return false (master interface missing — response shouldn't render). Otherwise check Master->EvaluateSlayerGate(PS) == Response.SlayerBinding.Gate.
  • [x] Visibility and dispatch stay independent axes per the contract — the existing DispatchDialogueEffect(Response.EffectEventTag, /*payload*/ nullptr) path fires Action.Trigger.Slayer.Assign for free when the response's EffectEventTag is 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 CurrentAssignment replication (per the contract's "Effect-tag then advance — order matters" footgun). (Behavior already correct — HandleResponseClicked calls DispatchDialogueEffect then 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] CradlDialogueDefinitionValidator extension — Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.h + .cpp:
  • [x] Error: a response may not set both QuestBinding (Role OR DialogueKey step gate) and SlayerBinding.Gate != None (the two gates would AND ambiguously — see SLAYER_SYSTEM.md "Dialogue Integration" Footguns).
  • [x] Warning: a SlayerBinding.Gate == Assign response with a non-empty EffectEventTag should pair with Action.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 SlayerBinding field consumer.
  • [x] Authored test dialogue:
  • [x] Add to DA_Dialogue_Test_SlayerMaster a 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 to None).
  • [x] A second response: Text = "Goodbye", no bindings, NextNodeId = NAME_None (closes modal).
  • [x] Place a BP_QuestGiver_SlayerMaster actor with this dialogue + the Phase-3 DA_SlayerMaster_Test slayer 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 None because slot is full), Slayer task appears via the existing task-tracker UI binding to OnAssignmentChanged.
  • 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 = Offer AND SlayerBinding.Gate = Assign → save → error. Author a response with SlayerBinding.Gate = Assign and EffectEventTag = None → save → warning.
  • P2P host+client: client clicks "Get a task" → UAssignSlayerTaskAbility activates LocalPredicted; the server-side roll lands; the client's CurrentAssignment RepNotify 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 OnAssignmentChanged direct-broadcast.
  • Edge: lower the player's Skill.Slayer level (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 — UAssignSlayerTaskAbility re-validates the gate server-side. Per DIALOGUE_SYSTEM.md, restated here.
  • A response carries at most one binding. Setting both QuestBinding.Role != None and SlayerBinding.Gate != None is 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's IsResponseVisible should 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 CurrentAssignment replication (same as the quest accept path).
  • Avoid Slot as a local in UDialogueWidget (feedback_slot_shadows_uwidget); bind any tooltip delegate in NativeOnInitialized (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 Target and the roll is server-side; no USlayerAssignRequest object — 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 because FSlayerEngagementScope references it by name in AddLooseGameplayTag. Per feedback_gameplay_tag_decl_minimal — the publisher arriving is exactly the trigger the stub's DevComment named.
  • [x] DevComment on the Status.AssignedTarget parent + leaf rewritten to remove the "No publisher today" caveat and point to FSlayerEngagementScope.
  • [x] FSlayerEngagementScopeSource/CRADL/Slayer/SlayerEngagementScope.h + .cpp (new):
  • [x] Lives in namespace CradlCombat alongside FTargetClassificationScope so the two scopes are constructed as sibling locals at every damage site.
  • [x] Constructor: FSlayerEngagementScope(UAbilitySystemComponent* AttackerASC, const AActor* Target). Resolves attacker via Cast<ACradlPlayerState>(AttackerASC->GetOwnerActor())->GetSlayerComponent(); reads target classification via Cast<ICombatStatsProvider>(Target)->GetClassificationTags(OutTags); on hierarchical HasTag(Assignment.FamilyTag) match, pushes Status.AssignedTarget.MatchingFamily via AddLooseGameplayTag and sets bApplied = true. Inert for non-player attackers (no USlayerComponent) and empty-slot players (invalid Assignment.FamilyTag).
  • [x] Destructor: if bApplied, calls RemoveLooseGameplayTag on the cached AttackerASC. Raw pointer (not TWeakObjectPtr) 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::IsAttackPermittedSource/CRADL/Slayer/SlayerCombat.h + .cpp (new):
  • [x] Free function in namespace SlayerCombat mirroring namespace CradlCombatMath.
  • [x] Null-guards: Attacker == nullptr || Target == nullptr → return true (let other failure paths surface naturally).
  • [x] Target read: Cast<AEnemyCharacter>(Target)->GetActiveDefinition()->RequiredSlayerLevel. Non-enemy / Required <= 0 → return true (the contract's "non-enemy / no-requirement targets return true").
  • [x] Attacker resolution: try Cast<ACradlPlayerState>(Attacker) first (matches GetDamageInstigator's player path), fall back to Cast<APawn>(Attacker)->GetPlayerState<ACradlPlayerState>() for callers that pass an avatar. Non-player attackers (enemy pawn, target dummy) → return true. Read level via PS->GetSlayerComponent()->GetSlayerLevel().
  • [x] Return AttackerLevel >= Required.
  • [x] Wire scope + gate into the swing path — Source/CRADL/Abilities/CradlAutoAttackAbilityBase.cpp:
  • [x] Located via the FTargetClassificationScope symbol (per the spec's "NOT a line number" rule).
  • [x] Before the existing FTargetClassificationScope construction (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 existing FTargetClassificationScope 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 FTargetClassificationScope site: SlayerCombat::IsAttackPermitted(PS, Target) (caster PS already resolved upstream as Cast<ACradlPlayerState>(Info->OwnerActor.Get())) → PostServerMessage + EndSelf(false); then the existing classification scope; then FSlayerEngagementScope SlayerScope(OwnerASC, Target); alongside it.
  • [x] UEnemyDefinition::RequiredSlayerLevel is consumed — the field was added Phase 0; the consumer is now live. Content task — set non-zero RequiredSlayerLevel on resilient enemies (mirror the SlayerXp authoring sweep from Phase 2). No code change.
  • [x] Slayer-gear EquipEffects GE authoring — content task:
  • [x] Author a test UGameplayEffect (e.g. GE_Equip_SlayerHelm_DamageBonus) with OngoingTagRequirements = Status.AssignedTarget.MatchingFamily and a Combat.Modifier.DamageMult modifier (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 in EquipEffects; 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_GoblinElite with RequiredSlayerLevel = 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 99999 to ≥ level 10, retry → swing damages normally.
  • Author a DA_Item_SlayerHelm with GE_Equip_SlayerHelm_DamageBonus(+10%). Without a task assigned: equip + attack a Goblin → no bonus (tag never pushed). Assign Enemy.Family.Goblin → attack a Goblin → bonus applies (tag pushed by FSlayerEngagementScope). 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 FSlayerEngagementScope lives 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 and feedback_replicated_loose_tag_ue54: the UE 5.4 AddReplicatedLooseGameplayTag only writes the mirror; without a paired local add the server-side read won't see its own write. FTargetClassificationScope uses the local variant; FSlayerEngagementScope does 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 = 0 is the "no requirement" sentinel. The gate returns true for Required == 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.AssignedTarget namespace. Only the leaf Status.AssignedTarget.MatchingFamily earns a symbol (Phase 5); the parent is .ini-only per feedback_gameplay_tag_decl_minimal. Adding the parent symbol would be ceremony.