0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI ENEMY_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
ENEMY_IMPLEMENTATION.md 13606 words ~62 min read Updated 2026-07-03

CRADL Enemy Implementation

Companion to ENEMY_SYSTEM.md (the contract), COMBAT_SYSTEM.md (the verb-side contract enemies bind to), and ARCHITECTURE.md (the foundation). This doc tracks the build order for v1 enemies: phased delivery, per-phase rationale, task checklists, and verification gates. The contract doc says what enemies are; this doc says what we build first, what depends on what, and how we know each step works.

Enemies are greenfield — no enemy code exists yet. Combat phases 0-9 of COMBAT_IMPLEMENTATION.md are landed and load-bearing for everything here; every forward reference in the enemy contract lands in this build order.

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 UEnemyDefinition, UDropTableDefinition, etc. updates the matching validator under Source/CRADLEditor/Validators/ in the same change.
  • Per CLAUDE.md "no UBT here": Claude does not build. After C++ edits, the user compiles and reports back.
  • No combat-contract redesigns. Enemies reuse the same engagement-loop core, the same UCradlAttributeSet, IPawnCombatant, IMoveIssuer, the same damage GE, the same cue tags, the same Combat.Event.{Hit,Miss,Death} channel — per ENEMY_SYSTEM.md "Combat Reuse". The contract's anti-fork rule applies to the math and the verb, not to the per-actor driver shell: extracting a shared UCradlAutoAttackAbilityBase so player and enemy abilities both run identical swing-cadence / damage / cap / range / cue code is contract-conformant. Two parallel damage rolls would not be. If a phase below appears to need a parallel combat code path inside the loop, that's the test failing.

Phase tracking

Phase Title Status Unblocks
0 Tag & data scaffolding [x] All later phases
1 Enemy pawn + IInteractable (manual placement) [x] 2, 3
2 Enemy Definition, Variants, Stat Tuning [x] 3, 7
3 AI brain v1 — retaliation + chase [x] 4, 5, 6
4 Hostility, perception, combat-level scalar [x] 5
5 Patrol & leashing [x] 6 (parallel-able with 4)
6 Group aggro [x] (closes the perception/engage shape)
7 Drop tables & loot attribution [x] 8
8 Enemy death, ground drops, spawners [x] (closes the kill-loop)

Phase 0 — Tag & Data Scaffolding

Goal. All enemy-side tags declared (under Enemy.* and the Status.EnemyInnateStats addition), GE classes for innate stats and heal-to-full registered as empty implementations, settings additions on UCradlCombatSettings. No behavior yet. This phase produces a compiling, no-op-extended codebase that unblocks every later phase.

Rationale. Phases 1+ all reference these tags / GE classes / settings fields. Landing them in a single low-risk PR avoids cross-phase merge churn and matches the Phase 0 shape from the combat doc.

