CRADL Slayer System
Companion to ARCHITECTURE.md, STAT_PIPELINE.md (the combat-modifier contract the optional objectives lean on), QUEST_SYSTEM.md (which pre-declared the shared kill surface), and DIALOGUE_SYSTEM.md (the verb-dispatch pattern the slayer master reuses). This document is the contract slayer must satisfy — its skill spine, its runtime assignment state, the level-gated roll, how a slayer master surfaces inside a conversation, how kills advance a task, and the optional combat-resilience/weakness gating. Implementation patterns and phase ordering live in SLAYER_IMPLEMENTATION.md; what's here does not change without a deliberate edit to this file.
North Star
Slayer is OSRS-derived and a sibling progression system to Quests, not a quest subtype. A slayer master assigns a runtime-rolled task — {Enemy.Family, count} drawn from a level-gated weighted pool — and the player progresses by eliminating that family. Kills and task completions grant Slayer XP through the existing skill pipeline; rising Slayer level widens the assignable pool. The conversation that hands out a task is the same dialogue modal quests use, firing a new tagged verb at a server-authoritative ability.
The sibling split is load-bearing, not stylistic: forcing slayer into UQuestDefinition would require retrofitting three capabilities quests deliberately exclude — runtime-parameterized task instances (quest TargetCount is EditDefaultsOnly on the immutable CDO, QuestTask.h), a non-terminal/repeatable lifecycle (quests are monotonic Locked→Unlocked→Active→Completed and assert against re-entry, QUEST_SYSTEM.md), and a weighted roll-table unlock (quests use a prerequisite DAG + skill-threshold sweep). Those exclusions keep quest reconciliation, persistence, and validation tractable. Slayer reuses every progression and dispatch pattern the quest and skill systems already established — it adds one rolled-state component, one roll-table asset, and two thin verbs, not foundation.
Quick Reference
| Topic | Answer | Section |
|---|---|---|
| Slayer level | A USkillDefinition with SkillTag = Skill.Slayer (new); XP via USkillsComponent::GrantXP |
Slayer Skill |
| XP sources | Per-kill (assigned family) and per-task-completion bonus — both granted server-side | Slayer Skill, Kill Tracking |
| Runtime state | USlayerComponent : UActorComponent (new) on ACradlPlayerState — single rolling assignment slot, not an array |
Slayer Component |
| Assignment shape | FSlayerAssignment { FamilyTag, TargetCount, KillsDone, CompletionXp } (new) — COND_OwnerOnly + RepNotify; CompletionXp snapshotted at roll |
Slayer Component |
| Task pool | USlayerMasterDefinition : UPrimaryDataAsset (new) — weighted FSlayerTaskEntry table, mirrors FDropTableEntry |
Slayer Master Definition |
| The roll | Server-only: filter entries by MinSlayerLevel ≤ playerSlayerLevel, weighted-pick, roll count in [MinCount, MaxCount] |
Task Assignment |
| Master actor | AQuestGiverActor implements ISlayerMaster (new) when its SlayerPool is set — canonical implementer, interface is the contract |
Task Assignment |
| Dialogue surface | FDialogueSlayerBinding { ESlayerGate } (new) on FDialogueResponse; visibility via ISlayerMaster::EvaluateSlayerGate |
Dialogue Integration |
| Assign verb | Action.Trigger.Slayer.Assign (new) → UAssignSlayerTaskAbility (new, LocalPredicted, server-authoritative roll) |
Dialogue Integration, Task Assignment |
| Kill tracking | USlayerComponent subscribes Combat.Event.Kill, filters InstigatorTags.HasTag(Assignment.FamilyTag) |
Kill Tracking |
| Completion | Auto-completes on the final kill (no return trip); completion XP granted, slot cleared, TasksCompleted++ |
Kill Tracking |
| Harder tasks | Level-gated weighted pool — MinSlayerLevel per entry + optional MasterRequirements per master |
Level Gating |
| Resilient monsters (opt.) | First-class SlayerCombat::IsAttackPermitted gate (new) — blocks the swing/cast when attacker Slayer level < target RequiredSlayerLevel; no tag, no GE |
Combat Modifiers |
| Weak-to-slayer-gear (opt.) | Existing Status.AssignedTarget.MatchingFamily stub, now published; slayer-gear EquipEffects GE gates on it |
Combat Modifiers |
| Authority | Roll + all mutation server-authoritative; conversation cosmetic; engagement tags are loose, unreplicated | Authority & Replication |
| Validator | UCradlSlayerMasterDefinitionValidator (new); CradlEnemyDefinitionValidator + CradlDialogueDefinitionValidator updated in lockstep |
Validator |
| Registry | None — master pool reached by the actor's direct ref (mirrors dialogue); Slayer skill rides the existing USkillRegistry |
Slayer Master Definition, Open Questions |
Slayer Skill
Rule: Slayer level is a first-class skill, authored as a USkillDefinition instance with SkillTag = Skill.Slayer (new tag). It carries no special machinery: XP is granted via USkillsComponent::GrantXP(Skill.Slayer, Amount), level is read via the skills component's level getter, and the XP→level curve comes from USkillRegistry's algorithmic OSRS fallback (the asset's XpCurve may be left null). Two XP sources feed it: a per-kill grant when an assigned-family monster dies, and a per-task completion bonus.
Why: The skill system already supports a new skill as pure content — USkillDefinition (Source/CRADL/Skills/SkillDefinition.h) keyed by SkillTag, indexed by USkillRegistry (Source/CRADL/Skills/SkillRegistry.h), with GrantXP self-routing to authority on USkillsComponent (Source/CRADL/Skills/SkillsComponent.h). Per project_skill_xp_curve_optional, a null XpCurve is the expected, primary path — the registry computes thresholds from the canonical RuneScape formula. Slayer needs zero changes to the XP/level core; it is one authored asset plus the callers that grant its XP.
Implementation surface:
- Content: a USkillDefinition asset with SkillTag = Skill.Slayer, DisplayName = "Slayer" (American English per CLAUDE.md / feedback_american_english), scanned by the existing SkillDefinition +PrimaryAssetTypesToScan entry — no new scan entry for the skill.
- Skill.Slayer (new) earns a C++ symbol in Source/CRADL/CradlGameplayTags.h because USlayerComponent references it by name in GrantXP (per feedback_gameplay_tag_decl_minimal — declare a symbol only when C++ names the tag; the generic gather/craft skills stay .ini-only because they read the tag from data).
- Per-kill XP source: a new int64 SlayerXp field on UEnemyDefinition (Source/CRADL/Enemy/EnemyDefinition.h) — XP granted per kill while that monster is the player's slayer task; int64 to match USkillsComponent::GrantXP(FGameplayTag, int64). Read off the victim's active definition in the kill handler (see Kill Tracking).
- Per-completion XP source: an int64 CompletionXp field on FSlayerTaskEntry (see Slayer Master Definition) — snapshotted into FSlayerAssignment at roll time (the master entry is unreachable by the time the final kill lands; see Slayer Component) and banked when the slot empties.
Footguns:
- Don't add a Slayer aggregator attribute. Slayer level is USkillsComponent state, not a UCradlAttributeSet member — XP/level lives on the skills component by design (STAT_PIPELINE.md "Out of Scope", CradlAttributeSet.h header comment). Reads go through the skills component getter.
- Grant on authority only. GrantXP self-routes through Server_GrantXP; call it from authority-guarded paths so a predicted client doesn't double-grant (same discipline as the quest abilities).
Related: Source/CRADL/Skills/SkillsComponent.h, Source/CRADL/Skills/SkillRegistry.h, project_skill_xp_curve_optional, feedback_gameplay_tag_decl_minimal.
Slayer Component
Rule: USlayerComponent : UActorComponent (new) lives on ACradlPlayerState and owns the player's slayer runtime state: a single current assignment (not a set — the deliberate divergence from quests' ActiveQuests array) and a lifetime TasksCompleted counter. It mirrors the shape of UQuestComponent / USkillsComponent — SetIsReplicatedByDefault(true), COND_OwnerOnly + RepNotify state, Server_* authority routing, ApplyPersistedState for save/load — but not their data model.
Why: This is the canonical CRADL per-player progression shape (USkillsComponent, USpellbookComponent, UQuestComponent), all instantiated as siblings in ACradlPlayerState (Source/CRADL/Player/CradlPlayerState.h). PlayerState survives pawn possession; COND_OwnerOnly keeps slayer state private. Adding a sibling component is one CreateDefaultSubobject + one getter — the grounding confirmed no PlayerState replication change is needed. The single-slot model (vs. quests' array) is what makes slayer's "refilling slot, rerolled indefinitely" lifecycle clean: there is exactly one assignment, it is replaced wholesale on each roll, and there is no terminal "completed" archive to churn.
Implementation surface:
- Files: Source/CRADL/Slayer/SlayerComponent.{h,cpp} (new).
- State struct FSlayerAssignment (new): { FGameplayTag FamilyTag; int32 TargetCount; int32 KillsDone; int64 CompletionXp; }. An invalid FamilyTag means no active task (empty slot). KillsDone is monotonic, clamped at [0, TargetCount]. CompletionXp is snapshotted from the rolled FSlayerTaskEntry at assign time — the master is gone (no retained ref, no registry) by the time the final kill completes the task, so the bonus must travel with the assignment. Per-kill XP is not snapshotted: it's read live off each victim (always present at its own kill), so only the value whose source becomes unreachable is captured. No MasterId: nothing in the v1 loop resolves a master after assignment (the roll takes the live ISlayerMaster; completion is self-contained), and persisting an FName master id is exactly the "save-data references a master by FName" trigger the no-registry decision forbids (Open Questions #1). It returns with the deferred Report feature (#2), co-located with its consumer.
- Replicated fields, each ReplicatedUsing=OnRep_*:
- FSlayerAssignment CurrentAssignment — OnRep_CurrentAssignment broadcasts an OnAssignmentChanged delegate (UI binds the task tracker to it).
- int32 TasksCompleted — OnRep_TasksCompleted broadcasts OnTasksCompletedChanged. Folded into the same OnRep is acceptable only if the UI reads both through getters; otherwise keep paired RepNotifies (see Footguns).
- Authority entry points (server-only mutation; off-authority calls forward via Server_* RPC, mirroring USkillsComponent::GrantXP):
- AssignTask(const TScriptInterface<ISlayerMaster>& Master) — rolls from the master's pool (see Task Assignment) and writes CurrentAssignment, copying the rolled entry's CompletionXp into the assignment; no-op if a task is already active.
- RecordKill(const FGameplayTagContainer& VictimFamilies, int64 SlayerXp) — internal, called by the kill subscriber; if the assignment matches, increments KillsDone, grants per-kill XP, and on reaching TargetCount runs completion.
- CompleteCurrentTask() — grants CurrentAssignment.CompletionXp (no master lookup — the value was snapshotted at assign time), increments TasksCompleted, clears CurrentAssignment. Invoked from RecordKill on the final kill (auto-complete).
- Kill subscription: bound in BeginPlay against the owning ASC's GenericGameplayEventCallbacks, handle cached for EndPlay cleanup — the exact pattern UQuestComponent::HandleKillEvent uses (see Kill Tracking).
- Persistence: read-only getters + ApplyPersistedState(const FSlayerAssignment&, int32 TasksCompleted). UCradlSaveSubsystem (Source/CRADL/SaveGame/CradlSaveSubsystem.h) owns the snapshot↔runtime conversion and reaches the component via PS->GetSlayerComponent(), mirroring how it persists the spellbook/quest components.
P2P Replication Audit (per feedback_p2p_replication_audit):
| Field | Replication | Rationale |
|---|---|---|
CurrentAssignment |
COND_OwnerOnly + RepNotify |
Private progression; only the owner's UI shows their task. Replaced wholesale on each roll. |
TasksCompleted |
COND_OwnerOnly + RepNotify |
Private lifetime counter; drives any future points/streak surface. |
| Engagement loose tag | Not replicated | Status.AssignedTarget.MatchingFamily is authority-side, scoped to one damage resolution (see Combat Modifiers). The resilience gate reads live state and writes no tag. |
Footguns:
- Single slot, replaced not appended. CurrentAssignment is one struct, not a TArray. AssignTask overwrites; it never accumulates. Re-deriving "do I have a task" is CurrentAssignment.FamilyTag.IsValid().
- Listen-server fires no OnRep for the host owner. Authority must broadcast OnAssignmentChanged directly after mutation, exactly as USkillsComponent::ApplyXPGrant does — don't rely on OnRep_CurrentAssignment to refresh the host's own UI.
- Two replicated fields read together can diverge on arrival. Per feedback_p2p_replication_audit, if the UI reads CurrentAssignment and TasksCompleted together, give each its own RepNotify or accept eventual consistency through defensive getters — don't assume co-arrival.
- Don't reseed assignment from a per-tick eligibility sweep. Assignment is a one-time server event per roll; per feedback_loose_tag_writer_bool_no_reseed, the writer (AssignTask) is the only mutator — don't re-derive it from ambient state.
Related: Source/CRADL/Quests/QuestComponent.h, Source/CRADL/Skills/SkillsComponent.h, Source/CRADL/Player/CradlPlayerState.h, feedback_p2p_replication_audit, feedback_loose_tag_writer_bool_no_reseed.
Slayer Master Definition
Rule: A slayer master's assignable tasks are authored as a single USlayerMasterDefinition : UPrimaryDataAsset (new), reached through the master actor's direct reference — no runtime FName lookup, so no registry. It holds optional MasterRequirements (a TArray<FSkillRequirement> gating use of the master, plural to match FDropTableEntry::SkillRequirements) and a weighted TArray<FSlayerTaskEntry> table whose shape mirrors FDropTableEntry.
Why: Identity follows the standard CRADL DataAsset grounding (UPrimaryDataAsset + GetPrimaryAssetId returning FPrimaryAssetId("SlayerMasterDefinition", GetFName()), the pattern shared by UQuestDefinition / USpellDefinition / USkillDefinition). The weighted-pick-with-count-band shape already exists as FDropTableEntry (Source/CRADL/Loot/DropTableDefinition.h) — Weight, MinCount/MaxCount, optional SkillRequirements — so the roll reuses a proven struct shape rather than inventing one. No registry: like dialogue (DIALOGUE_SYSTEM.md "Dialogue Definition"), the asset is reached only through the master actor's direct ref; a USlayerMasterRegistry would be ceremony with no consumer (flagged in Open Questions).
Implementation surface:
- Files: Source/CRADL/Slayer/SlayerMasterDefinition.{h,cpp} (new).
- USlayerMasterDefinition fields:
- FText DisplayName — master's name for UI.
- TArray<FSkillRequirement> MasterRequirements — optional gate to use this master at all (e.g. Skill.Slayer or a combat skill at level N), reusing FSkillRequirement (Source/CRADL/Skills/SkillRequirement.h) and matching the plural shape FDropTableEntry::SkillRequirements already establishes. Empty = always usable; multi-entry = all must be met, via USkillsComponent::MeetsRequirements(TArrayView<const FSkillRequirement>).
- TArray<FSlayerTaskEntry> AssignableTasks.
- FSlayerTaskEntry (new): { FGameplayTag FamilyTag (meta Categories="Enemy.Family"); int32 Weight; int32 MinCount; int32 MaxCount; int32 MinSlayerLevel; int64 CompletionXp; }.
- +PrimaryAssetTypesToScan entry in Config/DefaultGame.ini for SlayerMasterDefinition over /Game/Definitions/Slayer (mirrors the spell/quest/dialogue entries) — needed for cooking + validator discovery even though runtime access is by direct ref.
Footguns:
- The CDO is shared and read-only. Never mutate a USlayerMasterDefinition* at runtime (same rule as UQuestDefinition / UEnemyDefinition). The roll reads; it does not write back.
- FamilyTag is hierarchical. An entry on Enemy.Family.Goblin assigns a task satisfied by killing Enemy.Family.Goblin.Warrior (the HasTag contained-in match the kill handler uses). Authors wanting strict-leaf must name the leaf.
- Don't fold the pool onto UEnemyDefinition. The pool is a per-master authoring surface (different masters offer different tiers); it is not enemy-archetype data. Keep it a standalone DataAsset (the items-vs-skills DataTable-vs-DataAsset split rationale, ARCH #5/#6).
Related: Source/CRADL/Loot/DropTableDefinition.h, Source/CRADL/Skills/SkillRequirement.h, Source/CRADL/Quests/QuestDefinition.h.
Task Assignment
Rule: A slayer master is any dialogue NPC that implements ISlayerMaster (new) and holds a USlayerMasterDefinition. v1 ships AQuestGiverActor as the canonical implementer — it already hosts the dialogue + IInteractable surface — gated by a non-null SlayerPool reference (null = the NPC is not a master). The interface is the contract so a non-AQuestGiverActor master works without re-threading. Assigning a task is server-authoritative: UAssignSlayerTaskAbility re-validates the gate, then USlayerComponent::AssignTask performs the server-only roll — filter AssignableTasks to entries whose MinSlayerLevel ≤ player's Slayer level, weighted-pick one by Weight, roll a count in [MinCount, MaxCount], and write CurrentAssignment.
Why: Mirrors AQuestGiverActor shipping as the canonical IQuestGiver (DIALOGUE_SYSTEM.md "Quest-Binding Responses") — the actor that already owns the conversation owns the role predicate. Per CLAUDE.md "interfaces over concrete types" and feedback_interface_rule_reading, the ISlayerMaster seam is introduced now (not after a second implementer arrives) so the dialogue widget and assign ability bind to the interface, not AQuestGiverActor. The roll must be server-only and non-deterministic — a LocalPredicted client cannot roll without desyncing — so the ability predicts activation while authority alone produces the assignment, which then replicates via CurrentAssignment (the predict-don't-mutate discipline UAcceptQuestAbility already follows).
Implementation surface:
- Files: Source/CRADL/Slayer/SlayerMasterInterface.h (new) — ISlayerMaster; Source/CRADL/Abilities/AssignSlayerTaskAbility.{h,cpp} (new) — UAssignSlayerTaskAbility.
- ISlayerMaster methods:
- ESlayerGate EvaluateSlayerGate(const ACradlPlayerState* Player) const — returns Assign when MasterRequirements are met and the player has no active task; else None. (The author-time visibility predicate the dialogue widget consults — see Dialogue Integration.)
- const USlayerMasterDefinition* GetSlayerPool() const — the master's task table for the roll.
- AQuestGiverActor (Source/CRADL/Quests/QuestGiverActor.h) gains UPROPERTY(EditAnywhere) TObjectPtr<USlayerMasterDefinition> SlayerPool; and an ISlayerMaster implementation. The actor stays content-only (bReplicates = false); the ref is level-authored.
- UAssignSlayerTaskAbility (LocalPredicted): trigger FAbilityTriggerData { Action.Trigger.Slayer.Assign, GameplayEvent }; ActivationRequiredTags = { Action.Modal.Dialogue } (only ever dispatched from an open conversation — the declarative gate UAcceptQuestAbility uses). Server body (guarded by IsNetAuthority): resolve the master from EventData.Target via Cast<ISlayerMaster>, confirm EvaluateSlayerGate(PS) == Assign, call Slayer->AssignTask(Master). No request payload object — the master is the Target and the roll is server-side, so (like UTurnInQuestItemAbility) the ability re-derives rather than carrying a parameter struct. Added to BP_CradlPlayerState.DefaultAbilities.
Footguns:
- Only authority rolls. The roll uses a server-side RNG; a predicted client must not roll (it would produce a different task and desync on replication). Guard AssignTask's body behind IsNetAuthority; the client predicts activation only.
- Re-validate the gate server-side. The player's Slayer level (or a competing assignment) may have changed between opening the dialogue and clicking — response visibility is owner-local UX, not the authority check (the same "visibility is advisory, not a gate" rule as DIALOGUE_SYSTEM.md).
- Empty filtered pool is a valid outcome. If no entry's MinSlayerLevel is satisfied (mis-authored master, or level too low for any task), AssignTask assigns nothing and posts an owner-client message — it must not write an invalid CurrentAssignment. The validator warns when a master has no entry reachable at level 1.
Related: Source/CRADL/Quests/QuestGiverActor.h, Source/CRADL/Abilities/AcceptQuestAbility.h, Source/CRADL/Quests/QuestGiverInterface.h, feedback_interface_rule_reading.
Dialogue Integration
Rule: A FDialogueResponse gains an optional FDialogueSlayerBinding { ESlayerGate Gate; } (new) — a parallel of FDialogueQuestBinding, evaluated against the source actor's ISlayerMaster::EvaluateSlayerGate. When Gate != None, the response renders only while the master's live gate for this player matches (Gate == Assign shows the "Get a task" option only when assignment is available). Visibility and action stay independent axes: the gate decides whether the option renders; the response's EffectEventTag = Action.Trigger.Slayer.Assign decides what selecting it fires. ESlayerGate is { None, Assign } for v1.
Why: The user-selected design (parallel binding + interface) keeps the dialogue contract's deliberately-closed EDialogueQuestRole set untouched (DIALOGUE_SYSTEM.md North Star rule 3) — slayer adds its own closed gate vocabulary in a slayer-owned header rather than extending dialogue's quest enum. It gives slayer the same author-time visibility gating quests enjoy (the master shows "Get a task" only when a task is actually available), evaluated through the ISlayerMaster interface per feedback_interface_rule_reading. The dialogue widget remains a pure tag publisher — it never calls USlayerComponent; it dispatches Action.Trigger.Slayer.Assign exactly as it dispatches Action.Trigger.Quest.Accept (ARCH #18).
v1 gate set — { None, Assign }, no Report: Tasks auto-complete on the final kill (see Kill Tracking), so there is no return-to-master "report" step in v1, and a Report gate would have no consumer. Per feedback_dont_symmetrize_speculatively, the reserved counterpart is not parked: if a future slayer-points/streak turn-in lands (which would need a report step), Report is added then alongside its ability. (This trims the illustrative Report value shown during the design fork — flagged so it can be reinstated if report-to-complete is preferred over auto-complete.)
Implementation surface:
- ESlayerGate enum ({ None, Assign }) + FDialogueSlayerBinding struct live in Source/CRADL/Slayer/SlayerDialogueBinding.h (new) — a slayer-owned header included by both Source/CRADL/Dialogue/DialogueDefinition.h (to add the field to FDialogueResponse) and SlayerMasterInterface.h (for the predicate return type). Keeping the type in a slayer file is the deliberate alternative to extending dialogue's own enum.
- FDialogueResponse gains UPROPERTY(EditAnywhere) FDialogueSlayerBinding SlayerBinding;.
- Visibility evaluation in UDialogueWidget (Source/CRADL/UI/DialogueWidget.h): when Response.SlayerBinding.Gate != None, cast the source actor (GetModalContext()->Target) to ISlayerMaster and show the response iff EvaluateSlayerGate(PS) == Response.SlayerBinding.Gate. Reuses the existing per-response visibility hook alongside the quest-binding check.
- Dispatch: the "Get a task" response sets EffectEventTag = Action.Trigger.Slayer.Assign; the widget's existing DispatchDialogueEffect(EventTag, /*payload*/ nullptr) fires it with Target = the master NPC — no new dispatch code.
Footguns:
- Visibility is owner-local and advisory. Hiding the "Get a task" option is UX, not enforcement — UAssignSlayerTaskAbility re-validates the gate server-side (the DIALOGUE_SYSTEM.md rule, restated).
- A response carries at most one binding. Setting both QuestBinding.Role != None and SlayerBinding.Gate != None on one response is an authoring error (the two gates would AND ambiguously) — the validator flags it.
- 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 the widget (feedback_slot_shadows_uwidget), and bind any tooltip delegate in NativeOnInitialized (feedback_umg_tooltip_delegate_init_timing) — the existing UDialogueWidget constraints apply unchanged.
Related: DIALOGUE_SYSTEM.md "Quest-Binding Responses", Source/CRADL/UI/DialogueWidget.h, Source/CRADL/Dialogue/DialogueDefinition.h, feedback_dont_symmetrize_speculatively, feedback_interface_rule_reading.
Kill Tracking
Rule: USlayerComponent subscribes the existing Combat.Event.Kill ASC event in BeginPlay and, on each kill, matches the victim's classification tags (EventData.InstigatorTags) against CurrentAssignment.FamilyTag (hierarchical HasTag). On a match it grants the victim's UEnemyDefinition::SlayerXp, increments KillsDone, and on reaching TargetCount auto-completes the task — grants the entry's CompletionXp, increments TasksCompleted, and clears the slot. This is the same kill notification quests' Eliminate task consumes; slayer is a second, independent subscriber.
Why: 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"). It fires on the attribution winner's PlayerState ASC from UEnemyDeathAbility (Source/CRADL/Enemy/EnemyDeathAbility.h) after UEnemyDropComponent::ResolveAttribution (Source/CRADL/Enemy/EnemyDropComponent.h), carrying the victim's ICombatStatsProvider::GetClassificationTags set (Source/CRADL/Combat/CombatStatsProviderInterface.h) as InstigatorTags. Adding a second GenericGameplayEventCallbacks subscriber is the canonical UE pattern — the grounding confirmed no refactor. The event is the shared asset, not the quest container.
Implementation surface:
- Subscribe in BeginPlay: ASC->GenericGameplayEventCallbacks.FindOrAdd(CradlTags::Combat_Event_Kill).AddUObject(this, &USlayerComponent::HandleKillEvent); cache the FDelegateHandle and remove it in EndPlay (the UQuestComponent::HandleKillEvent lifecycle, verbatim shape).
- HandleKillEvent: if CurrentAssignment.FamilyTag.IsValid() and EventData->InstigatorTags.HasTag(CurrentAssignment.FamilyTag), read the victim's SlayerXp from Cast<AEnemyCharacter>(EventData->Target)'s active UEnemyDefinition, then call RecordKill.
- Completion is owned by USlayerComponent (the lifecycle machine), not the handler — RecordKill calls CompleteCurrentTask on the final kill, exactly as UQuestComponent::AdvanceTask owns the Active→Completed transition rather than its subscribers.
Footguns:
- Null attribution = no event. A kill with no damage-credit winner never fires Combat.Event.Kill (per QUEST_SYSTEM.md and UEnemyDeathAbility). Slayer simply makes no progress on those kills — handle it as a non-event, never assume every assigned-family death advances.
- Read the victim definition before teardown. UEnemyDeathAbility fires Combat.Event.Kill before the pawn is destroyed and the handler runs synchronously on that authority frame, so reading EventData->Target's active definition is safe — but defensively null-check the cast (the actor may be mid-destroy on a delayed path).
- Hierarchical match is intentional. An assignment on Enemy.Family.Goblin counts Enemy.Family.Goblin.Warrior kills (HasTag contained-in). Strict-leaf assignments name the leaf in the master's table.
Related: Source/CRADL/Enemy/EnemyDeathAbility.h, Source/CRADL/Enemy/EnemyDropComponent.h, Source/CRADL/Quests/QuestComponent.h, QUEST_SYSTEM.md "Eliminate Task".
Level Gating
Rule: "Harder tasks unlock as Slayer level rises" is a level-gated weighted draw, not a prerequisite graph. Each FSlayerTaskEntry carries a MinSlayerLevel; the roll filters to entries the player's current Slayer level satisfies, then weighted-picks among the survivors. Optional per-master MasterRequirements gate whether a master will talk tasks at all (the OSRS "you need combat level N for this master" tier). No quest-style prerequisite DAG, no skill-threshold unlock sweep.
Why: This is the structural reason slayer is not a quest. Quest unlock is a one-time monotonic event from a PrerequisiteQuests DAG + skill-threshold sweep (QUEST_SYSTEM.md "Lifecycle"); slayer's "harder tasks" is a runtime filter over a weighted pool, re-evaluated on every roll against the live Slayer level. The pool draw is exactly the FDropTableEntry weighted-pick shape, with MinSlayerLevel as the filter predicate.
Implementation surface:
- The filter + weighted pick live in USlayerComponent::AssignTask (or a free helper in the slayer folder), reading USkillsComponent for the player's Skill.Slayer level.
- MasterRequirements are checked in ISlayerMaster::EvaluateSlayerGate via USkillsComponent::MeetsRequirements (so an unmet master requirement hides the "Get a task" option) and re-checked server-side in the assign ability.
Footguns: - Level is read live, not snapshotted. A player who levels up between conversations sees a wider pool on the next roll automatically — there is no cached eligibility set to invalidate (contrast the quest unlock sweep, which does persist unlock state). - Filter-then-weight, not weight-then-filter. Drop entries above the player's level before the weighted pick, or an over-level entry skews the weight total and can produce no valid pick.
Related: STAT_PIPELINE.md, Source/CRADL/Loot/DropTableDefinition.h, QUEST_SYSTEM.md "Lifecycle".
Combat Modifiers
(Both objectives here are optional — they are the last build phase and may ship after the core loop.)
Rule: The two combat flavors use different mechanisms, chosen by what each one is. Neither changes the damage formula or the single choke point in UCradlCombatMath.
- Weakness — tag + GE (graded, compositional, content-authored). A new RAII FSlayerEngagementScope (new), constructed at the same two damage-resolution sites that already use FTargetClassificationScope, pushes one authority-side loose tag — Status.AssignedTarget.MatchingFamily (the existing .ini stub, now published) — onto the attacker ASC when the target's family is the player's currently assigned family. Slayer-gear EquipEffects GEs with OngoingTagRequirements = Status.AssignedTarget.MatchingFamily then activate, applying their bonus only against your assigned task. This is byte-for-byte the Salve/Dragon-hunter target-conditional gear pattern STAT_PIPELINE already ships — slayer gear is a peer of every other conditional item, not a special case.
- Resilience — explicit gate (binary eligibility). A first-class SlayerCombat::IsAttackPermitted(Attacker, Target) (new) check at the top of the two resolution paths blocks the swing/cast when the attacker's Slayer level is below the target's UEnemyDefinition::RequiredSlayerLevel (new field): no damage, owner-client message, end the action. No tag, no GE — a yes/no "can I damage this at all" is not a graded modifier and gains nothing from routing through GAS aggregation.
Why the split: Tags + OngoingTagRequirements exist to compose — many sources summing into one number through GAS aggregation (the STAT_PIPELINE thesis). Weakness is exactly that: a graded gear bonus authored on the item as an EquipEffects GE, identical to the Salve amulet / Dragon hunter gear the swing path already names (CradlAutoAttackAbilityBase.cpp). Slayer gear can't gate on the target's own Enemy.Family.* (that fires whenever you fight the family, not only when it's your task), so the scope publishes the derived Status.AssignedTarget.MatchingFamily — the integration seam between "slayer knows your assignment" and "item GEs compose in the formula." That stub exists today purely awaiting a publisher (Source/CRADL/Combat/TargetClassificationScope.h is the sibling it mirrors); slayer is it. A dedicated FSlayerEngagementScope (rather than extending FTargetClassificationScope) keeps slayer logic out of the combat-math struct: combat #includes a slayer header at two call sites; slayer does not reach into combat.
Resilience is the opposite kind of thing — a binary eligibility verdict the engine computes, not a value content composes. Modeling it as a tag-gated UGE_DefMult_* (Source/CRADL/Combat/CombatGameplayEffects.h) would (a) only ever produce a soft accuracy nudge, (b) need a gated GE per damage type on every resilient monster, and (c) require the lock tag to live on the enemy ASC — where OngoingTagRequirements and GetDefenseMultiplierForType read — while the lock condition is attacker-side knowledge, a cross-ASC dance for what is one comparison. A direct gate is simpler, OSRS-faithful ("you can't hurt this yet"), and treating slayer as first-class means giving it a real gate rather than an emergent side effect of tag composition.
Implementation surface:
- Files: Source/CRADL/Slayer/SlayerEngagementScope.{h,cpp} (weakness) and Source/CRADL/Slayer/SlayerCombat.{h,cpp} (the resilience gate) — both new.
- FSlayerEngagementScope: constructed { attacker ASC, target } alongside FTargetClassificationScope in UCradlAutoAttackAbilityBase's swing-resolution path (Source/CRADL/Abilities/CradlAutoAttackAbilityBase.cpp) and UCastSpellAbility's cast-resolution path. Reads the attacker's USlayerComponent (assigned family); pushes Status.AssignedTarget.MatchingFamily onto the attacker ASC via AddLooseGameplayTag when the target's ICombatStatsProvider family matches. One tag, one ASC — a genuine mirror of FTargetClassificationScope.
- SlayerCombat::IsAttackPermitted(const AActor* Attacker, const AActor* Target): called at the top of the same two resolution paths, beside the existing ResolveTargetDefense early-out. Reads the attacker's USkillsComponent Skill.Slayer level and the target's RequiredSlayerLevel (slayer code may cast Target to the enemy to read its definition — non-enemy / no-requirement targets return true). On block: post the owner-client "needs higher Slayer level" message and EndSelf(false).
- Status.AssignedTarget.MatchingFamily earns a C++ symbol (the scope references it by name). RequiredSlayerLevel is plain data read by the gate — no tag.
- Authoring: slayer-gear weakness GEs live in item EquipEffects (STAT_PIPELINE.md "Target-Conditional Modifiers"). Resilience needs no authoring beyond setting RequiredSlayerLevel on the enemy definition.
Footguns:
- AddLooseGameplayTag, not the replicated variant. Like FTargetClassificationScope, the weakness tag is authority-side damage-resolution state with no replicated consumer — do not use AddReplicatedLooseGameplayTag (and recall its UE 5.4 caveat per feedback_replicated_loose_tag_ue54: the replicated variant only writes the mirror).
- Push/pop spans exactly one damage resolution. The scope is RAII and per-target; for any future AoE it must live inside the per-target loop body so one target's tags don't leak into another's roll (the STAT_PIPELINE AoE rule).
- Don't model resilience as a tag. It's tempting to symmetrize it with weakness, but 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; there is nothing to publish. (If a graded under-level penalty is ever wanted instead of a hard wall, that's the moment to revisit — see Open Questions #6.)
- 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.
Related: STAT_PIPELINE.md "Target-Conditional Modifiers", Source/CRADL/Combat/TargetClassificationScope.h, Source/CRADL/Combat/CombatGameplayEffects.h, feedback_replicated_loose_tag_ue54, feedback_dont_symmetrize_speculatively.
Authority & Replication
Rule: The roll and all task mutation are server-authoritative; the conversation is cosmetic; the combat engagement tags are loose and unreplicated. The P2P audit:
| State | Owner | Replication | Rationale |
|---|---|---|---|
CurrentAssignment |
USlayerComponent |
COND_OwnerOnly + RepNotify |
Private per-player task; replaced wholesale on each authority roll. |
TasksCompleted |
USlayerComponent |
COND_OwnerOnly + RepNotify |
Private lifetime counter. |
| Slayer XP / level | USkillsComponent (existing) |
existing COND_OwnerOnly + RepNotify |
Unchanged — slayer grants through GrantXP; no new replicated field. |
Assign engagement (Action.Modal.Dialogue) |
UCradlModalAbility (existing) |
loose tag, owner + server peer | Base behavior; lets UAssignSlayerTaskAbility::ActivationRequiredTags gate. |
| Roll outcome | UAssignSlayerTaskAbility → USlayerComponent::AssignTask |
server-authoritative; arrives via CurrentAssignment |
Client predicts activation, never rolls (non-deterministic). |
| Weakness loose tag | FSlayerEngagementScope |
Not replicated | Authority-side, one damage resolution; mirrors FTargetClassificationScope. |
| Resilience gate | SlayerCombat::IsAttackPermitted |
server-side check, no state | Reads live attacker Slayer level vs target RequiredSlayerLevel; writes nothing. |
USlayerMasterDefinition |
content asset / CDO | Not replicated | Immutable shared data. |
AQuestGiverActor.SlayerPool ref |
level actor (bReplicates = false) |
Not replicated | Level-placed content; no server-mutated state. |
No new replicated Actor properties are introduced — the only new replicated state is the two COND_OwnerOnly fields on USlayerComponent, both with paired RepNotifies, both routed through Server_* mutation.
Footguns:
- The roll is the one thing that must never predict. Unlike quest accept/advance (deterministic given server state), the slayer roll is RNG — a predicted client roll would diverge from authority. The ability predicts activation; AssignTask mutates only on authority and the result replicates.
- Listen-server host gets no self-OnRep. Authority broadcasts OnAssignmentChanged directly after the roll (the USkillsComponent::ApplyXPGrant discipline), or the host's own task UI never refreshes.
- Every server-mutated field got a deliberate answer. Per feedback_p2p_replication_audit: CurrentAssignment and TasksCompleted are owner-private replicated; XP rides the existing skills replication; the weakness loose tag is intentionally local and the resilience gate writes nothing. No field is left implicit.
Related: ARCH #1, #15, #18, feedback_p2p_replication_audit, QUEST_SYSTEM.md "Per-Player State".
Validator
Rule: A new UCradlSlayerMasterDefinitionValidator : UEditorValidatorBase lands with USlayerMasterDefinition (per CLAUDE.md "validators in lockstep"), and the existing CradlEnemyDefinitionValidator and CradlDialogueDefinitionValidator are updated in the same change for their new fields.
UCradlSlayerMasterDefinitionValidator enforces:
- AssignableTasks non-empty.
- Each FamilyTag is valid and under Enemy.Family.* (the MatchesTag parent check CradlEnemyDefinitionValidator already does for UEnemyDefinition::FamilyTag).
- Weight ≥ 1; MinCount ≥ 1; MaxCount ≥ MinCount; MinSlayerLevel within [1, registry max level]; CompletionXp ≥ 0.
- At least one entry reachable at level 1 (else a fresh character can never be assigned a task) — warning.
- Best-effort: each FamilyTag matches the FamilyTag of at least one known UEnemyDefinition — warning (best-effort cross-asset scan, like CradlDropTableDefinitionValidator's ItemId resolution; no family collector exists in CradlValidationHelpers today, so this scans UEnemyDefinition assets directly).
CradlEnemyDefinitionValidator (Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h) gains: SlayerXp ≥ 0; RequiredSlayerLevel within [0, registry max level].
CradlDialogueDefinitionValidator (Source/CRADLEditor/Validators/CradlDialogueDefinitionValidator.h) gains: a response may not set both QuestBinding.Role != None and SlayerBinding.Gate != None (error); a SlayerBinding.Gate == Assign response should pair with EffectEventTag = Action.Trigger.Slayer.Assign (warning, mirroring the quest verb-pairing warnings).
Implementation surface:
- File: Source/CRADLEditor/Validators/CradlSlayerMasterDefinitionValidator.{h,cpp} (new), mirroring CradlDropTableDefinitionValidator / CradlQuestDefinitionValidator and reusing CradlValidationHelpers.
- Add USlayerMasterDefinition to the validated-assets list in CLAUDE.md.
Footguns:
- Cross-asset family resolution is best-effort. A master referencing a family with no enemy authored is a warning, not an error — runtime simply never produces a matching kill (the PrerequisiteQuests best-effort precedent).
- Tag-by-name, not C++ symbol, for authored family tags. The validator requests Enemy.Family.* membership via the tag tree, not via a C++ symbol (feedback_gameplay_tag_decl_minimal); only the system verbs/markers below earn symbols.
Related: CLAUDE.md "validators in lockstep", Source/CRADLEditor/Validators/CradlDropTableDefinitionValidator.h, Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h, CradlValidationHelpers.
Tag Taxonomy
All new tags land in Config/DefaultGameplayTags.ini with DevComment per feedback_gameplay_tag_decl_minimal. C++ symbols in Source/CRADL/CradlGameplayTags.h are added only for tags referenced by name in C++.
| Tag | Existing? | Where declared | Where referenced |
|---|---|---|---|
Skill.Slayer |
new | .ini + C++ symbol | USlayerComponent::GrantXP(Skill.Slayer, …); level-gating reads; the USkillDefinition asset's SkillTag |
Action.Trigger.Slayer |
new (parent) | .ini | namespace root for slayer dialogue verbs |
Action.Trigger.Slayer.Assign |
new | .ini + C++ symbol | dispatched by UDialogueWidget for Assign-gate responses; trigger on UAssignSlayerTaskAbility |
Status.AssignedTarget.MatchingFamily |
existing (stub, now published) | .ini + C++ symbol | pushed by FSlayerEngagementScope; gates slayer-gear EquipEffects weakness GEs |
Combat.Event.Kill |
existing | .ini + C++ symbol | reused, unchanged — USlayerComponent adds a second subscriber alongside UQuestComponent |
Enemy.Family.* |
existing | .ini-only | reused, unchanged — assignment family identity + kill filter |
Action.Modal.Dialogue |
existing | .ini + C++ symbol | reused — UAssignSlayerTaskAbility::ActivationRequiredTags |
Config/DefaultGame.ini gains one +PrimaryAssetTypesToScan entry for SlayerMasterDefinition over /Game/Definitions/Slayer. (The Slayer USkillDefinition uses the existing SkillDefinition scan entry.)
Forward Code References
Future PRs land in named, predictable places:
Source/CRADL/Slayer/SlayerComponent.{h,cpp}—USlayerComponent,FSlayerAssignment, theCombat.Event.Killsubscriber, the level-gated roll.Source/CRADL/Slayer/SlayerMasterDefinition.{h,cpp}—USlayerMasterDefinition,FSlayerTaskEntry.Source/CRADL/Slayer/SlayerMasterInterface.h—ISlayerMaster.Source/CRADL/Slayer/SlayerDialogueBinding.h—ESlayerGate,FDialogueSlayerBinding.Source/CRADL/Slayer/SlayerEngagementScope.{h,cpp}—FSlayerEngagementScope(weakness tag; optional-objectives phase).Source/CRADL/Slayer/SlayerCombat.{h,cpp}—SlayerCombat::IsAttackPermittedresilience gate (optional-objectives phase).Source/CRADL/Abilities/AssignSlayerTaskAbility.{h,cpp}—UAssignSlayerTaskAbility.Source/CRADLEditor/Validators/CradlSlayerMasterDefinitionValidator.{h,cpp}— validator.- Modifications to existing files (each a small surface change, not a refactor):
Source/CRADL/Dialogue/DialogueDefinition.h— addFDialogueSlayerBinding SlayerBindingtoFDialogueResponse(includeSlayerDialogueBinding.h).Source/CRADL/UI/DialogueWidget.{h,cpp}— add theISlayerMaster::EvaluateSlayerGatevisibility check alongside the quest-binding check.Source/CRADL/Quests/QuestGiverActor.{h,cpp}— addTObjectPtr<USlayerMasterDefinition> SlayerPool+ implementISlayerMaster.Source/CRADL/Enemy/EnemyDefinition.h— addint64 SlayerXpandint32 RequiredSlayerLevel.Source/CRADL/Player/CradlPlayerState.{h,cpp}— instantiateUSlayerComponent+GetSlayerComponent().Source/CRADL/Abilities/CradlAutoAttackAbilityBase.cppandSource/CRADL/Abilities/CastSpellAbility.cpp— constructFSlayerEngagementScopeand callSlayerCombat::IsAttackPermittedat the existing damage-resolution sites (optional-objectives phase).Source/CRADL/SaveGame/CradlSaveSubsystem.{h,cpp}— persistCurrentAssignment+TasksCompletedviaUSlayerComponent::ApplyPersistedState.Source/CRADL/CradlGameplayTags.{h,cpp}— C++ symbols forSkill.Slayer,Action.Trigger.Slayer.Assign,Status.AssignedTarget.MatchingFamily.Config/DefaultGameplayTags.ini— the new tag declarations with DevComment.Config/DefaultGame.ini—+PrimaryAssetTypesToScanforSlayerMasterDefinition.CLAUDE.md— addUSlayerMasterDefinitionto the validated-assets list.
Open Questions
- No slayer registry (resolved for v1 — direct reference). The master actor holds a
TObjectPtr<USlayerMasterDefinition>; the asset is never looked up by FName. AUSlayerMasterRegistryis omitted (mirrors the dialogue decision). The trigger to reintroduce one is save-data or a quest/UI referencing a master pool by FName — neither exists in v1. DroppingMasterIdfromFSlayerAssignment(it was the lone candidate) keeps this airtight: no replicated or persisted state references a master by FName, so there is nothing to resolve on load. Reportgate / slayer points (deferred). v1 auto-completes on the final kill, soESlayerGate = { None, Assign }. A slayer-points economy, streak rewards, or task-skip/block (all OSRS slayer features) would reintroduce aReportgate + a turn-in-style ability + replicated points state. A return-to-master step is also whereFSlayerAssignment.MasterId(dropped in v1) returns — co-located with the consumer that actually needs to know which master. Deferred perfeedback_dont_symmetrize_speculatively— added with its consumer, not before. (If report-to-complete is preferred over auto-complete, this flips andReportreturns to the gate set.)- Per-kill XP source granularity. v1 reads
UEnemyDefinition::SlayerXp(per-monster). If the same family should yield different XP per master (it does not in OSRS), that would move XP ontoFSlayerTaskEntryinstead. Flagged; current decision is per-monster. - Task variety beyond a single family (deferred). OSRS tasks are always one monster type; v1 matches that (
FamilyTagper assignment). Multi-family tasks ("kill any undead") are already expressible via a higherEnemy.Family.*parent tag and need no new shape, but compound/either-or tasks are out of scope. - Spectator-visible slayer state (deferred).
CurrentAssignmentisCOND_OwnerOnly. If a future co-op UI needs "Player X is on a Goblin task," add a small publicFActivityDescriptor-style field on PlayerState (per ARCH #1), not a replication-condition change on the owner-private field — the same resolution QUEST_SYSTEM.md records. - Resilience semantics (resolved for v1 — hard gate). v1 blocks the attack outright when the attacker is under the requirement, via
SlayerCombat::IsAttackPermittedin the swing/cast path (no tag, no GE) — the OSRS-faithful "you can't hurt this yet." A graded under-level penalty (a soft accuracy/defense reduction that still lets you fight) was considered and rejected for v1: it would mean a per-damage-type gatedUGE_DefMult_*on every resilient monster for a softer outcome, and a cross-ASC tag dance (lock tag on the enemy, condition computed attacker-side). If graded resilience is ever wanted, that is when it returns — as a target-side tag-gated GE, the tag pushed onto the enemy ASC while the condition stays attacker-computed.
Contract drafted at SLAYER_SYSTEM.md. Review and tell me when aligned, or run /design-system Slayer again with iteration feedback. Run /design-system --phase=derive-implementation Slayer to derive the phased implementation doc from it.