Tasks.

  • [x] Enemy tags — Config/DefaultGameplayTags.ini (and selectively Source/CRADL/CradlGameplayTags.h per the project's "declare in C++ only when referenced by name" rule):
  • [x] Enemy.Family.* — .ini only at v0; one leaf per authored archetype lands when the archetype does (Enemy.Family.Goblin, etc.). C++ declarations only for families a runtime path names directly (drop-table sub-table lookup is data-driven, so likely none).
  • [x] Enemy.Hostility.* — .ini only (Aggressive, Passive). Runtime source is EEnemyHostility; tag namespace is reserved for future tag-based filtering.
  • [x] Enemy.Rank.* — .ini only (Trash, Elite, Boss). Reserved per ENEMY_SYSTEM.md "Open Questions"; no leaves consumed yet.
  • [x] Status additions:
  • [x] Status.EnemyInnateStats — C++ + .ini. Granted by UGE_Enemy_InnateStats so the GE is locatable by tag for removal / introspection.
  • [x] GE class shells in Source/CRADL/Combat/CombatGameplayEffects.h + .cpp:
  • [x] UGE_Enemy_InnateStats — Infinite duration, modifiers populated at runtime: ApplyDefinition appends one FGameplayModifierInfo (FScalableFloat magnitude) per stat-tuning entry to a transient instance (not SetByCallerMagnitude — see the Phase-2 note). Granted tag Status.EnemyInnateStats via the existing UTargetTagsGameplayEffectComponent named-default-subobject pattern (same as UGE_Combat_InCombat / UGE_Status_Dead).
  • [x] UGE_Enemy_HealToFull — Instant duration, single override modifier writes Health = MaxHealth. Used by BTTask_LeashReset (Phase 5). Lands as an empty class here; the modifier wiring is finalized in Phase 5 when the leash branch consumes it.
  • [x] UCradlCombatSettings extension (Source/CRADL/Combat/CradlCombatSettings.h):
  • [x] float DropOwnershipSeconds = 60.f — how long ground items remain tagger-only post-spawn (Phase 7/8).
  • [x] float EnemyCorpseSeconds = 3.f — corpse-visible window before Pawn->Destroy() (Phase 8).
  • [x] float DefaultEnemyLeashRadiusCm = 1500.f — sentinel for definitions that leave LeashRadiusCm at the default (Phase 5).
  • [x] Forward-reference module / folder scaffold:
  • [x] Create empty Source/CRADL/Enemy/ and Source/CRADL/Loot/ directories (a .gitkeep or initial placeholder header is enough; later phases drop concrete files in).

Verification.

  • Compile clean (user-side).
  • Editor: a fresh DataAsset of UGE_Enemy_InnateStats or UGE_Enemy_HealToFull is visible in the Content Browser (confirms class registration); no behavior is exercised.
  • Editor: Project Settings → CRADL → Combat shows the three new fields with their defaults.

Exits. Phase 1 has GE classes to apply (innate-stats stub stays empty for now) and the settings fields it'll need at death time; phase 2 has Status.EnemyInnateStats to grant.

Footguns.

  • Don't C++-declare every Enemy.Family.* leaf just because an archetype exists. The .ini is the authoritative list; C++ declares only the leaves runtime code names. Per feedback_gameplay_tag_decl_minimal.
  • UGE_Enemy_HealToFull modifier must be Override, not Additive. Settling for additive would treat heal as damage-negative and route through UCradlAttributeSet::PostGameplayEffectExecute's damage branch — wrong cues, wrong XP grants, wrong InCombat refresh. The leash reset is a state reset, not a heal event. Implementation lands in Phase 5; flag here so the empty-class placeholder doesn't quietly ship with the wrong modop.

Phase 1 — Enemy Pawn + IInteractable (Manual Placement)

Goal. AEnemyCharacter exists as an ACharacter with its own ASC + UCradlAttributeSet, implements IInteractable returning Action.Trigger.Combat.Engage as its primary action. Manually placed in the test map. Player can click and auto-attack it identically to ATargetDummy. No definition, no AI, no death drops yet — the pawn is the test fixture for the rest of the build.

Rationale. Splitting the pawn shell from the AI brain and from the data-driven definition lets us validate the combat-contract binding in isolation: does click-to-engage route through IInteractable::DispatchContextAction? Does the auto-attack ability activate on the enemy's ASC and deal damage? Does the multi-attacker cap on the enemy honor MaxAttackers? Same staging philosophy as Phase 5's target-dummy-before-NPC in the combat doc — a static enemy is a target dummy with the player-shaped pawn class.

Pre-work — Combat-side decoupling

Phase 1's verification ("enemy fires Engage → swing lands on player") requires the auto-attack ability to drive an enemy attacker. Before this phase, UAutoAttackAbility was PlayerState-shaped: SnapshotSwingStats hard-cast OwnerActor to ACradlPlayerState and short-circuited on enemies; ResolveTargetDefense carried parallel Cast<ATargetDummy> / Cast<ACradlPlayerState> branches; the multi-attacker cap was wired to ATargetDummy concretely at the activation and swing-fire sites. Phase-1 work that didn't address this would either fail the swing verification or smuggle Cast<AEnemyCharacter> branches into Combat/ (a Combat→Enemy include backwards from the natural Enemy/Combat/ direction).

Two judgment calls land here, with rationale recorded so future PRs don't re-litigate:

Lyra-style merged ability vs split. Lyra abilities are short fire-and-forget — pull-trigger → fire → end. Sharing one ability between bot and player works because the per-actor surface is small. CRADL's auto-attack is a long-running engagement state machine. Player-only branches are substantial: spellbook autocast, style-XP routing, "Equip a weapon" UX toast, QueuedInteract reactivation, ACradlPlayerState::SetActivity, Combat.Style.* ASC tag read. Enemy-only branches grow through Phase 5 (leash) / Phase 6 (group aggro). A merged ability would carry if (Skills) / if (Spellbook) / if (PS) guards forever; a split ability lets each driver own its persona while sharing the cadence/damage/cap/range/cue core. Lyra's opinion is right for Lyra and wrong for CRADL. Decision: share the engagement-loop core via UCradlAutoAttackAbilityBase; subclass per actor. Anti-fork rule (no parallel damage paths) is honored because the math sits on the base, not the subclass.

Interfaces over concrete casts in Combat/. Per CLAUDE.md "interfaces over concrete types when a second implementer arrives": ATargetDummy and AEnemyCharacter both need cap mgmt and combat-stats query. Two interfaces (IAttackerCappedTarget, ICombatStatsProvider) plus one helper (CradlCombat::ResolveCombatContext) is the minimum decoupling that doesn't reintroduce the Cast<ATargetDummy> pattern with an AEnemyCharacter cousin next to it.

Pre-work tasks.

  • [x] IAttackerCappedTarget — new Source/CRADL/Combat/AttackerCappedTargetInterface.h. Three pure virtuals (TryRegisterAttacker, UnregisterAttacker, WouldAcceptAttacker) with the same shape as ATargetDummy's current concrete API.
  • ATargetDummy migrates from concrete methods to interface implementations (signatures unchanged; virtual ... override).
  • UAutoAttackAbility's two Cast<ATargetDummy> cap sites switch to Cast<IAttackerCappedTarget>.
  • AEnemyCharacter (Phase-1 main task below) implements the interface for free.
  • [x] ICombatStatsProvider — new Source/CRADL/Combat/CombatStatsProviderInterface.h. Two pure virtuals:
  • int32 GetCombatSkillLevel(FGameplayTag SkillTag) const — attacker-side weapon-style or defense level; target-side defense level.
  • int32 GetDefenseBonusForType(FGameplayTag DamageType) const — target-side per-Combat.DamageType.* matrix bonus. Universal Defense attribute scalar layers on top at the call site (read from the target's ASC).
  • ATargetDummy implements via existing DummyDefenseLevel + GetDummyDefenseBonus (drops the bespoke names from public API).
  • ACradlPlayerState implements via USkillsComponent::GetLevel + UEquipmentComponent::SumDefenseBonus.
  • AEnemyCharacter (Phase 1) implements via placeholder fields (see main task below); Phase 2 swaps reads to StatTuning-buffed AttributeSet.
  • UAutoAttackAbility::ResolveTargetDefense collapses dummy + PS branches into a single Cast<ICombatStatsProvider> call; the universal Defense scalar still layers on via the target's ASC.
  • UCombatStatsDebugComponent::ResolveTargetDefense (the mirror copy used by the combat-stats overlay) gets the same treatment so the overlay numbers stay aligned with the swing.
  • [x] CradlCombat::ResolveCombatContext — new helper in Source/CRADL/Combat/CradlCombatRange.h sibling to ResolveEngagementRangeForPawn. Returns FCombatActorContext { UEquipmentComponent*, USkillsComponent*, UCradlAttributeSet*, USpellbookComponent* }:
  • AttributeSet: from Info->AbilitySystemComponent->GetSet<UCradlAttributeSet>() — works for both PS-owned and pawn-owned ASCs.
  • PS path: Cast<ACradlPlayerState>(Info->OwnerActor) → reads Equipment/Skills/Spellbook from PS components.
  • Avatar fallback: when OwnerActor isn't a PS, reads Equipment from Info->AvatarActor->FindComponentByClass<UEquipmentComponent>(). Skills/Spellbook stay null (enemies don't carry them in v1).
  • CradlCombat::ResolveEngagementRangeForPawn gets the same PS-first / avatar-fallback shape so a Phase-3 enemy retaliating finds its own MainHand range, and a player-clicking-an-enemy still resolves the player's weapon range correctly.
  • [x] Split UAutoAttackAbility — extract shared core.
  • UCradlAutoAttackAbilityBase (Source/CRADL/Abilities/CradlAutoAttackAbilityBase.h + .cpp). Owns the engagement-loop core: ActivationOwnedTags = Action.Combat.AutoAttack, CancelOnTagsAdded = { Status.Stunned, Status.Dead, Action.Modal }, the per-encounter FRandomStream, TargetActor, CurrentSwingStats, bRegisteredOnTarget, the swing loop (StartNextSwing / HandleSwingFinished / chase poll), IsTargetValid / IsTargetInRange, cap registration via IAttackerCappedTarget, accuracy roll via CradlCombatMath, damage GE application via UGE_Combat_Damage, Combat.Event.{Hit,Miss} publication, hit cue dispatch via Cradl::Cues::ResolveAttachComponent. Anti-fork: damage roll lives here exactly once.
  • Virtual hooks on the base (driver-specific decisions):
    • virtual bool ResolveSwingContext(FCombatActorContext&) const — calls CradlCombat::ResolveCombatContext by default; subclasses override only if they need bespoke lookup.
    • virtual int32 ResolveAttackerLevel(FGameplayTag StyleSkill) const — player reads PS->Skills->GetLevel(StyleSkill); enemy reads ICombatStatsProvider::GetCombatSkillLevel(StyleSkill) from the avatar.
    • virtual void OnAfterSnapshotFail() — player toasts "Equip a weapon to attack."; enemy is silent.
    • virtual void OnEngagementBegan(AActor* Target, FGameplayTag Channel) — player updates PS-side UCombatTargetingComponent + SetActivity; enemy updates pawn-side UCombatTargetingComponent (no Activity descriptor).
    • virtual void OnEngagementEnded() — symmetric clear.
    • virtual void GrantStyleXp(int32 DamageDealt, const FAutoAttackSwingStats&) — player implementation routes by Combat.Style.* tag (existing logic moves verbatim); enemy is no-op.
    • virtual AActor* GetDamageInstigator() const — player returns PS (so UCradlAttributeSet::PostGameplayEffectExecute's HP-XP grant casts InstigatorActor correctly); enemy returns the pawn.
    • virtual bool ResolveAutocast(const FCombatActorContext&, FAutoAttackSwingStats&) const — player reads Spellbook->GetAutocastSpell() and writes the autocast snapshot fields; enemy returns false (no autocast in v1).
    • virtual bool ConsumeAmmoForSwing(const FAutoAttackSwingStats&) / ConsumeRunesForSwing(...) — player decrements bag; enemy treats both as infinite (returns true unconditionally).
  • UAutoAttackAbility stays as the player-side concrete subclass. All player-only logic moves into the virtual overrides; the bulk of the file shrinks. ActivationBlocked/Required tags + per-player cancel hooks (Action.QueuedInteract interaction) stay here.
  • UEnemyAutoAttackAbility — new Source/CRADL/Enemy/EnemyAutoAttackAbility.h + .cpp. Phase 1: attacker level reads route through ICombatStatsProvider on the avatar (placeholder fields), no autocast, no XP grant, pawn-side targeting component update. Phase 5/6 extend with leash-break disengage + group-aggro broadcast hooks.
  • The contract update lands in the same PR: ENEMY_SYSTEM.md "Combat Reuse" revises from "same UAutoAttackAbility class" to "same UCradlAutoAttackAbilityBase core; per-actor concrete drivers", with the anti-fork rule re-anchored on the math/verb (not the class identity). ENEMY_SYSTEM.md "Forward Code References" gains UCradlAutoAttackAbilityBase and UEnemyAutoAttackAbility. The "Net Execution Policy" section's "Shared abilities reused as-is" footgun gets reworded to "engagement-loop core is shared; subclasses inherit LocalPredicted and degrade to server-only on enemies."

Pre-work verification.

  • Compile clean.
  • Existing player auto-attack vs ATargetDummy works unchanged: walk-up + swing + damage + multi-attacker cap + autocast + style XP + "Equip a weapon" toast all behave as before.
  • Debug_RollSim numbers unchanged (the math path moved files but didn't change inputs/outputs).
  • Debug_ToggleCombatStats overlay renders the same numbers against a dummy target.
  • Debug_EngageNearest (player→dummy) still works end-to-end.

Pre-work footguns.

  • UCradlAutoAttackAbilityBase is abstract by convention, not enforced. Don't grant it directly — it has no concrete attacker-level resolution. Add an ensureMsgf(false, ...) in the base's ResolveAttackerLevel default impl so a stray grant fails loudly in PIE.
  • Driver-specific cancel/block tags stay on subclasses. The base names only the universal cancel set (Status.Stunned, Status.Dead, Action.Modal). Player-only cancellation by Action.QueuedInteract (or future enemy-only modal blocks) lives on the subclass — sharing those would couple every driver to player-shaped concerns.
  • Don't fold the cap into ICombatStatsProvider. Cap state is server-authoritative + replicated; combat-stats reads are pure / const. Keeping them on separate interfaces avoids const-correctness fights and accidental authority writes from a stats-read site.
  • ResolveCombatContext's avatar fallback only finds Equipment. Skills/Spellbook stay null for non-player owners. Subclasses MUST tolerate the null (the player path's null-Skills branch is dead code today; the enemy path is where it actually fires). Don't add a "default zero level" fallback inside ResolveAttackerLevel — surface the null via ensure in development so a future enemy attempting to read style XP without overriding gets caught.
  • Don't migrate the player's Cast<ACradlPlayerState> PvP-stub branch in ResolveTargetDefense until PvP is real. The interface-based path subsumes dummy + enemy; the PS branch is dead code that documents the PvP intent. Removing it now would lose the design hint; deleting it later when PvP lands replaces it with the real Cast<ICombatStatsProvider> resolution against ACradlCharacter.

Tasks.

  • [x] AEnemyCharacter — new files Source/CRADL/Enemy/EnemyCharacter.h + .cpp:
  • [x] ACharacter subclass; bReplicates = true, bReplicateMovement = true.
  • [x] Owns TObjectPtr<UCradlAbilitySystemComponent> AbilitySystem constructed in ctor; calls InitAbilityActorInfo(this, this) server-side in BeginPlay. No OnRep_PlayerState analog (enemies have no PlayerState); the pawn is its own owner + avatar. Mirrors ATargetDummy::BeginPlay discipline (authority-only init).
  • [x] Owns TObjectPtr<UCradlAttributeSet> AttributeSet — same class the player uses, verbatim.
  • [x] Implements: IAbilitySystemInterface (returns AbilitySystem), IPawnCombatant, IInteractable, ICueAnchorProvider, IAttackerCappedTarget, ICombatStatsProvider.
  • [x] Components constructed in ctor: UChaseComponent (verbatim reuse from Source/CRADL/Combat/ChaseComponent.h), UEquipmentComponent (slot list provided by ApplyDefinition in Phase 2 — Phase 1 grants a single MainHand slot in the ctor so a hand-placed enemy can carry a weapon and so the unarmed-fallback path through UCradlCombatSettings::UnarmedDefaultWeaponItemId resolves cleanly), UCombatTargetingComponent (existing class, hosted on the pawn instead of PlayerState — per ENEMY_SYSTEM.md "Combat Reuse").
  • [x] Initial MaxHealth and Health set in BeginPlay from a temporary EditDefaultsOnly int32 PlaceholderMaxHealth = 30 field on the pawn class itself — Phase 2 replaces this with ApplyDefinition-driven values. Comment-document as // PHASE 2: remove.
  • [x] ICombatStatsProvider placeholder fields (sibling stopgaps to PlaceholderMaxHealth, all // PHASE 2: remove):
    • EditAnywhere int32 PlaceholderAttackLevel = 1 — returned from GetCombatSkillLevel for Skill.Combat.{Melee,Ranged,Magic}.
    • EditAnywhere int32 PlaceholderDefenseLevel = 1 — returned from GetCombatSkillLevel for Skill.Combat.Defense.
    • EditAnywhere TMap<FGameplayTag, int32> PlaceholderDefenseBonusByType (meta=(Categories="Combat.DamageType")) — returned from GetDefenseBonusForType (missing entries resolve to 0, mirroring ATargetDummy::GetDummyDefenseBonus).
  • [x] IInteractable::GetPrimaryActionTag() returns Action.Trigger.Combat.Engage.
  • [x] IInteractable::CanInteract(invoker) mirrors ATargetDummy::CanInteract — alive gate via AttributeSet->GetHealth() > 0, engagement range via CradlCombat::ResolveEngagementRangeForPawn(InteractingPawn), WouldAcceptAttacker peek through the new interface. Same InteractCheck.Reason.* rejection map.
  • [x] IInteractable::GetInteractLabel() = "Attack"; IInteractable::GetSourceLabel() returns a placeholder "Enemy" (Phase 2 routes this through UEnemyDefinition::DisplayName / active variant).
  • [x] IInteractable::GatherActions overrides the base impl to set SourceActor = const_cast<AEnemyCharacter*>(this) — same workaround ATargetDummy::GatherActions uses. Base-class fix is deferred (out of Phase 1 scope).
  • [x] IPawnCombatant impl: delegates to UChaseComponent exactly as ACradlCharacter does (see IPawnCombatant impl in Source/CRADL/Player/CradlCharacter.h).
  • [x] IAttackerCappedTarget implTSet<TWeakObjectPtr<AActor>> CurrentAttackers (server-only), Replicated int32 CurrentAttackerCount, idempotent TryRegisterAttacker / UnregisterAttacker / WouldAcceptAttacker. Cap defaults to UCradlCombatSettings::DefaultMaxAttackers; Phase 2 lets UEnemyDefinition::MaxAttackers override per-instance. Eviction fires from UCradlAutoAttackAbilityBase::EndAbility (now interface-shaped, so no extra code path).
  • [x] Grant UEnemyAutoAttackAbility to the ASC server-side in BeginPlay so the enemy could swing — but Phase 1 has no AI to trigger it. Verification below tests this surface via the cheat that fires Action.Trigger.Combat.Engage on the enemy's ASC.
  • [x] ICueAnchorProvider::GetCueAttachComponent_Implementation returns a CueAnchor scene component (head-height above the capsule top, same shape as ATargetDummy::CueAnchor).
  • [x] IPawnCombatant re-affirmation on the enemy pawn. Per ENEMY_SYSTEM.md "Pawn Shape", the existing chase primitive composes cleanly with the enemy's component. Confirm at this phase: UCradlAutoAttackAbilityBase::BeginOrContinueChase resolves IPawnCombatant through GetAvatarActor(); on an enemy pawn that's AEnemyCharacter directly, the interface dispatch lands.
  • [x] Movement-mode policy carry-over. UMovementModePolicyComponent (facing-drive owner per Phase 6.5B of combat) lives on ACradlCharacter today. v1 enemy doesn't need facing-drive yet (Phase 3 introduces it via the BT chase branch); document the deferral inside the pawn header.
  • [x] Cheat commandACradlPlayerController::Debug_EnemyEngagePlayer():
  • [x] Server RPC walks TActorIterator<AEnemyCharacter> for the nearest one, fires Action.Trigger.Combat.Engage on its ASC with Target = local player pawn. Authority lives on the enemy's ASC; the cheat is the AI substitute for Phase 3. Declared UFUNCTION(Exec) unconditionally; body wrapped in #if !UE_BUILD_SHIPPING.

Verification.

Drop a single placed AEnemyCharacter (or BP child) into the test map.

  • Equip a melee weapon. Click the enemy → walk to engagement range → auto-attack starts. Enemy HP drops on hit; "Hit: N Slash HP X -> Y" debug overlay fires (Phase 1 combat cue path); Status.InCombat flips on both sides. Same swing math as engaging a dummy (the math sits on the shared base).
  • Enemy HP reaches 0 → ability ends with target-dead exit; no death pipeline runs yet — the pawn stays at 0 HP with no respawn (Phase 8 owns death). Manually destroy via console or relaunch PIE for the next test.
  • Spawn additional player pawns (existing P2P harness) clicking the same enemy → 4th gets "That target is overwhelmed." rejection at click-time, same as the dummy. Cap path runs through IAttackerCappedTarget end-to-end.
  • Debug_EnemyEngagePlayer → enemy fires Action.Trigger.Combat.Engage against the player → UEnemyAutoAttackAbility activates on the enemy's ASC → swing fires, damage applies to the player; the enemy stays in place (no AI yet) so the swing only lands while the player is in range. Confirms enemy attacker context (Equipment/AttrSet/level) all resolve through the new helper + interface chain.
  • Run the combat-stats debug overlay while engaging the enemy → numbers populate identically to the dummy case (overlay reads through ICombatStatsProvider, which both targets implement).

Exits. Phase 2 has a pawn class to pump values into; Phase 3 has a target for AI possession + IInteractable + IPawnCombatant surfaces an AI controller can read.

Footguns.

  • ASC owner & avatar are both the pawn for enemies. Player code branches on OwnerActor != AvatarActor to route through the APlayerState-bound HP-XP grant. On enemies, both equal the pawn. The pre-work refactor centralizes this: CradlCombat::ResolveCombatContext is the single site that disambiguates. Any new call site assuming "owner is always PlayerState" silently breaks — route through the helper instead. Per ENEMY_SYSTEM.md "Pawn Shape" footgun.
  • Don't reach for APlayerState from UCradlAttributeSet::PostGameplayEffectExecute when the victim is an enemy. The HP-XP grant path already short-circuits when attacker == victim; for player→enemy damage, the attacker's PlayerState lookup still works because the attacker is the player. Confirm this branch end-to-end in verification — a damage event with both endpoints as enemies (a future enemy-vs-enemy case) is not a Phase 1 concern.
  • UCombatTargetingComponent replication condition. Per ENEMY_SYSTEM.md "Combat Reuse", the player-side replicates COND_OwnerOnly (no spectator visibility cost); the enemy-side replicates to all peers because spectators need to know who the enemy is attacking. v1 ships the cheap form on the enemy too (the data is small) and adds a bReplicateToAllPeers flag only if a HUD consumer surfaces real friction.
  • Placeholder fields are Phase-2-bound stopgaps. Don't add validator coverage of PlaceholderMaxHealth / PlaceholderAttackLevel / PlaceholderDefenseLevel / PlaceholderDefenseBonusByType — all four get ripped out when ApplyDefinition + StatTuning land. Mark each with // PHASE 2: remove so the next PR has clear search targets.
  • Don't grant UDeathAbility to the enemy. That's the player's death class — drops to cold storage, respawns the player. The enemy gets UEnemyDeathAbility in Phase 8; Phase 1 leaves death un-handled deliberately, with the pawn stuck at 0 HP visible to verify the trigger fires.
  • Don't grant UCradlAutoAttackAbilityBase directly. It's the shared core — abstract by convention. Always grant UAutoAttackAbility (player) or UEnemyAutoAttackAbility (enemy). The ensureMsgf in the base's default ResolveAttackerLevel catches stray grants in PIE.

Phase 2 — Enemy Definition, Variants, Stat Tuning

Goal. UEnemyDefinition, FWeightedEnemyVariant, FEnemyStatTuning, the lazy UEnemyRegistry, and the editor validator land. AEnemyCharacter::ApplyDefinition(const UEnemyDefinition*, int32 VariantSeed) server-side pumps the definition into a freshly spawned pawn: stamps the rolled variant's mesh + weapon, applies the rolled stat-tuning GE, grants GrantedAbilities. No spawners yet — the manual-placement enemy reads its definition from an editor field. Phase 1's PlaceholderMaxHealth field gets removed.

Rationale. Definition + variants + stat tuning are the data spine. Landing them together (vs. one-per-phase) keeps the validator change in a single editor PR and matches how ULoadoutDefinition / USkillDefinition / URecipeDefinition already shape their authoring surface (one DataAsset, one validator, one apply-at-runtime helper, one registry). Splitting variants off from definition would force two passes through ApplyDefinition's shape and re-run the validator twice.

Tasks.

  • [x] UEnemyDefinition — new Source/CRADL/Enemy/EnemyDefinition.h. UPrimaryDataAsset. Authoring fields per ENEMY_SYSTEM.md "Enemy Definition":
  • [x] FText DisplayName, TSoftObjectPtr<UTexture2D> Icon, FGameplayTag FamilyTag.
  • [x] EEnemyHostility Hostility (Aggressive / Passive), float AggroRadiusCm, int32 LevelGate, bool bRequireLOS = truefields land here, behavior lands in Phase 4.
  • [x] int32 MaxAttackers — overrides per-pawn cap. Default value reads from UCradlCombatSettings::DefaultMaxAttackers (sentinel 0 = use settings default).
  • [x] EEnemyPatrolMode PatrolMode, float PatrolRadiusCmfields land here, behavior lands in Phase 5.
  • [x] float LeashRadiusCm = 1500.ffield lands here, behavior lands in Phase 5. (No per-definition heal-tolerance field — arrival tolerance lives on BTTask_LeashReset::ArrivalToleranceCm, see Phase 5.)
  • [x] float GroupAggroRadiusCm = 0.ffield lands here, behavior lands in Phase 6.
  • [x] int32 MeleeLevel, RangedLevel, MagicLevel, DefenseLevel — authored per-archetype combat skill levels (per [ENEMY_SYSTEM.md "Stat Tuning"] "no scaling": level is identity, not variance). Read through ICombatStatsProvider.
  • [x] TArray<FWeightedEnemyVariant> Variants, TArray<FEnemyStatTuning> StatTuning.
  • [x] TSoftObjectPtr<UDropTableDefinition> DropTablefield lands here, behavior lands in Phase 7-8. Stub UDropTableDefinition lives at Source/CRADL/Loot/DropTableDefinition.h so the type-correct field can be authored before the schema fills in.
  • [x] TArray<TSubclassOf<UCradlGameplayAbility>> GrantedAbilities — empty defaults to UEnemyAutoAttackAbility via AEnemyCharacter::DefaultGrantedAbilities.
  • [x] TSoftClassPtr<UBehaviorTree> BehaviorTreefield lands here, brain lands in Phase 3.
  • [x] GetPrimaryAssetId() returns FPrimaryAssetId("EnemyDefinition", GetFName()).
  • [x] FWeightedEnemyVariant — inline struct in the same header per ENEMY_SYSTEM.md "Variants":
  • [x] FText DisplayName, int32 Weight = 1, FName WeaponItemId, FName AmmoItemId, TSoftObjectPtr<USkeletalMesh> Mesh, TSoftClassPtr<UAnimInstance> AnimClass, TSubclassOf<UGameplayEffect> VariantStatsEffect. (Mesh corrected from TSoftClassPtr<USkeletalMesh> in the contract — meshes are object assets, not classes.)
  • [x] Inline-struct decision rationale comment referencing ENEMY_SYSTEM.md "Variants" Why — three-to-five fields don't earn a sibling registry; promote later if scope grows.
  • [x] FEnemyStatTuning — inline struct per ENEMY_SYSTEM.md "Stat Tuning":
  • [x] FGameplayAttribute Attribute, int32 Min, int32 Max, EGameplayModOp Op (default Additive).
  • [x] UEnemyRegistry — lazy UGameInstanceSubsystem at Source/CRADL/Enemy/EnemyRegistry.h + .cpp. Same shape as USpellRegistry/USkillRegistry/UCradlRecipeRegistry. EnsureBuilt() on first use, GetDefinitionByName(FName), GetAllDefinitions(TArray<...>&). Per ARCH #14 (lazy, not eager).
  • [x] AEnemyCharacter::ApplyDefinition(const UEnemyDefinition*, int32 VariantSeed) — server-only entry point. Sequence: 1. [x] Cache ActiveDefinition (replicated, no rep notify — read-only after spawn). Idempotent guard: re-applying the same definition is a no-op. 2. [x] Roll variant via deterministic weighted pick over Variants; cache ActiveVariantIndex (replicated int8, rep notify OnRep_ActiveVariantIndex). -1 sentinel means "no variant rolled." 3. [x] Cosmetic apply (server triggers; clients run via rep notify): async-load the variant's Mesh / AnimClass via FStreamableManager::RequestAsyncLoad; on completion, swap on ACharacter::Mesh. Weapon attachment lives in the equipped row's authoring (out of scope for Phase 2). 4. [x] Equipment stamp: Reshape the pawn's UEquipmentComponent to MainHand (+ Ammo when the variant carries AmmoItemId), ApplySnapshot with the variant's items. Ammo treated as infinite (count = 9999) — server has no per-shot consume for enemies. 5. [x] Stat-tuning roll: for each FEnemyStatTuning, roll a single value in [Min, Max] (server-side FRandomStream seeded from (GetUniqueID() ^ world time-ms) at v1 — reproducibility is an [open question]). Build a transient UGameplayEffect of class UGE_Enemy_InnateStats per spawn, program one modifier per tuning entry, apply once. Honors EGameplayModOp::{Additive,Multiplicitive} cleanly; the doc's "SetByCallerMagnitude" wording was an imprecise sketch — the transient-instance shape is what GAS supports for "per-spawn rolled modifier list." 6. [x] Variant stats GE: if Variant.VariantStatsEffect is set, apply that as a separate Infinite spec — composes with the tuning GE through standard GAS aggregation. 7. [x] Grant abilities: every entry in Definition->GrantedAbilitiesASC->GiveAbility(...); falls back to DefaultGrantedAbilities (UEnemyAutoAttackAbility) if the definition list is empty. 8. [x] MaxAttackers wiring: caches Definition->MaxAttackers into the per-pawn MaxAttackers field (0 = settings default). 9. [x] Replication of ActiveDefinition + ActiveVariantIndex is load-bearing for cosmetics; remote clients re-run the cosmetic apply in OnRep_ActiveVariantIndex.
  • [x] Editor field on pawn: TSoftObjectPtr<UEnemyDefinition> EditorDefinitionEditAnywhere field on AEnemyCharacter. On BeginPlay (server), if set, calls ApplyDefinition(EditorDefinition.LoadSynchronous(), 0); otherwise falls through to ApplyManualFallback (Phase-1-style HP=30 + DefaultGrantedAbilities for cheat-driven testing). Phase 8's spawner will call ApplyDefinition from its deferred-spawn finalizer instead.
  • [x] Remove Phase-1 PlaceholderMaxHealth / PlaceholderAttackLevel / PlaceholderDefenseLevel / PlaceholderDefenseBonusByType. MaxHealth base = HitpointsLevel × UCradlCombatSettings::EnemyMaxHealthPerHitpointsLevel (default 1, OSRS-faithful; enemy analog of the player's ×4), written in ApplyDefinition, with StatTuning / GE application layering ± variance on top; combat skill levels read from the definition's authored fields; defense matrix reads from UEquipmentComponent::SumDefenseBonus. (Superseded: originally "stat tuning drives MaxHealth" + a validator rule requiring a MaxHealth StatTuning entry — the base now comes from HitpointsLevel, so that entry is optional ± variance and the validator no longer requires it.)
  • [x] IInteractable::GetSourceLabel — routes through ActiveDefinition->DisplayName, with Variants[ActiveVariantIndex].DisplayName as override when non-empty. Generic "Enemy" fallback only when no definition is wired.
  • [x] UCradlEnemyDefinitionValidatorSource/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h + .cpp, inheriting UEditorValidatorBase:
  • [x] DisplayName non-empty.
  • [x] FamilyTag under Enemy.Family.* (MatchesTag parent check).
  • [x] At least one entry in Variants; every variant's WeaponItemId resolves through CradlValidationHelpers::CollectKnownItemIds; AmmoItemId resolves if non-None; Mesh / AnimClass soft refs resolve.
  • [x] Every FEnemyStatTuning::Attribute resolves on UCradlAttributeSet; Min <= Max; Op == Additive || Op == Multiplicative.
  • [x] ~~At least one StatTuning entry targets UCradlAttributeSet::GetMaxHealthAttribute().~~ (Superseded: rule removed — base MaxHealth now derives from HitpointsLevel × EnemyMaxHealthPerHitpointsLevel (default 1), so a MaxHealth tuning entry is optional ± variance.)
  • [x] DropTable soft-ref resolves (when set; null is allowed pre-Phase 7).
  • [x] BehaviorTree soft-class resolves (when set).
  • [x] Combat skill levels >= 1.
  • [x] Pre-req fix in UEquipmentComponent::GetOwnerASC: route ASC lookup through IAbilitySystemInterface on the owner rather than GetOwner<ACradlPlayerState>(). Equip GEs (Bucket-1 row scalars, defense matrix wiring) now apply uniformly to pawn-owned enemy equipment. Per CLAUDE.md "interfaces over concrete types when a second implementer arrives" — this was a Phase-1 oversight that becomes load-bearing in Phase 2 (the first time an enemy actually has a weapon equipped).
  • [x] Register EnemyDefinition + DropTableDefinition in Config/DefaultGame.ini's PrimaryAssetTypesToScan. Both point at /Game/Definitions/Enemies and /Game/Definitions/DropTables directories respectively.
  • [x] Cheat commandACradlPlayerController::Debug_RollEnemyStats(FName DefAssetName):
  • [x] Resolves the definition via UEnemyRegistry; runs 1000 stat-tuning rolls; prints observed Min/Max/Mean per attribute against the authored band. Mirror of Debug_RollSim from combat Phase 3.

Replication-correctness fixes landed in Phase 2 (audit-driven; recorded here so future phases don't re-introduce the same patterns): - [x] ActiveDefinition + ActiveVariantIndex paired rep notify. Two replicated UPROPERTYs whose arrival order on a remote client is unspecified. Both have OnRep_* callbacks; both guard on ActiveDefinition != nullptr && ActiveVariantIndex >= 0 so the cosmetic apply fires exactly once on whichever rep lands second. Without this, the index-arrives-first ordering left remote clients stuck on the default mesh. - [x] MaxAttackers is read from ActiveDefinition, not cached locally. Previous draft cached Definition->MaxAttackers into a non-replicated int32 MaxAttackers field on the pawn, but that diverged on every remote client (cap=settings-default instead of the per-definition override). ResolveAttackerCap() now reads ActiveDefinition->MaxAttackers directly so the click-time WouldAcceptAttacker peek agrees with the server. - [x] UEnemyEquipmentComponent subclass for the pawn-owned equipment. The default COND_OwnerOnly on the inherited Slots FastArray is correct for player-owned equipment (only the owning client needs visibility) but silently means "to nobody" on a pawn-owned equipment component — enemies have no owning client connection, so client-side reads of GetDefenseBonusForType (combat-stats debug overlay, future enemy HUD) see empty inventory and silently return 0. Combat damage stays correct because it's server-authoritative, but observable numbers diverge. A first-pass "per-instance flag in GetLifetimeReplicatedProps" approach does not work in UE 5.4: FRepLayout is built once per UClass from UNetDriver::RepLayoutMap, querying the class CDO's GetLifetimeReplicatedProps exactly once; per-instance variation of a flag set in a constructor is never re-read at send time. The fix is per-class: UEnemyEquipmentComponent (subclass of UEquipmentComponent) overrides GetLifetimeReplicatedProps and uses RESET_REPLIFETIME_CONDITION(UInventoryComponent, Slots, COND_None) to flip the inherited condition. AEnemyCharacter CreateDefaultSubobjects the subclass. - [x] TObjectPtr<UEnemyDefinition> for the replicated field, not TObjectPtr<const UEnemyDefinition>. UHT / FRepLayout handle non-const TObjectPtr cleanly; const-pointed types are unreliable in UPROPERTY contexts. The immutability discipline lives in GetActiveDefinition()'s const return type; the one assignment site uses a const_cast against the registry's const UEnemyDefinition* return.

Verification.

Author a single placeholder definition, e.g. DA_Enemy_Goblin_TEST with: - DisplayName = "Goblin", FamilyTag = Enemy.Family.Goblin. - Variants = [{ WeaponItemId = "weapon.bronze_dagger", Weight = 2 }, { WeaponItemId = "weapon.bronze_sword", Weight = 1 }]. - HitpointsLevel = 25; StatTuning = [{ MaxHealth, [0, 10] }, { StrengthBonus, [2, 4] }] (HP rolls 25–35 — the level-25 base plus the MaxHealth variance; Strength rolls 2–4).

Place an AEnemyCharacter in the test map; set EditorDefinition = DA_Enemy_Goblin_TEST. Run PIE.

  • BeginPlay server-side → ApplyDefinition runs → on first peer, ActiveVariantIndex replicates → cosmetic apply on every peer (mesh swap, weapon attachment). Confirm via two-peer PIE: both peers see the same weapon mesh.
  • Engage the goblin → swing damage reads its rolled StrengthBonus; multiple PIE sessions show different rolls within [2, 4]. Engagement cap honors Definition->MaxAttackers (if set) over the settings default.
  • Debug_RollEnemyStats DA_Enemy_Goblin_TEST → 1000-sample roll histogram lands within the authored band.
  • Validator failure: change Variants[0].WeaponItemId to a missing row, save → validator rejects with the offending row name. Restore. (The former "remove the MaxHealth tuning entry → validator rejects" check no longer applies — MaxHealth tuning is optional now that the base derives from HitpointsLevel.)
  • Reload the same enemy after PIE restart → fresh rolls (reproducibility is an open question; v1 ships per-PIE-restart re-rolls).

Exits. Phase 3 has a definition to read for BehaviorTree / Hostility / AggroRadiusCm from. Phase 7-8 have DropTable resolved at definition-load time.

Footguns.

  • The CDO is empty. Don't push definition values into pawn UPROPERTY defaults via PostInitProperties or constructor hacks. The CDO is shared across every spawn; mutating it bleeds across instances. Per ENEMY_SYSTEM.md "Pawn Shape" footgun.
  • ApplyDefinition runs server-only before the BT starts. Phase 3's controller OnPossess reads the blackboard from the already-populated pawn — but the order must be SpawnDeferred → ApplyDefinition → FinishSpawning → AIController::OnPossess (which fires inside FinishSpawning). For manual editor placement at Phase 2, BeginPlay is fine since no controller possesses yet; Phase 3 wires the deferred-spawn shape into the spawner (Phase 8) and the editor-placement path runs ApplyDefinition from PossessedBy instead to guarantee ordering.
  • Variant roll must be server-only. Two peers rolling independently produces a Goblin holding a spear on one machine and a sword on the other. Server rolls, server replicates ActiveVariantIndex, clients apply via rep notify. Per ENEMY_SYSTEM.md "Variants" footgun.
  • Stat tuning bakes into a GE, not into the BaseValue. Writing to AttributeBaseValue bypasses GAS aggregation; future GEs (poison, rage buffs) would compose against a pre-modified base and feel wrong. Bake the roll into the GE's modifier magnitude (an FScalableFloat on a runtime-appended FGameplayModifierInfo), never the attribute itself. Per ENEMY_SYSTEM.md "Stat Tuning" footgun.
  • Variant weight is not a probability. Authors should write "twice as common" as weight 2 against 1, not 0.66 / 0.33. Fractional weights compile but readability collapses.
  • StatTuning and Variant.VariantStatsEffect compose, they don't replace. Both apply on spawn; both layer through standard GAS aggregation. A variant that wants to override a tuning value does so via additive/multiplicative modifier on the same attribute — there is no "remove tuning entry X" surface.

Phase 3 — AI Brain v1 — Retaliation + Chase

Goal. AEnemyAIController exists with IMoveIssuer impl. A simple Behavior Tree drives the retaliation branch only: enemy receives Combat.Event.Hit, writes the instigator to BlackboardKey:ActiveTarget, fires Action.Trigger.Combat.Engage against its own ASC, runs UAutoAttackAbility against the target, uses IPawnCombatant::BeginChase to close distance through Phase 6.5B's chase primitive. No auto-aggro perception yet — that's Phase 4. The enemy sits passively until hit, then fights back.

Rationale. Retaliation is the smallest provable BT shape — perception is additive on top of it (Phase 4 only adds an upstream branch that writes the same ActiveTarget blackboard key). Landing retaliation first proves: (1) the brain/body split actually works (BT fires the engage event, GAS commits the swing); (2) IMoveIssuer on AEnemyAIController makes chase land identically to the player; (3) the disengage / target-dead teardown shape works end-to-end. Phase 4 then plugs perception into the same engage-dispatch task.

Tasks.

  • [x] AEnemyAIController — new Source/CRADL/Enemy/EnemyAIController.h + .cpp. AAIController subclass, implements IMoveIssuer:
  • [x] IMoveIssuer::IssueMoveTo(FVector)UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, Loc). (Note: the IssueMoveTo(AActor*) overload doesn't exist on the interface — the doc was imprecise; chase uses the FVector overload via UChaseComponent.)
  • [x] IMoveIssuer::StopMove()AAIController::StopMovement().
  • [x] UBehaviorTreeComponent + UBlackboardComponent constructed in ctor.
  • [x] BT-start is pushed from the pawn's FinalizeBrainSetup after ApplyDefinition populates state — OnPossess left as a no-op to avoid the race where possession fires before the definition lands.
  • [x] AEnemyCharacter::AIControllerClass = AEnemyAIController in ctor. AutoPossessAI = PlacedInWorldOrSpawned so manual-placement and spawner-spawn both auto-possess.
  • [ ] Blackboard asset at Content/AI/BB_Enemy_Default.uasset. Keys: ActiveTarget (Object → AActor), Anchor (Vector — Phase 5), Definition (Object → UEnemyDefinition), bAggroBroadcastReceived (Bool — Phase 6). (Editor-authored; not a C++ deliverable.)
  • [ ] Behavior Tree asset at Content/AI/BT_Enemy_Default.uasset. (Editor-authored.) Phase 3 shape:
  • Root → Selector → [Retaliation Sequence (Decorator: Blackboard ActiveTarget Is Set) → Fire Combat.EngageWait For Engagement End (clears ActiveTarget on natural end)] || [Idle Wait 1.0s].
  • [x] BTTask_FireCombatEngage — new Source/CRADL/Enemy/BT/BTTask_FireCombatEngage.h + .cpp. Builds an FGameplayEventData with EventTag = Action.Trigger.Combat.Engage, Target = ActiveTarget (BB read), Instigator = ControlledPawn, and calls ASC->HandleGameplayEvent. Returns Succeeded if any ability triggered, Failed otherwise.
  • [x] BTTask_WaitForEngagementEnd — new Source/CRADL/Enemy/BT/BTTask_WaitForEngagementEnd.h + .cpp. Latent; subscribes to Action.Combat.AutoAttack tag-change on the pawn's ASC; succeeds when count → 0. Clears configured BB key (default ActiveTarget) on natural end to break the dead-target re-fire loop.
  • [x] Retaliation event hookupAEnemyCharacter::BeginPlay (server) binds to its ASC's Combat.Event.Hurt event (new victim-side cousin of Combat.Event.Hit; the doc said "Hit" but Hit fires on the attacker's ASC — see UCradlAutoAttackAbilityBase::HandleSwingFinished). On fire: reads Instigator from the payload, resolves PlayerState→Pawn, filters self/dead, writes to BB ActiveTarget. Source of truth for "who struck me" is always the fresh event — no last-striker cache.
  • [x] Combat.Event.Hurt tag — declared in CradlGameplayTags.h, defined in CradlGameplayTags.cpp, registered in DefaultGameplayTags.ini. Fired from UCradlAttributeSet::PostGameplayEffectExecute on the victim's ASC with Instigator=attacker, Target=victim, EventMagnitude=DamageDelta. Server-only fire — the project's FireReplicatedGameplayEvent helper would clobber Instigator with the victim's OwnerActor.
  • [x] Disengage on natural end — the auto-attack ability ends itself when target dies / cancellation tag fires (existing combat plumbing); WaitForEngagementEnd observes the tag clear and the BT falls back to idle. No explicit CancelAbilities from the BT for Phase 3 (the leash-driven mid-engagement abort lands in Phase 5 — chase itself has no internal give-up, so target-death is the only natural end before leashing is wired up).
  • [x] Phase-2 fix-up: UEnemyDefinition::BehaviorTree corrected from TSoftClassPtrTSoftObjectPtr. BT assets are UObject instances, not classes; the original picker would have shown nothing.
  • [x] Cheat command extensionDebug_DamageNearestEnemy(int32 Amount, FName TypeLeaf = NAME_None) on ACradlPlayerController applies a UGE_Combat_Damage spec to the nearest enemy with the local PS as instigator. Verifies retaliation fires from the event branch alone, with no QueuedInteract or range/accuracy involvement.

Verification.

Place a AEnemyCharacter with DA_Enemy_Goblin_TEST from Phase 2.

  • Walk near the enemy → it does nothing (no perception in Phase 3).
  • Click the enemy → player walks up and engages → enemy's Combat.Event.Hit fires on first damage → enemy writes player to ActiveTarget, fires Action.Trigger.Combat.Engage, starts swinging back. Two-way combat.
  • Player walks out of melee range → enemy IPawnCombatant::BeginChase closes distance via UChaseComponent + IMoveIssuer::IssueMoveTo → swings resume on close. Same path the player's chase primitive uses.
  • Player kites faster than enemy walks → enemy pursues indefinitely (chase has no internal give-up). End condition is the leash decorator from Phase 5 (BTDecorator_WithinLeash aborts the engage branch when the enemy drifts past LeashRadiusCm from its anchor); before Phase 5 lands, the enemy follows the player across the map by design.
  • Kill the enemy → BT idle branch (no further action; Phase 8 wires real death). Pawn stuck at 0 HP as Phase 1.
  • Debug_DamageNearestEnemy 5 → enemy fires retaliation against the cheat caller (player); same end-to-end as click.
  • Multi-attacker: 4 players engage one enemy → enemy retaliates against the most recent striker per Combat.Event.Hit payload, not the previous ActiveTarget. Verify by alternating strikes from two players.

Exits. Phase 4 plugs perception into the same engage-dispatch task; Phase 5 adds patrol/leash decorators that abort the engage branch. The brain/body split is proven end-to-end.

Footguns.

  • The BT does not roll damage. Damage rolls live in UCradlCombatMath and are reached through UAutoAttackAbility::HandleSwingFinished. A BT task computing accuracy / damage is the test failing. Per ENEMY_SYSTEM.md "AI Brain" footgun.
  • The BT does not write to the ASC's combat tags. Status.InCombat, Action.Combat.AutoAttack, and friends are owned by the combat abilities and GE channel. The BT reads tags and fires triggers; it never writes leaf state.
  • Don't bypass IMoveIssuer with a direct MoveToActor from a BT task. The pawn's UChaseComponent looks the controller up through IMoveIssuer for chase moves; calling SimpleMoveToActor from the task bypasses chase rate-limiting. The dedicated BTTask_FireCombatEngage triggers the ability; the ability handles its own chase through IPawnCombatant.
  • Don't subscribe BT decorators to State.Moving. Same self-cancellation footgun as combat abilities — the AI causes its own movement and would self-terminate.
  • Retaliation reads Instigator from the damage event payload, not from ActiveTarget. A multi-attacker enemy re-targets to the most recent striker, not to whichever player happened to be the current ActiveTarget when the new hit landed.
  • Action.Combat.AutoAttack is the wait-while-engaged signal, not Status.InCombat. The latter persists 20s post-fight (its whole purpose); the former clears at EndAbility. Don't watch the wrong tag.

Phase 4 — Hostility, Perception, Combat-Level Scalar

Goal. Aggressive enemies auto-engage players in AggroRadiusCm whose CombatLevel < LevelGate (subject to bRequireLOS). Passive enemies still only retaliate. CradlCombat::ComputeCombatLevel(PS) lands as the canonical formula. Enemies stop pestering high-level players just like OSRS.

Rationale. Perception is the smallest additive branch on top of Phase 3's retaliation — same BTTask_FireCombatEngage, same downstream wait-while-engaged subtree. Adding the upstream perception decorator + the level-gate check earns "this enemy chases me through the world" content without re-shaping anything Phase 3 landed. The combat-level scalar is a one-function lift; codifying it now (vs. piecemeal at each gate site) prevents drift before any second caller arrives.

Tasks.

  • [x] CradlCombat::ComputeCombatLevel(const ACradlPlayerState*) — landed in Source/CRADL/Combat/CradlCombatRange.h (sibling to ResolveCombatContext; same module-side as ResolveEngagementRangeForPawn's PS lookups, so PS-shaped reads cluster). Formula matches ENEMY_SYSTEM.md "Combat Level Scalar" verbatim; returns 1 on null/invalid PS so callers don't branch.
  • [x] UBTService_EnemyPerceptionSource/CRADL/Enemy/BT/BTService_EnemyPerception.h + .cpp. Default tick 0.5s ± 0.05s deviation. Sphere-overlap → PlayerState filter → Status.Dead skip → LevelGate fade → AAIController::LineOfSightTo when bRequireLOS → write nearest survivor (or null) to BB ActiveTarget. Service short-circuits when Hostility != Aggressive.
  • [x] UBTDecorator_ShouldAutoEngageSource/CRADL/Enemy/BT/BTDecorator_ShouldAutoEngage.h + .cpp. Gates on Hostility=Aggressive + ActiveTarget set + target alive + CombatLevel < LevelGate. FlowAbortMode = EBTFlowAbortMode::LowerPriority by default — out-of-range / level-flip aborts the engage branch back to sibling.
  • [ ] BT shape extension(editor-side; not a C++ deliverable.) Update BT_Enemy_Default:
  • Root → Selector → [Perception engage (Aggressive only)] || [Retaliation] || [Idle Wait].
  • Service Enemy Perception attaches to the root Selector, not the gated perception Sequence. The decorator on the perception Sequence gates entry on ActiveTarget being set — if the service lived on the gated Sequence, the decorator would gate the service out before it could ever write the BB key. Attaching to the parent Selector means the service ticks every interval regardless of which child branch is currently active, breaking the chicken-and-egg.
  • Consequence: perception's null-write must be gated by Action.Combat.AutoAttack on the pawn's ASC (see BTService_EnemyPerception::TickNode). A null-write while engaged would clobber a retaliation target the moment the player steps out of aggro radius and abort an in-flight engagement — wrong, retaliation should continue until chase/leash decides otherwise. Non-null writes still flow through (re-target to a closer perceived player remains permitted). Natural-end paths (WaitForEngagementEnd's tag-clear, Phase 5's leash decorator) own clearing the BB key when the engagement ends.
  • Perception engage Sequence: Decorator Should Auto-EngageFire Combat.EngageWait For Engagement End.
  • Retaliation: unchanged from Phase 3 (Blackboard ActiveTarget Is Set decorator + same Sequence).
  • [x] Disengage extension — perception writes null when no candidate is in range; decorator's LowerPriority abort drops the engage branch; Phase 3's WaitForEngagementEnd aborts and ActiveTarget is cleared on its abort path (auto-attack ability ends on the disengage; no explicit CancelAbilities from the BT for Phase 4 — Phase 5's leash decorator adds that bigger hammer).
  • [x] Phase 2 fields consumed end-to-endAggroRadiusCm, LevelGate, bRequireLOS, Hostility all routed through the new service/decorator.
  • [x] Cheat commandDebug_PrintCombatLevel prints CL + (Def, HP, Melee, Ranged, Magic) via the canonical scalar.

Verification.

Author DA_Enemy_Goblin_TEST with Hostility = Aggressive, AggroRadiusCm = 800, LevelGate = 20, bRequireLOS = true. Place in the test map.

  • Fresh character (combat level 3) walks within 800cm of the goblin → goblin auto-engages and chases.
  • Walk behind a wall within 800cm → goblin doesn't engage (LOS gates).
  • Level up Defense + Hitpoints to push combat level past 20 → goblin no longer auto-engages (level-gate fade). Hit the goblin manually → goblin retaliates (level-gate only blocks first-strike).
  • Reset combat level back below 20 → goblin re-engages.
  • Author DA_Enemy_Cow_TEST with Hostility = Passive, AggroRadiusCm = 0. Place. Walk past → cow does nothing. Strike the cow → cow retaliates.
  • Debug_PrintCombatLevel at all-1s → 3. After raising Hitpoints to 30 / Defense to 30 / Melee to 50 → 34. Matches ENEMY_SYSTEM.md "Combat Level Scalar" reference table.
  • Two-player session: both peers in range → goblin picks the nearest; both peers level-gated → goblin stays idle.

Exits. Phase 5 adds the leash decorator on top of the engage branch (engagement aborts on leash break); Phase 6 plugs group aggro into the perception branch's engage task.

Footguns.

  • LevelGate is per-player. Different players in the same area see different aggro behavior from the same mob; the decorator queries the perception-tick target's combat level, not a globally-stored value. Per ENEMY_SYSTEM.md "Hostility" footgun.
  • One canonical home for combat level. Don't re-derive at call sites — even a single open-coded copy invites drift the next time the formula is tuned. Always CradlCombat::ComputeCombatLevel(PS).
  • Don't add a "hates this player" memory. OSRS-style aggro has no grudge. Once the target leaves the radius (or LOS lapses for the next perception tick), the enemy disengages.
  • UAIPerceptionComponent is deliberately left out of v1. The hand-rolled sphere-overlap + LOS service matches the OSRS "see things in a radius" model with less ceremony. Adding UAIPerceptionComponent later is additive — but don't preempt it. Per ENEMY_SYSTEM.md "AI Brain".
  • Perception tick rate is a tunable, not a constant. Default 0.5s; fast-moving content (boss arenas, ranger enemies that need tighter detection) lifts to 0.25s, lazy world spawns drop to 1s. Authoring lever lives on the service node, not in code.
  • Don't read BestStyle's underlying skill at gate sites. "This enemy aggros melee players only" is a different feature (style-typed hostility); backdooring it via the BestStyle component bypasses a contract the function holds (style-agnostic max). If style-typed aggro is ever wanted, add it as an explicit field on UEnemyDefinition.

Phase 5 — Patrol & Leashing

Goal. Three patrol modes (Stationary, RandomRadius, Spline) run while the enemy is idle. Leash distance aborts engagement when the pawn wanders too far from its anchor; on patrol-return-to-anchor, the pawn heals to full via UGE_Enemy_HealToFull. Damage attribution clears on heal-reset.

Rationale. Patrol and leashing are the two "where does this enemy belong" surfaces; both consume the same AnchorLocation field, so they land together. Without leashing, a player can kite a Goblin across the entire map; without patrol, an idle-but-aggressive enemy stands at its spawn like a target dummy. Per ENEMY_SYSTEM.md "Patrol" and ENEMY_SYSTEM.md "Leashing".

Tasks.

  • [x] AEnemyCharacter::AnchorLocation (server-only FVector) + EffectiveLeashRadiusCm (server-only float) — initialized in ApplyDefinition / ApplyManualFallback via InitializePatrolAndLeashState(). Pure server-side state — BT runs server-only, no client consumer. Anchor captured at apply-time (not BeginPlay) so Phase-8's deferred-spawn flow captures the post-spawn location, not the pre-deferred zero. EffectiveLeashRadiusCm reads Definition->LeashRadiusCm today; the Phase-8 spawner override layers in by writing the field directly after ApplyDefinition.
  • [x] BTTask_PatrolStationarySource/CRADL/Enemy/BT/BTTask_PatrolStationary.h + .cpp. Synchronous-succeed when already at anchor (the common case); else MoveTo anchor via IMoveIssuer::IssueMoveTo(FVector) and tick until arrival. This is how a Stationary pawn "heads back home naturally" after a leash break — per the user's "bake into each patrol task" decision (vs a separate ReturnToAnchor sibling task).
  • [x] BTTask_PatrolRandomSource/CRADL/Enemy/BT/BTTask_PatrolRandom.h + .cpp. Nav-projected pick via UNavigationSystemV1::GetRandomReachablePointInRadius (anchor-centered so the return-to-home effect falls out — a kited pawn's next pick is inside the radius). Falls through to DefaultPatrolRadiusCm when the definition's radius is 0; validator now warns on that authoring case. Idle delay lives in the BT graph's Wait sibling, not the task itself.
  • [x] BTTask_PatrolSplineSource/CRADL/Enemy/BT/BTTask_PatrolSpline.h + .cpp. Phase 5 stub per the user's decision: when PatrolSplineRef is null (always the case in v5 since no spawner exists) the task falls back to stationary-at-anchor with a Verbose log. Phase-8 path (closest-point-on-spline → MoveTo) is roughed in for the case where a designer wires a spline manually; the full implementation (per-pawn SplineDistance, ping-pong/loop, clamped anchor-update) lands with the spawner in Phase 8.
  • [x] Spline reference plumbingAEnemyCharacter::SetPatrolSplineRef(USplineComponent*) is the public hook the Phase-8 spawner will call post-ApplyDefinition to wire its PatrolSpline. GetPatrolSplineRef() exposes the weak ref to the BT task. Field is server-only (not replicated — BT runs server-side); setter is out-of-line in the .cpp because TWeakObjectPtr<USplineComponent> = T* requires the full type. Manual-placement enemies with PatrolMode = WaypointLoop and no spawner fall back to stationary-at-anchor; the validator now warns on the authoring case.
  • [x] BTDecorator_WithinLeashSource/CRADL/Enemy/BT/BTDecorator_WithinLeash.h + .cpp. Returns false when DistSqXY(pawn, AnchorLocation) > EffectiveLeashRadiusCm². Sentinel: EffectiveLeashRadiusCm <= 0 short-circuits to true (no leash). FlowAbortMode = Both so a mid-engagement leash break aborts the engage branch immediately. Tick-driven ConditionalFlowAbort(ConditionResultChanged) re-checks every 0.25s (configurable) — the engine doesn't observe pawn-transform changes, so the explicit re-check is required.
  • [x] BTTask_LeashResetSource/CRADL/Enemy/BT/BTTask_LeashReset.h + .cpp. Latent retreat-and-reset task: drives the pawn back to AnchorLocation, then heals to full and clears UGE_Enemy_Leashing. Drops as a sibling of the patrol tasks in the Idle Sequence (typically first, so retreat preempts patrol while leashing is active). Gates: Status.Leashing absent → instant Succeeded no-op (the common BT iteration); Health <= 0 → Failed (death pipeline owns the corpse window); otherwise issues IssueMoveTo(AnchorLocation) and ticks until arrival within ArrivalToleranceCm (default 50cm, tight — ~character capsule width) or MaxWalkSeconds (default 20s watchdog) elapses. On arrival: Enemy->ApplyAnchorReset() (heal-to-full via UGE_Enemy_HealToFull, clear UEnemyDropComponent attribution, remove UGE_Enemy_Leashing). On stall: stop move, return Succeeded so the BT loops and retries on the next iteration (mirror of the patrol tasks' stuck-pawn shape).
  • ArrivalToleranceCm and MaxWalkSeconds are task UPROPERTYs, not per-enemy-archetype — they live on the BT asset, so every enemy sharing the BT shares the same arrival tolerance. Lifting them onto UEnemyDefinition is a follow-up when a second archetype genuinely needs divergence.
  • Why retreat to anchor instead of "back inside leash radius." Patrol tasks can pick destinations near the leash boundary, so a "boundary re-entry" trigger would re-arm the pawn for engagement while still adjacent to the kite-out distance — letting a player damage→leash→heal at the boundary. Forcing retreat to the anchor commits real return-trip time before re-engageability. Single lever stays LeashRadiusCm: wider leash → longer retreat → fewer kite cycles per minute. Documented at the top of BTTask_LeashReset.h.
  • [x] UGE_Enemy_HealToFull finalizeSource/CRADL/Combat/CombatGameplayEffects.cpp. Instant duration, single modifier on Health, ModifierOp = Override, magnitude resolved via FAttributeBasedFloat capturing MaxHealth from Target with bSnapshot = false (so applying the CDO reads the live MaxHealth at execute time, not module-init time). Override bypasses UCradlAttributeSet::PostGameplayEffectExecute's Damage-meta branch entirely — leash heal fires no cue, no XP, no InCombat refresh. Per the Phase-0 footgun: the modifier must be Override, never Additive.
  • [ ] BT shape extension(editor-authored; not a C++ deliverable.) BT_Enemy_Default adds:
  • Engage subtree wrapped in BTDecorator_WithinLeash (engage aborts when out of leash; WaitForEngagementEnd::AbortTask now owns the CancelAbilities + EndChase teardown per the Phase-5 user decision).
  • Patrol branch (one of the three patrol tasks per definition's PatrolMode) runs in idle. On engage end, BT falls back to patrol naturally — patrol-back-to-anchor is the same code path as routine patrol movement (per the user's "bake into each patrol task" decision); the leash-reset task is a sibling gated on arrival within tolerance.
  • [x] Validator extensionSource/CRADLEditor/Validators/CradlEnemyDefinitionValidator.cpp:
  • LeashRadiusCm >= 0 is enforced via meta=(ClampMin="0.0") on the UPROPERTY.
  • Warning: PatrolMode == WanderRadius && PatrolRadiusCm == 0 — runtime fallback catches it, but the authoring is still wrong.
  • Warning: PatrolMode == WaypointLoop — validator can't see spawner placement; warn so the author knows a spawner is required (Phase 8).

Phase-5 cross-cutting change: WaitForEngagementEnd owns the cancel teardown. Per the user's "extend WaitForEngagementEnd::AbortTask" decision (vs. dedicated cancel task or decorator side-effect): UBTTask_WaitForEngagementEnd::AbortTask now calls ASC->CancelAbilities({EngagementTag}) + IPawnCombatant::EndChase + StopMovement + BB clear when the BT subtree aborts. Centralizes the teardown in one place — the task whose lifetime matches "engagement is active" — instead of spreading side effects across every decorator that might cause an abort. Decorators stay predicates. Header doc updated to reflect the new policy. Survives future Phase-6 group-aggro target invalidation without further changes.

Replication audit (per CLAUDE.md / saved feedback). All Phase 5 server-mutated state is deliberately not replicated: - AnchorLocation, EffectiveLeashRadiusCm, PatrolSplineRef on the pawn — server-only; BT (server-side) is the only consumer. - UGE_Enemy_HealToFull apply — server-side; Health attribute change replicates via standard GAS attribute rep (Mixed mode). - CancelAbilities + EndChase + StopMovement in WaitForEngagementEnd::AbortTask — server-side; ActivationOwnedTag clear replicates via the ASC's tag-count container, movement halt via CharacterMovement.

Verification.

Author DA_Enemy_Goblin_PATROL with PatrolMode = RandomRadius, PatrolRadiusCm = 500, LeashRadiusCm = 1500. Place.

  • Goblin wanders within 500cm of its spawn point on a few-second cadence.
  • Engage → patrol pauses; goblin chases the player.
  • Kite the goblin past 1500cm from anchor → engage branch aborts; UGE_Enemy_Leashing applies; BTTask_LeashReset drives the goblin all the way back to its AnchorLocation. On arrival (within ArrivalToleranceCm, 50cm default), HP snaps to MaxHealth and Status.Leashing clears (verify via combat-stats overlay + on-screen log). Player can't poke the goblin during retreat to short-circuit the cycle — the heal only fires at the anchor, not mid-walk.
  • Mid-leash-return, the player follows the goblin and strikes again → retaliation re-engages (Phase 3 path) and the leash decorator re-evaluates against the new anchor distance. If still out of range, engagement re-aborts; the back-and-forth doesn't deadlock.
  • Stationary DA_Enemy_Cow_TEST (PatrolMode = Stationary) → cow stays put.
  • LeashRadiusCm = 0 on a "boss" placeholder → the decorator short-circuits to true; kite the boss across the world → it pursues indefinitely (intended — 0 means "no leash, boss/kiting-ranged archetype"). The only way to end pursuit on a LeashRadiusCm = 0 enemy is the target dying or the player despawning out of the world; design the boss arena with that in mind.
  • Damage attribution clears on heal-reset: deal 10 damage, kite to leash-reset → look at the enemy's DamageDealt map (server-side breakpoint or debug print) → empty. Next engage starts from zero.

Exits. Phase 6 plugs group aggro into the perception branch; Phase 7-8 land the death + drop pipeline that the leash-reset's damage-map clear forward-references.

Footguns.

  • Don't fire heal on leash abort; fire on arrival. Healing on abort rewards kiting (player damages, enemy leashes, enemy heals at the leash boundary, player can poke from outside the radius indefinitely). Per ENEMY_SYSTEM.md "Leashing" footgun.
  • Heal-to-full is an Override modifier, not additive damage. Writing Health = MaxHealth bypasses the Damage meta-attribute path, so the leash heal doesn't fire heal cues, doesn't refresh Status.InCombat, doesn't grant Hitpoints XP. Intentional — leash-heal is a state reset.
  • LeashRadiusCm = 0 is a sentinel for "no leash," not "leash at zero distance." A decorator that reads 0 and computes distance > 0 would fire on the first tick after spawn. The decorator short-circuits.
  • Don't put the anchor on UEnemyDefinition. The definition is shared across every spawn; the anchor is per-instance state. It lives on the pawn.
  • Spline-patrol anchor updates must be clamped per perception tick. If "closest point on spline" can jump arbitrary distances per tick, a pawn can effectively chase along an infinite spline. Clamp to ≤ patrol walk speed × tick interval.
  • Patrol-back-to-anchor is not chase. It uses IMoveIssuer::IssueMoveTo(FVector) to the anchor location, not IPawnCombatant::BeginChase. Chase has no internal give-up — using it for patrol would pursue the anchor "target" forever instead of arriving and stopping. Per ENEMY_SYSTEM.md "Patrol" footgun.

Phase 6 — Group Aggro

Goal. Opt-in pack aggro per UEnemyDefinition::GroupAggroRadiusCm > 0. When an enemy fires its perception-driven engage branch, a BT task sphere-overlaps same-FamilyTag neighbors within the radius and writes the engaged target to each neighbor's blackboard ActiveTarget. Single-hop only.

Rationale. Most OSRS mobs are solo-aggro; packs are explicitly authored (cave goblins, dagannoth families). Opt-in via default-zero radius keeps authoring intent explicit. Landing after perception (Phase 4) makes the broadcast a clean additive task — it doesn't change retaliation, doesn't change leashing, doesn't change patrol; it only fires when the engage task succeeds.

Tasks.

  • [x] BTTask_BroadcastGroupAggroSource/CRADL/Enemy/BT/BTTask_BroadcastGroupAggro.h + .cpp. Authored to run as a sibling step on the perception-driven engage sequence, immediately after BTTask_FireCombatEngage (BT graph wiring is editor-side). Behavior:
  • Single-hop guard: if own BBKey_AggroBroadcastReceived is true, return Succeeded immediately (don't re-broadcast). The flag itself is cleared by the receiver's BTService_EnemyPerception, not by this task — see "consumption channel" below.
  • Short-circuits if Definition->GroupAggroRadiusCm <= 0 (default 0 = opt-out). Task is a no-op so the BT graph can host the node on every engage sequence without per-archetype gating.
  • Sphere-overlap radius = GroupAggroRadiusCm, channel = ECC_Pawn (same shape as BTService_EnemyPerception).
  • Filters: AEnemyCharacter cast, exclude self, exclude dead (Status.Dead tag check), neighbor's ActiveDefinition->FamilyTag.MatchesTagExact(self.FamilyTag). Exact-leaf match — no parent-tag fallback, per ENEMY_SYSTEM.md "Group Aggro" footgun.
  • For each match: write neighbor's BBKey_ActiveTarget = Victim + BBKey_AggroBroadcastReceived = true directly through UBlackboardComponent::SetValueAsObject / SetValueAsBool. The decorator's LowerPriority value-change observer picks up the ActiveTarget write on the neighbor's next BT tick.
  • Hostility filtering happens on the receiver side, not in this task. The broadcaster writes to every FamilyTag-matched neighbor regardless of the neighbor's Hostility — a Passive receiver "gets the message" via the BB write, then its own BTDecorator_ShouldAutoEngage rejects the engage on Hostility/LevelGate. Matches OSRS "hit one cow ≠ herd attacks" semantics.
  • [x] Decorator behavior (no functional change in BTDecorator_ShouldAutoEngage.cpp). The decorator already passes when BBKey_ActiveTarget is set + Aggressive + alive + LevelGate-pass. The broadcast pathway works through ActiveTarget — the broadcaster's neighbor write triggers the decorator's value-change observer naturally. Header doc updated to call out the broadcast pathway explicitly so future readers don't look for a literal flag check.
  • [x] Flag consumption channel deviation from the original doc. The original task list said "the receiver clears bAggroBroadcastReceived after engaging (or rejecting per Hostility/LevelGate)". Putting the consume in the decorator is impure (decorators are predicates); putting it in BTTask_FireCombatEngage doesn't fire for rejected-engage receivers (level-faded / Passive) and would leak the flag forever, permanently pausing perception. Corrected: BTService_EnemyPerception is the consumer. On its tick, if BBKey_AggroBroadcastReceived is true, the service (1) clears the flag and (2) skips its own ActiveTarget write for this tick so the broadcast write isn't clobbered. Placed before the Hostility gate in the service so Passive enemies also clear (otherwise the early-return on !Aggressive would leave the flag stuck). This bounds the broadcast's preservation window to one perception interval (~0.5s) — the decorator's value-change observer fires within one BT tick of the broadcast write, so engage dispatch and the perception consume don't race.
  • [x] Group aggro is perception-only. The BT graph places BTTask_BroadcastGroupAggro on the perception-driven engage sequence only, not the retaliation branch. Per ENEMY_SYSTEM.md "Group Aggro" footgun — broadcasting on retaliation would change the contract ("hit one cow and the whole herd attacks") in an unexpected way. The task itself is permissive (works from either context); the BT graph enforces the perception-only placement.
  • [x] Validator extensionGroupAggroRadiusCm is already meta=(ClampMin="0.0") on the UPROPERTY (compile-time enforced ≥ 0). Added AssetWarning in CradlEnemyDefinitionValidator.cpp for GroupAggroRadiusCm > 0 && !FamilyTag.IsValid() — structurally redundant today (FamilyTag is required at save-time, so this warning is unreachable), but kept defensive in case FamilyTag becomes optional for a future no-pack archetype. The warning explains the group-aggro consequence specifically ("broadcast filters by exact-leaf FamilyTag and will find none") so a designer who saw both errors can connect them.

Verification.

Author DA_Enemy_Goblin_PACK with Hostility = Aggressive, AggroRadiusCm = 600, LevelGate = 20, GroupAggroRadiusCm = 800, FamilyTag = Enemy.Family.Goblin.Warrior. Place three of them clustered within 800cm of each other.

  • Walk one player into 600cm of the front goblin → front goblin aggros; perception broadcast fires; both rear goblins receive bAggroBroadcastReceived and also engage. All three engage from a single perception tick.
  • Multi-attacker cap: pack of 3 vs 1 player → all 3 register on the player; 4th attacker (if added) hits the cap. The cap check stays unchanged — broadcast doesn't bypass it.
  • One-hop: spawn a Goblin at 1600cm from the trio, also Warrior-family. Walk into the trio's radius → the trio engages; the 4th Goblin does not receive the broadcast (it's outside the 800cm radius). The trio doesn't re-broadcast.
  • Family-tag specificity: spawn a Enemy.Family.Goblin.Wizard neighbor at 700cm from the Warrior pack. Walk in → wizard does not receive the broadcast (family-tag is exact-leaf, not parent).
  • Retaliation doesn't broadcast: strike one of the trio without entering the perception radius (Debug_DamageNearestEnemy) → retaliation fires; the other two stay idle. Confirms broadcast is perception-only.
  • Cap interaction: spawn 4 broadcast receivers but only 3 can attack the target (Phase 1 cap = 3). The 4th's TryRegisterAttacker fails on first swing; receiver falls back to patrol. No pre-filtering on the broadcast side.

Exits. Perception / engagement / leash / group-aggro shape is complete. Phase 7 layers loot tracking on top of damage; Phase 8 closes the kill-loop.

Footguns.

  • Group aggro does not bypass the multi-attacker cap. A broadcast that pushes a target above its MaxAttackers results in the receiver's authoritative TryRegisterAttacker failing on first swing; the receiver gets the standard "target is overwhelmed" path and falls back to patrol.
  • Family-tag match is exact-leaf. A Enemy.Family.Goblin.Wizard broadcast does not pull in Enemy.Family.Goblin.Warrior neighbors. Hierarchical aggro is a downstream design choice; default exact-match forces designers to think about which pack is aggroing.
  • One-hop enforcement via blackboard flag. A receiver who's also an aggressive-perception aggro source can't broadcast outward on the same turn. The original broadcaster already covered the radius; cascade chains create flicker pathologies.
  • Don't add group aggro to Passive enemies. A passive enemy doesn't fire the engage branch from perception; broadcasting on retaliation would change semantics. Enforced by BTTask_BroadcastGroupAggro being on the perception-driven engage branch only.

Phase 7 — Drop Tables & Loot Attribution

Goal. UDropTableDefinition exists with composable sub-tables. UEnemyDropComponent on AEnemyCharacter tracks per-attacker damage. CradlLoot::RollDropTable produces an FRolledItem list. Drops aren't spawned yet — Phase 8 wires the ground-item spawn from the death pipeline. Phase 7 lands data + roll math in isolation, verifiable via cheat.

Rationale. Drop tables are the loot data spine; composable sub-tables (Rare Drop Table, family-shared bones table) collapse authoring duplication across thirty future enemies. Landing the data + roll function before death lets us cheat-verify drop rates against authored weights without entangling death-pipeline state. Mirrors how UCradlCombatMath landed in combat Phase 3 — pure leaf, isolated validation.

Tasks.

  • [x] UDropTableDefinition — landed at Source/CRADL/Loot/DropTableDefinition.h. UPrimaryDataAsset:
  • [x] FText DisplayName.
  • [x] TArray<FDropTableEntry> AlwaysDrops.
  • [x] TArray<FDropTableEntry> WeightedDrops.
  • [x] TArray<TSoftObjectPtr<UDropTableDefinition>> SubTables.
  • [x] GetPrimaryAssetId() returns FPrimaryAssetId("DropTableDefinition", GetFName()).
  • [x] FDropTableEntry — inline struct in the same header:
  • [x] FName ItemId (resolves through UItemRegistry::FindItem; empty in WeightedDrops = "no drop" slot per OSRS convention).
  • [x] int32 Weight = 1 (ignored in AlwaysDrops).
  • [x] int32 MinCount = 1, int32 MaxCount = 1.
  • [x] TArray<FSkillRequirement> SkillRequirements — mirror of FItemDrop::SkillRequirements on UGatheringNodeDefinition.
  • [x] UDropTableRegistry — lazy UGameInstanceSubsystem at Source/CRADL/Loot/DropTableRegistry.h + .cpp. Same shape as UEnemyRegistry. EnsureBuilt() on first use; GetTable(FName), GetAllTables(...).
  • [x] CradlLoot::RollDropTable — free function at Source/CRADL/Loot/CradlLoot.h + .cpp. Pure server-side.
  • Signature deviation: takes an FRandomStream& parameter instead of an explicit TSet<TWeakObjectPtr<...>>& Visited (per the original sketch). Visited-set threading is an internal implementation detail of the recursion; surfacing it in the public signature pushed RNG ownership onto every caller. Now the caller owns the FRandomStream (death-time path: fresh stream per kill; cheat sim: one stream reused across N rolls for representative aggregate distribution), and the cycle-detecting Visited set lives inside RollTableRecursive (anonymous-namespace helper in the .cpp).
  • Walks AlwaysDrops → rolls MinCount..MaxCount per entry, skipping empty-ItemId rows (warned at validation).
  • Weighted pick from WeightedDrops filtered by SkillRequirements against Attribution.Tagger's USkillsComponent. Empty ItemId on chosen entry → no output (the "no drop" slot).
  • Recurses into each SubTables independently; cycle-safe via the internal Visited set.
  • [x] FRolledItem + FDropAttribution — landed in Source/CRADL/Loot/CradlLoot.h. Attribution is { TWeakObjectPtr<ACradlPlayerState> Tagger, int32 ContributorCount }; the weak ref auto-clears on disconnect.
  • [x] Cycle detection — runtime guard via Visited set inside RollTableRecursive; authoring-time guard via DFS in the validator.
  • [x] UEnemyDropComponent — landed at Source/CRADL/Enemy/EnemyDropComponent.h + .cpp. UActorComponent on AEnemyCharacter. Server-only (SetIsReplicatedByDefault(false) in ctor); state is non-UPROPERTY(Replicated).
  • [x] TMap<TWeakObjectPtr<ACradlPlayerState>, int32> DamageDealt + TWeakObjectPtr<ACradlPlayerState> LastStriker.
  • [x] void RegisterDamage(ACradlPlayerState* Attacker, float DamageDelta) — additive; floors float to int with saturation against int32::Max. Authority-guarded on the owning actor.
  • [x] FDropAttribution ResolveAttribution() const — walks the map, skips stale weak refs (disconnected players forfeit tag), picks highest cumulative; ties resolve to LastStriker.
  • [x] void Reset() — empties the map and LastStriker. Called from BTTask_LeashReset on heal-to-anchor.
  • [x] void GetDebugSnapshot(...) — non-shipping, declaration guarded by #if !UE_BUILD_SHIPPING (not a UFUNCTION, so the CLAUDE.md rule on header guards doesn't apply); produces a sorted (desc by damage) snapshot for the Debug_PrintEnemyDropComponent cheat.
  • [x] Damage hook — deviation from original sketch. The Phase 7 doc's original plan was a branch in UCradlAttributeSet::PostGameplayEffectExecute that resolved the victim → AEnemyCharacter and called RegisterDamage. That sketch predates Phase 3's introduction of Combat.Event.Hurt and the AEnemyCharacter::HandleHurtEvent server-side subscriber. The corrected hook lands inside HandleHurtEvent (which already runs server-only, already extracts Payload->Instigator, and already gets Payload->EventMagnitude = DamageDelta for free). Recording attribution alongside the existing retaliation SetRetaliationTarget write means no Combat→Enemy include reversal in UCradlAttributeSet, no new interface, no FindComponentByClass cross-module hop. The damage write is placed before the dead-instigator filter (damage is fact-of-history; an attacker who dies before the killing blow still keeps their contribution; the resolution-time logic decides disconnect/death forfeitures).
  • [x] AEnemyCharacter integrationUEnemyDropComponent* DropComponent UPROPERTY constructed as a default subobject in the ctor; public GetDropComponent() accessor; HandleHurtEvent records damage via DropComponent->RegisterDamage(AttackerPS, Payload->EventMagnitude) immediately after PS resolution.
  • [x] Phase 5 leash-reset call site populatedBTTask_LeashReset.cpp now calls Enemy->GetDropComponent()->Reset() after the heal-to-full GE applies. PHASE 7 TODO comment removed; static description updated to reflect the now-landed reset.
  • [x] UCradlDropTableDefinitionValidator — landed at Source/CRADLEditor/Validators/CradlDropTableDefinitionValidator.h + .cpp. Mirrors UCradlEnemyDefinitionValidator shape:
  • [x] Every FName ItemId resolves through UItemRegistry (empty ItemId in WeightedDrops valid — the OSRS no-drop slot; empty in AlwaysDrops fails as dead authoring).
  • [x] MinCount <= MaxCount enforced; per-array (AlwaysDrops + WeightedDrops).
  • [x] Weight = 0 warned in WeightedDrops (no-op entry); Weight != 1 warned in AlwaysDrops (Weight is ignored there — usually means the author meant to put the entry in WeightedDrops).
  • [x] All SubTables soft-refs resolve through IAssetRegistry::GetAssetByObjectPath.
  • [x] DFS cycle detection through SubTables; fails with the cycle path printed (e.g. "DT_A -> DT_B -> DT_A").
  • [x] Wholly-empty table (no AlwaysDrops, no WeightedDrops, no SubTables) warned as a half-authored table.
  • [x] Cheat commands on ACradlPlayerController:
  • [x] Debug_RollDropTable(FName TableAssetName, int32 N = 1000) — resolves the table via UDropTableRegistry, builds a synthetic FDropAttribution { Tagger = local PS }, runs N rolls with one reused FRandomStream, prints aggregate per-ItemId counts + per-roll averages + no-output count. Mirror of Debug_RollEnemyStats's output shape.
  • [x] Debug_PrintEnemyDropComponent() — finds nearest AEnemyCharacter via TActorIterator, prints the resolved attribution (tagger + contributor count) plus the descending-sorted damage map. Authority-gated (clients see "server-only" notice on dedicated-server topologies).

Replication audit (per CLAUDE.md / saved feedback). Server-mutated state added in Phase 7: - [x] UEnemyDropComponent::DamageDealt (TMap), LastStriker (TWeakObjectPtr) — server-only; non-UPROPERTY(Replicated). Clients have no consumer (AGroundItem replication in Phase 8 is the only client-visible loot surface). Per ENEMY_SYSTEM.md "Loot Attribution" footgun. - [x] AEnemyCharacter::DropComponent (component pointer) — UPROPERTY(VisibleAnywhere) without Replicated specifier. Component is a default subobject so it exists on every peer's CDO-derived instance, but state is empty on clients. Component's own SetIsReplicatedByDefault(false) ensures no auto-replication kicks in.

Verification.

Author DT_Goblin_TEST with: - AlwaysDrops = [{ ItemId = "bones", MinCount = 1, MaxCount = 1 }]. - WeightedDrops = [{ "coin", Weight = 10, MinCount = 5, MaxCount = 15 }, { "rune.air", Weight = 3, MinCount = 1, MaxCount = 5 }, { "", Weight = 87 }].

And DT_RareDropTable_TEST with: - WeightedDrops = [{ "rune.fire", Weight = 1 }, { "", Weight = 999 }].

Set DA_Enemy_Goblin_TEST.DropTable = DT_Goblin_TEST and DT_Goblin_TEST.SubTables = [DT_RareDropTable_TEST].

  • Debug_RollDropTable DT_Goblin_TEST 10000 → bones drops 10000 times (always); coin appears ~10/100 of weighted rolls × ~10 avg count; rune.air appears ~3/100 × ~3 avg count; rune.fire appears ~1/1000 (from the sub-table).
  • Engage and kill a goblin → Debug_PrintEnemyDropComponent against the (still 0-HP) pawn shows the damage map populated from the single attacker.
  • Two-player co-op: both peers strike → component damage map shows two entries; ResolveAttribution returns the higher.
  • Tie test: scripted equal damage from two players → tagger = LastStriker.
  • Validator: introduce DT_A.SubTables = [DT_B] and DT_B.SubTables = [DT_A] → validator rejects with a cycle message.
  • Skill-gated entry: add SkillRequirements = [{ Slayer, 50 }] to a rare entry; tagger at Slayer 10 → entry skipped from the weighted pool; tagger at Slayer 50+ → entry eligible.

Exits. Phase 8 has the drop-roll producer + attribution provider it needs at death-time.

Footguns.

  • The "no drop" slot is an empty ItemId, not a bIsNoDrop flag. Weighted picks treat empty ItemIds as a successful roll-with-no-output. A parallel flag would invite forks where one site checks the flag and another only checks the ItemId. Per ENEMY_SYSTEM.md "Drop Tables" footgun.
  • Cycle detection is mandatory. DT_A → DT_B → DT_A is finite-but-explosive recursion. Caught at validation; the runtime Visited set is belt-and-suspenders.
  • Skill-requirement gates use the attacker's skills (the tagger). Not the enemy's.
  • Don't read items table values inside the drop roll. The roll produces FName ItemId + count; ground-item spawn (Phase 8) resolves the row. Mixing roll and resolve forces the loot module to depend on the items module's row shape.
  • Sub-table rolls are independent. Each sub-table independently rolls its own AlwaysDrops + WeightedDrops. A monster with WeightedDrops + SubTable(RareDropTable) yields a primary weighted pick plus a rare-table weighted pick — not a single union. Matches OSRS semantics.
  • UEnemyDropComponent does not replicate. Clients have no consumer; replicating it inflates bandwidth and exposes per-attacker numbers that have no client-side use. Per ENEMY_SYSTEM.md "Loot Attribution" footgun.
  • TWeakObjectPtr<ACradlPlayerState> clears on disconnect — exactly the right behavior. A player who disconnects mid-fight forfeits their tag; the next-highest remaining player becomes tagger. Don't switch to FUniqueNetId "to remember disconnected players" — that re-opens the grief pattern.

Phase 8 — Enemy Death, Ground Drops, Spawners

Goal. Enemy reaching Health = 0 fires UEnemyDeathAbilityStatus.Dead applied → drop table rolled against the tagger → ground items spawned at the corpse with OwnedBy = Tagger for DropOwnershipSeconds → corpse-visible window elapses → pawn destroyed. AEnemySpawner placeable manages spawn cadence + MaxAlive and schedules respawn on death notification. Closes the v1 enemy kill-loop end-to-end.

Rationale. Death and spawners are deliberately bundled — they're co-dependent (death notifies the spawner, spawner depends on death-cleanup running exactly once per kill). Splitting into two phases forces a no-op stub on one side or the other. Landing them together means the verification gate is a real demo: place a spawner, kill the enemy, watch it respawn.

Tasks.

  • [x] UEnemyDeathAbilitySource/CRADL/Enemy/EnemyDeathAbility.h + .cpp:
  • [x] UCradlGameplayAbility subclass, NetExecutionPolicy = ServerOnly, InstancingPolicy = InstancedPerActor.
  • [x] Trigger: Combat.Event.Death via FAbilityTriggerData{ GameplayEvent } — same event the player listens to; the grant gate is the pawn class.
  • [x] ActivationOwnedTags = Action.Combat.Death (shared tag — matches existing convention).
  • [x] ActivationBlockedTags = { Action.Combat.Death, Status.Dead } — re-activation guard. UCradlAttributeSet::PostGameplayEffectExecute fires Combat.Event.Death whenever a damage event leaves Health ≤ 0, including stray damage that lands on a 0-HP corpse during the corpse window. Without this gate a second event would spawn duplicate drops and re-notify the spawner.
  • [x] Granted unconditionally from AEnemyCharacter::BeginPlay (after InitAbilityActorInfo, before any path-specific branching) with a FindAbilitySpecFromClass dedupe so a designer who lists it in Definition->GrantedAbilities doesn't produce two specs. Mirror of ACradlPlayerState's C++ grant of UDeathAbility.
  • [x] On activate (server):
    1. Apply UGE_Status_Dead to self via ApplyGameplayEffectToSelf(GetDefault<UGE_Status_Dead>(), ...). Status.Dead is in UCradlAutoAttackAbilityBase::CancelOnTagsAdded, so every attacker's swing tears down before the corpse window ends.
    2. Attribution = Enemy->GetDropComponent()->ResolveAttribution().
    3. Sync-load Def->DropTable.LoadSynchronous() — drop tables are small.
    4. Roll via CradlLoot::RollDropTable(Table, Attribution, FRandomStream) (caller-owned stream per the actual signature — the doc's "Visited set in the public signature" sketch was wrong; cycle detection is an internal detail of the recursion).
    5. For each FRolledItem: spawn AGroundItem at corpse + 50cm XY scatter, SetPayload, and SetOwnership(TaggerPS, DropOwnershipSeconds) if the tagger is valid. Null tagger falls through with no gate — free-for-all from spawn.
    6. Enemy->GetOwningSpawner()->NotifyDeath() — schedules the spawner's respawn timer. Editor-placed enemies (no spawner) skip this step.
    7. Run UAbilityTask_WaitDelay::WaitDelay(this, EnemyCorpseSeconds); bind OnFinish → OnCorpseWindowFinished (UFUNCTION on the ability — multicast delegate signature requires it).
    8. OnCorpseWindowFinished: Avatar->Destroy(); EndAbility(... bWasCancelled=false).
  • [x] AGroundItem extension — added OwnedBy: TObjectPtr<ACradlPlayerState> + bHasOwnershipGate: bool, both Replicated. Server-side SetOwnership(Tagger, DurationSeconds) API:
  • Null tagger or 0-duration leaves the gate fields at default → free-for-all from spawn.
  • Valid tagger + positive duration sets both fields and starts an OwnershipExpiryTimer weak-lambda that clears the gate on expiry.
  • CanInteract reads the gate via the invoker's PS. Disconnect mid-window: OwnedBy clears (PS destroyed), bHasOwnershipGate stays true — gate stays closed to everyone until the timer fires. Matches ENEMY_SYSTEM.md "Loot Attribution" disconnect rule.
  • Replicated state pattern (separate flag + ref, not a single nullable ref) chosen so "disconnected tagger" is distinguishable from "free-for-all"; per the user's "null tagger → free-for-all immediately" decision the spawn site simply doesn't call SetOwnership when the tagger is null.
  • [x] AEnemySpawnerSource/CRADL/Enemy/EnemySpawner.h + .cpp. AActor, server-only (bReplicates = false, bNetLoadOnClient = false):
  • [x] Fields: TSoftObjectPtr<UEnemyDefinition> EnemyDefinition, float SpawnRadiusCm = 100.f, int32 MaxAlive = 1, float RespawnSeconds = 30.f, embedded USplineComponent* PatrolSpline default subobject, bool bOverrideLeashRadius = false, float LeashRadiusOverrideCm = 1500.f (EditCondition="bOverrideLeashRadius"). PatrolSpline is an embedded subobject per the user's decision (one spawner = one path; multiple goblins from the same spawner share it).
  • [x] Spawn flow deviation from the original doc. The doc's "SpawnActorDeferred → ApplyDefinition → FinishSpawning" sequence breaks GAS: ApplyDefinition calls GiveAbility + ApplyGameplayEffectToSelf, which need the ASC's ActorInfo populated by InitAbilityActorInfo. Init runs in BeginPlay, which doesn't fire until FinishSpawning. The corrected sequence is:
    1. SpawnActorDeferred<AEnemyCharacter> at randomized SpawnTransform.
    2. Pawn->SetOwningSpawner(this) BEFORE FinishSpawning so AEnemyCharacter::BeginPlay's spawner-path early-out sees it and skips ApplyManualFallback (no fallback HP=30 to fight over).
    3. FinishSpawning: fires BeginPlayInitAbilityActorInfo → unconditional UEnemyDeathAbility grant → spawner-path early-out → no fallback ran.
    4. Pawn->ApplyDefinition(Def, FMath::Rand()): now the ASC is initialized, so the per-spawn innate-stats GE applies, equipment stamps, abilities grant. InitializePatrolAndLeashState at its tail captures the post-spawn anchor.
    5. Post-ApplyDefinition stamps: SetPatrolSplineRef (independent of init order) and SetEffectiveLeashRadiusCm(LeashRadiusOverrideCm) (set AFTER ApplyDefinition so the override wins over the definition's value).
  • [x] NotifyDeath(): decrements LiveCount; sets a per-slot FTimerHandle weak-lambda for RespawnSeconds (clamped to KINDA_SMALL_NUMBER so 0 fires next tick). Independent per-slot timers — multiple deaths schedule independent respawns.
  • [x] EndPlay clears all timers via World->GetTimerManager().ClearAllTimersForObject(this) so a spawner destroyed during a respawn delay doesn't leak timer entries.
  • [x] Editor visualization: WITH_EDITORONLY_DATA UBillboardComponent with the engine's S_NavLink sprite (resilient ConstructorHelpers::FObjectFinderOptional).
  • [x] AEnemyCharacter::OwningSpawnerTWeakObjectPtr<AEnemySpawner> on the pawn with Get/SetOwningSpawner accessors. Weak so spawner destruction doesn't pin the pawn; NotifyDeath becomes a no-op against a stale weak ref.
  • [x] AEnemyCharacter::SetEffectiveLeashRadiusCm — public setter exposed for the spawner's leash override. Called AFTER ApplyDefinition so it wins over the definition's LeashRadiusCm.
  • [x] AEnemyCharacter::BeginPlay restructure — three-way branch: 1. OwningSpawner.IsValid() → return (spawner-path; spawner will call ApplyDefinition next). 2. !EditorDefinition.IsNull() → resolve through UEnemyRegistry, call ApplyDefinition (existing editor-placement path). 3. Else → ApplyManualFallback (existing hand-placed fallback path).

ApplyDefinition's grant loop and ApplyManualFallback's grant loop both pick up a FindAbilitySpecFromClass dedupe so the unconditional UEnemyDeathAbility grant doesn't double-spec when a definition lists it. - [x] Status.Dead interaction with cancel-on-tags — already in UCradlAutoAttackAbilityBase::CancelOnTagsAdded per combat Phase 5; verified working end-to-end (attacker swings cancel when UEnemyDeathAbility applies Status.Dead). - [x] UCradlCombatSettings — Phase 0 already landed DropOwnershipSeconds and EnemyCorpseSeconds; Phase 8 consumes both inside UEnemyDeathAbility::ActivateAbility via GetDefault<UCradlCombatSettings>(). - [x] Editor placement still works — placed AEnemyCharacter without a spawner: OwningSpawner.IsValid() == false; UEnemyDeathAbility skips the NotifyDeath step; pawn destroys after the corpse window. No respawn. Boss / one-off placement shape preserved.

Verification.

Drop a AEnemySpawner in the test map with EnemyDefinition = DA_Enemy_Goblin_TEST, MaxAlive = 2, RespawnSeconds = 10, SpawnRadiusCm = 200.

  • PIE starts → 2 goblins spawn within 200cm of the spawner. Each rolls its own variant and stats.
  • Kill one goblin → UEnemyDeathAbility fires; bones + (likely) coins land on the ground at the corpse; corpse persists for 3s (EnemyCorpseSeconds); pawn destroys; 10s later a fresh goblin spawns at the spawner.
  • During the 60s ownership window (DropOwnershipSeconds), only the tagger can pick up the drops. Drop a second peer onto the corpse mid-window → pickup attempt rejected with the existing "Not yours yet" semantics from AGroundItem. Wait out the window → ownership clears; either peer can pick up.
  • Co-op kill: peer A deals 7 damage; peer B deals 13 damage → peer B is the tagger; drops are B's during the ownership window.
  • Disconnect-while-tagged: peer A is the tagger; peer A disconnects mid-corpse-window → TWeakObjectPtr<ACradlPlayerState> clears on the ground item → ownership effectively becomes "no one"; the window timer continues; on expiry, anyone can pick up.
  • Kill both goblins in quick succession → both slots schedule independent respawn timers; after 10s, both respawn (independent of each other).
  • MaxAlive cap: if the test map has a placed AEnemyCharacter already (Phase 1-2 leftover), the spawner doesn't count it; the spawner only manages its own spawned-pawn slots. (Per ENEMY_SYSTEM.md "Spawners" footgun — the spawner doesn't aggregate drop state and similarly doesn't aggregate spawn state across unrelated placed pawns.)
  • Leash override: set bOverrideLeashRadius = true, LeashRadiusOverrideCm = 600 on the spawner. Engage a spawned goblin; kite past 600cm from anchor → leash aborts at 600cm, ignoring the definition's 1500cm default. Per ENEMY_SYSTEM.md "Leashing".
  • Boss placement: drop an AEnemyCharacter directly (no spawner) with EditorDefinition = DA_Enemy_BossDragon (assume the definition has LeashRadiusCm = 0). Kill it → corpse + drops spawn, no respawn (no spawner). Pawn destroyed after corpse window.
  • Spline patrol: set the spawner's PatrolSpline to a placed USplineComponent and the definition's PatrolMode = Spline. Two goblins follow the spline at different starting offsets.

Exits. v1 enemy back-end is complete. Engage / kill / loot / respawn loop closes end-to-end. Cosmetic polish (FX, montages, ragdoll death cue) follows the combat polish pass (Phase 7 of combat); deferred features from Open Questions are explicitly out of scope.

Footguns.

  • Don't trigger respawn from EndPlay. The death ability is the authoritative cleanup site; using EndPlay would also fire on map-unload / level-streaming, scheduling phantom respawns. The spawner's NotifyDeath is called from the ability, exactly once per kill. Per ENEMY_SYSTEM.md "Death" footgun.
  • Don't extend UDeathAbility to handle the enemy case. The combat contract specifies UDeathAbility for the player (cold storage, respawn-self). Branching on IsA<AEnemyCharacter> inside the player-named class is the wrong scaling story; sibling classes are correct.
  • Variant weapon / ammo do not auto-drop. A spear-wielding Goblin's spear is in the equipment slot for combat stats; it's not in the drop pool. To drop the spear, an entry for the spear's ItemId goes in DT_Goblin_Specific.
  • Spawner is server-only. bReplicates = false. Clients see only the spawned pawns, not the spawner. Adding bReplicates = true inflates bandwidth for no consumer.
  • A spawner whose EnemyDefinition fails to resolve at runtime fails loudly. Editor-time validator catches missing refs at save; runtime fallback logs an error and skips spawning — not silently producing nothing. Silent-no-spawn is the worst debugging failure mode.
  • Drop ownership window starts at corpse-spawn, not kill-trigger. Trivially short edge case; the timer begins when the ground items exist, not at the death event fire. Important for the disconnect / clean-up semantics.
  • Sync-load of DropTable at death-time is OK. Drop tables are small assets; the death moment is a natural "we already stopped combat" pause. If a future content pass has hundreds of giant drop tables and the sync-load shows up in profiling, prefetch on definition-load (the UEnemyRegistry::EnsureBuilt's walk could warm DropTable soft-refs); v1 doesn't pre-optimize.
  • MaxAlive > 1 with shared PatrolSpline is a common authoring shape (one path, multiple goblins). Each pawn picks a random starting offset along the spline so they don't pile up at waypoint 0.

Deferred (out of v1 scope)

Mirrors the contract doc's Open Questions section — implementation phases for these don't exist yet, but their absence is deliberate, not missed.

  • Reproducibility of stat rolls — server is authoritative within a session, but save-and-reload picks fresh rolls. Deterministic seed reproducibility (for repro replays, deterministic testing) is additive; not blocking v1.
  • Elite / boss variantsEnemy.Rank.* namespace reserved (Phase 0). Whether elites are a per-spawn modifier, a separate spawner flag, or a definition flavor is a content-design call. No code phase scheduled.
  • Instanced encounters / dungeons — out of scope for the open-world spawner model; lands as a sibling system if/when instanced content is designed.
  • PvE summons / pets — the brain/body split extends cleanly (same pawn shape, allegiance-flipped BT, typically no drop table). Not v1.
  • Per-attacker eligibility for skill-gated drops — v1 keeps it simple: skill requirements query the tagger's skills only. If multi-attacker content surfaces friction (the tagger doesn't meet a requirement but another contributor does), revisit.
  • Ground-item ownership window tuning — default 60s for everything. Per-item-rarity / per-zone (PvP vs PvE) tuning is downstream.
  • Bestiary / kill log — UI surface consuming UEnemyRegistry. Not part of the contract; lands when the UI is designed.
  • Boss-specific multi-attacker overridesMaxAttackers per pawn is supported. Named-player slot reservations, threat-based priority queues, etc. are content questions.
  • Per-spell / per-weapon enemy variants — v1 enemies use auto-attack with the variant's weapon. A spell-casting Goblin variant would slot UCastSpellAbility into GrantedAbilities and the BT's engage task would dispatch Action.Trigger.Combat.Cast instead of Engage. The plumbing supports it; no v1 archetype demands it.
  • UAIPerceptionComponent migration — v1 ships the hand-rolled sphere-overlap service. Migrating to Epic's perception component is additive (the same BTDecorator_ShouldAutoEngage would consume perception events instead of the service's blackboard write).
  • AnimBP enemy stance maps — combat Phase 7's animation polish drives a player-side AnimBP tag map; enemies want the same on their own AnimBP. Lands as part of the combat polish pass, not the enemy build order — purely cosmetic; doesn't gate v1 gameplay.

Cross-cutting verification

End-state of v1 enemies is a single demo:

  1. Spawn the test map with one AEnemySpawner of an Aggressive Goblin definition (MaxAlive = 2, RespawnSeconds = 30).
  2. Walk into the spawner's aggro radius → both goblins detect (one initially, the other via group-aggro broadcast if GroupAggroRadiusCm > 0 is set on the definition) → both engage and chase.
  3. Kill the first goblin → corpse + bones + (maybe) coins land at the corpse position; tagger-only ownership window starts; second goblin keeps swinging.
  4. Kite the second goblin past the leash radius → it disengages, walks back, heals to full on arrival at anchor.
  5. Re-engage the second goblin → it retaliates; kill it → drops; spawner schedules respawn for both slots.
  6. Wait 30s → spawner respawns 2 fresh goblins with fresh rolled variants and rolled stats; the cycle restarts.
  7. Level up Defense + Hitpoints past LevelGate → goblins no longer auto-aggro on respawn; first-strike them → they still retaliate.

If all 7 work, v1 enemies ship.