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
ACradlPlayerControllerexec functions guarded by#if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded. - Per CLAUDE.md "validators in lockstep": any phase that touches
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 sameCombat.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 sharedUCradlAutoAttackAbilityBaseso 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 isEEnemyHostility; 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 byUGE_Enemy_InnateStatsso 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:ApplyDefinitionappends oneFGameplayModifierInfo(FScalableFloatmagnitude) per stat-tuning entry to a transient instance (notSetByCallerMagnitude— see the Phase-2 note). Granted tagStatus.EnemyInnateStatsvia the existingUTargetTagsGameplayEffectComponentnamed-default-subobject pattern (same asUGE_Combat_InCombat/UGE_Status_Dead). - [x]
UGE_Enemy_HealToFull— Instant duration, single override modifier writesHealth = MaxHealth. Used byBTTask_LeashReset(Phase 5). Lands as an empty class here; the modifier wiring is finalized in Phase 5 when the leash branch consumes it. - [x]
UCradlCombatSettingsextension (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 beforePawn->Destroy()(Phase 8). - [x]
float DefaultEnemyLeashRadiusCm = 1500.f— sentinel for definitions that leaveLeashRadiusCmat the default (Phase 5). - [x] Forward-reference module / folder scaffold:
- [x] Create empty
Source/CRADL/Enemy/andSource/CRADL/Loot/directories (a.gitkeepor initial placeholder header is enough; later phases drop concrete files in).
Verification.
- Compile clean (user-side).
- Editor: a fresh DataAsset of
UGE_Enemy_InnateStatsorUGE_Enemy_HealToFullis 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_HealToFullmodifier must beOverride, notAdditive. Settling for additive would treat heal as damage-negative and route throughUCradlAttributeSet::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 asATargetDummy's current concrete API. ATargetDummymigrates from concrete methods to interface implementations (signatures unchanged;virtual ... override).UAutoAttackAbility's twoCast<ATargetDummy>cap sites switch toCast<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. UniversalDefenseattribute scalar layers on top at the call site (read from the target's ASC).ATargetDummyimplements via existingDummyDefenseLevel+GetDummyDefenseBonus(drops the bespoke names from public API).ACradlPlayerStateimplements viaUSkillsComponent::GetLevel+UEquipmentComponent::SumDefenseBonus.AEnemyCharacter(Phase 1) implements via placeholder fields (see main task below); Phase 2 swaps reads to StatTuning-buffed AttributeSet.UAutoAttackAbility::ResolveTargetDefensecollapses dummy + PS branches into a singleCast<ICombatStatsProvider>call; the universalDefensescalar 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 toResolveEngagementRangeForPawn. ReturnsFCombatActorContext { UEquipmentComponent*, USkillsComponent*, UCradlAttributeSet*, USpellbookComponent* }: AttributeSet: fromInfo->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
OwnerActorisn't a PS, reads Equipment fromInfo->AvatarActor->FindComponentByClass<UEquipmentComponent>(). Skills/Spellbook stay null (enemies don't carry them in v1). CradlCombat::ResolveEngagementRangeForPawngets 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-encounterFRandomStream,TargetActor,CurrentSwingStats,bRegisteredOnTarget, the swing loop (StartNextSwing/HandleSwingFinished/ chase poll),IsTargetValid/IsTargetInRange, cap registration viaIAttackerCappedTarget, accuracy roll viaCradlCombatMath, damage GE application viaUGE_Combat_Damage,Combat.Event.{Hit,Miss}publication, hit cue dispatch viaCradl::Cues::ResolveAttachComponent. Anti-fork: damage roll lives here exactly once.- Virtual hooks on the base (driver-specific decisions):
virtual bool ResolveSwingContext(FCombatActorContext&) const— callsCradlCombat::ResolveCombatContextby default; subclasses override only if they need bespoke lookup.virtual int32 ResolveAttackerLevel(FGameplayTag StyleSkill) const— player readsPS->Skills->GetLevel(StyleSkill); enemy readsICombatStatsProvider::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-sideUCombatTargetingComponent+SetActivity; enemy updates pawn-sideUCombatTargetingComponent(no Activity descriptor).virtual void OnEngagementEnded()— symmetric clear.virtual void GrantStyleXp(int32 DamageDealt, const FAutoAttackSwingStats&)— player implementation routes byCombat.Style.*tag (existing logic moves verbatim); enemy is no-op.virtual AActor* GetDamageInstigator() const— player returns PS (soUCradlAttributeSet::PostGameplayEffectExecute's HP-XP grant castsInstigatorActorcorrectly); enemy returns the pawn.virtual bool ResolveAutocast(const FCombatActorContext&, FAutoAttackSwingStats&) const— player readsSpellbook->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).
UAutoAttackAbilitystays 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.QueuedInteractinteraction) stay here.UEnemyAutoAttackAbility— new Source/CRADL/Enemy/EnemyAutoAttackAbility.h +.cpp. Phase 1: attacker level reads route throughICombatStatsProvideron 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
UAutoAttackAbilityclass" to "sameUCradlAutoAttackAbilityBasecore; 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" gainsUCradlAutoAttackAbilityBaseandUEnemyAutoAttackAbility. The "Net Execution Policy" section's "Shared abilities reused as-is" footgun gets reworded to "engagement-loop core is shared; subclasses inheritLocalPredictedand degrade to server-only on enemies."
Pre-work verification.
- Compile clean.
- Existing player auto-attack vs
ATargetDummyworks unchanged: walk-up + swing + damage + multi-attacker cap + autocast + style XP + "Equip a weapon" toast all behave as before. Debug_RollSimnumbers unchanged (the math path moved files but didn't change inputs/outputs).Debug_ToggleCombatStatsoverlay renders the same numbers against a dummy target.Debug_EngageNearest(player→dummy) still works end-to-end.
Pre-work footguns.
UCradlAutoAttackAbilityBaseis abstract by convention, not enforced. Don't grant it directly — it has no concrete attacker-level resolution. Add anensureMsgf(false, ...)in the base'sResolveAttackerLeveldefault 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 byAction.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 avoidsconst-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 insideResolveAttackerLevel— surface the null viaensurein 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 inResolveTargetDefenseuntil 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 realCast<ICombatStatsProvider>resolution againstACradlCharacter.
Tasks.
- [x]
AEnemyCharacter— new files Source/CRADL/Enemy/EnemyCharacter.h +.cpp: - [x]
ACharactersubclass;bReplicates = true,bReplicateMovement = true. - [x] Owns
TObjectPtr<UCradlAbilitySystemComponent> AbilitySystemconstructed in ctor; callsInitAbilityActorInfo(this, this)server-side inBeginPlay. NoOnRep_PlayerStateanalog (enemies have no PlayerState); the pawn is its own owner + avatar. MirrorsATargetDummy::BeginPlaydiscipline (authority-only init). - [x] Owns
TObjectPtr<UCradlAttributeSet> AttributeSet— same class the player uses, verbatim. - [x] Implements:
IAbilitySystemInterface(returnsAbilitySystem),IPawnCombatant,IInteractable,ICueAnchorProvider,IAttackerCappedTarget,ICombatStatsProvider. - [x] Components constructed in ctor:
UChaseComponent(verbatim reuse from Source/CRADL/Combat/ChaseComponent.h),UEquipmentComponent(slot list provided byApplyDefinitionin 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 throughUCradlCombatSettings::UnarmedDefaultWeaponItemIdresolves cleanly),UCombatTargetingComponent(existing class, hosted on the pawn instead of PlayerState — per ENEMY_SYSTEM.md "Combat Reuse"). - [x] Initial
MaxHealthandHealthset inBeginPlayfrom a temporaryEditDefaultsOnly int32 PlaceholderMaxHealth = 30field on the pawn class itself — Phase 2 replaces this withApplyDefinition-driven values. Comment-document as// PHASE 2: remove. - [x]
ICombatStatsProviderplaceholder fields (sibling stopgaps toPlaceholderMaxHealth, all// PHASE 2: remove):EditAnywhere int32 PlaceholderAttackLevel = 1— returned fromGetCombatSkillLevelforSkill.Combat.{Melee,Ranged,Magic}.EditAnywhere int32 PlaceholderDefenseLevel = 1— returned fromGetCombatSkillLevelforSkill.Combat.Defense.EditAnywhere TMap<FGameplayTag, int32> PlaceholderDefenseBonusByType(meta=(Categories="Combat.DamageType")) — returned fromGetDefenseBonusForType(missing entries resolve to 0, mirroringATargetDummy::GetDummyDefenseBonus).
- [x]
IInteractable::GetPrimaryActionTag()returnsAction.Trigger.Combat.Engage. - [x]
IInteractable::CanInteract(invoker)mirrorsATargetDummy::CanInteract— alive gate viaAttributeSet->GetHealth() > 0, engagement range viaCradlCombat::ResolveEngagementRangeForPawn(InteractingPawn),WouldAcceptAttackerpeek through the new interface. SameInteractCheck.Reason.*rejection map. - [x]
IInteractable::GetInteractLabel()= "Attack";IInteractable::GetSourceLabel()returns a placeholder "Enemy" (Phase 2 routes this throughUEnemyDefinition::DisplayName/ active variant). - [x]
IInteractable::GatherActionsoverrides the base impl to setSourceActor = const_cast<AEnemyCharacter*>(this)— same workaroundATargetDummy::GatherActionsuses. Base-class fix is deferred (out of Phase 1 scope). - [x]
IPawnCombatantimpl: delegates toUChaseComponentexactly asACradlCharacterdoes (seeIPawnCombatantimpl in Source/CRADL/Player/CradlCharacter.h). - [x]
IAttackerCappedTargetimpl —TSet<TWeakObjectPtr<AActor>> CurrentAttackers(server-only),Replicated int32 CurrentAttackerCount, idempotentTryRegisterAttacker/UnregisterAttacker/WouldAcceptAttacker. Cap defaults toUCradlCombatSettings::DefaultMaxAttackers; Phase 2 letsUEnemyDefinition::MaxAttackersoverride per-instance. Eviction fires fromUCradlAutoAttackAbilityBase::EndAbility(now interface-shaped, so no extra code path). - [x] Grant
UEnemyAutoAttackAbilityto the ASC server-side inBeginPlayso the enemy could swing — but Phase 1 has no AI to trigger it. Verification below tests this surface via the cheat that firesAction.Trigger.Combat.Engageon the enemy's ASC. - [x]
ICueAnchorProvider::GetCueAttachComponent_Implementationreturns aCueAnchorscene component (head-height above the capsule top, same shape asATargetDummy::CueAnchor). - [x]
IPawnCombatantre-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::BeginOrContinueChaseresolvesIPawnCombatantthroughGetAvatarActor(); on an enemy pawn that'sAEnemyCharacterdirectly, the interface dispatch lands. - [x] Movement-mode policy carry-over.
UMovementModePolicyComponent(facing-drive owner per Phase 6.5B of combat) lives onACradlCharactertoday. 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 command —
ACradlPlayerController::Debug_EnemyEngagePlayer(): - [x] Server RPC walks
TActorIterator<AEnemyCharacter>for the nearest one, firesAction.Trigger.Combat.Engageon its ASC withTarget = local player pawn. Authority lives on the enemy's ASC; the cheat is the AI substitute for Phase 3. DeclaredUFUNCTION(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.InCombatflips 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
IAttackerCappedTargetend-to-end. Debug_EnemyEngagePlayer→ enemy firesAction.Trigger.Combat.Engageagainst the player →UEnemyAutoAttackAbilityactivates 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 != AvatarActorto route through theAPlayerState-bound HP-XP grant. On enemies, both equal the pawn. The pre-work refactor centralizes this:CradlCombat::ResolveCombatContextis 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
APlayerStatefromUCradlAttributeSet::PostGameplayEffectExecutewhen the victim is an enemy. The HP-XP grant path already short-circuits when attacker == victim; for player→enemy damage, the attacker'sPlayerStatelookup 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. UCombatTargetingComponentreplication condition. Per ENEMY_SYSTEM.md "Combat Reuse", the player-side replicatesCOND_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 abReplicateToAllPeersflag 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 whenApplyDefinition+ StatTuning land. Mark each with// PHASE 2: removeso the next PR has clear search targets. - Don't grant
UDeathAbilityto the enemy. That's the player's death class — drops to cold storage, respawns the player. The enemy getsUEnemyDeathAbilityin 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
UCradlAutoAttackAbilityBasedirectly. It's the shared core — abstract by convention. Always grantUAutoAttackAbility(player) orUEnemyAutoAttackAbility(enemy). TheensureMsgfin the base's defaultResolveAttackerLevelcatches 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 = true— fields land here, behavior lands in Phase 4. - [x]
int32 MaxAttackers— overrides per-pawn cap. Default value reads fromUCradlCombatSettings::DefaultMaxAttackers(sentinel0= use settings default). - [x]
EEnemyPatrolMode PatrolMode,float PatrolRadiusCm— fields land here, behavior lands in Phase 5. - [x]
float LeashRadiusCm = 1500.f— field lands here, behavior lands in Phase 5. (No per-definition heal-tolerance field — arrival tolerance lives onBTTask_LeashReset::ArrivalToleranceCm, see Phase 5.) - [x]
float GroupAggroRadiusCm = 0.f— field 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 throughICombatStatsProvider. - [x]
TArray<FWeightedEnemyVariant> Variants,TArray<FEnemyStatTuning> StatTuning. - [x]
TSoftObjectPtr<UDropTableDefinition> DropTable— field lands here, behavior lands in Phase 7-8. StubUDropTableDefinitionlives 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 toUEnemyAutoAttackAbilityviaAEnemyCharacter::DefaultGrantedAbilities. - [x]
TSoftClassPtr<UBehaviorTree> BehaviorTree— field lands here, brain lands in Phase 3. - [x]
GetPrimaryAssetId()returnsFPrimaryAssetId("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 fromTSoftClassPtr<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(defaultAdditive). - [x]
UEnemyRegistry— lazyUGameInstanceSubsystemat Source/CRADL/Enemy/EnemyRegistry.h +.cpp. Same shape asUSpellRegistry/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] CacheActiveDefinition(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 overVariants; cacheActiveVariantIndex(replicatedint8, rep notifyOnRep_ActiveVariantIndex).-1sentinel means "no variant rolled." 3. [x] Cosmetic apply (server triggers; clients run via rep notify): async-load the variant'sMesh/AnimClassviaFStreamableManager::RequestAsyncLoad; on completion, swap onACharacter::Mesh. Weapon attachment lives in the equipped row's authoring (out of scope for Phase 2). 4. [x] Equipment stamp:Reshapethe pawn'sUEquipmentComponentto MainHand (+ Ammo when the variant carriesAmmoItemId),ApplySnapshotwith the variant's items. Ammo treated as infinite (count = 9999) — server has no per-shot consume for enemies. 5. [x] Stat-tuning roll: for eachFEnemyStatTuning, roll a single value in[Min, Max](server-sideFRandomStreamseeded from(GetUniqueID() ^ world time-ms)at v1 — reproducibility is an [open question]). Build a transientUGameplayEffectof classUGE_Enemy_InnateStatsper spawn, program one modifier per tuning entry, apply once. HonorsEGameplayModOp::{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: ifVariant.VariantStatsEffectis set, apply that as a separate Infinite spec — composes with the tuning GE through standard GAS aggregation. 7. [x] Grant abilities: every entry inDefinition->GrantedAbilities→ASC->GiveAbility(...); falls back toDefaultGrantedAbilities(UEnemyAutoAttackAbility) if the definition list is empty. 8. [x] MaxAttackers wiring: cachesDefinition->MaxAttackersinto the per-pawnMaxAttackersfield (0 = settings default). 9. [x] Replication ofActiveDefinition+ActiveVariantIndexis load-bearing for cosmetics; remote clients re-run the cosmetic apply inOnRep_ActiveVariantIndex. - [x] Editor field on pawn:
TSoftObjectPtr<UEnemyDefinition> EditorDefinition—EditAnywherefield onAEnemyCharacter. OnBeginPlay(server), if set, callsApplyDefinition(EditorDefinition.LoadSynchronous(), 0); otherwise falls through toApplyManualFallback(Phase-1-style HP=30 + DefaultGrantedAbilities for cheat-driven testing). Phase 8's spawner will callApplyDefinitionfrom its deferred-spawn finalizer instead. - [x] Remove Phase-1
PlaceholderMaxHealth/PlaceholderAttackLevel/PlaceholderDefenseLevel/PlaceholderDefenseBonusByType.MaxHealthbase =HitpointsLevel × UCradlCombatSettings::EnemyMaxHealthPerHitpointsLevel(default 1, OSRS-faithful; enemy analog of the player's ×4), written inApplyDefinition, with StatTuning / GE application layering ± variance on top; combat skill levels read from the definition's authored fields; defense matrix reads fromUEquipmentComponent::SumDefenseBonus. (Superseded: originally "stat tuning drives MaxHealth" + a validator rule requiring a MaxHealth StatTuning entry — the base now comes fromHitpointsLevel, so that entry is optional ± variance and the validator no longer requires it.) - [x]
IInteractable::GetSourceLabel— routes throughActiveDefinition->DisplayName, withVariants[ActiveVariantIndex].DisplayNameas override when non-empty. Generic "Enemy" fallback only when no definition is wired. - [x]
UCradlEnemyDefinitionValidator— Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.h +.cpp, inheritingUEditorValidatorBase: - [x]
DisplayNamenon-empty. - [x]
FamilyTagunderEnemy.Family.*(MatchesTagparent check). - [x] At least one entry in
Variants; every variant'sWeaponItemIdresolves throughCradlValidationHelpers::CollectKnownItemIds;AmmoItemIdresolves if non-None;Mesh/AnimClasssoft refs resolve. - [x] Every
FEnemyStatTuning::Attributeresolves onUCradlAttributeSet;Min <= Max;Op == Additive || Op == Multiplicative. - [x] ~~At least one
StatTuningentry targetsUCradlAttributeSet::GetMaxHealthAttribute().~~ (Superseded: rule removed — baseMaxHealthnow derives fromHitpointsLevel × EnemyMaxHealthPerHitpointsLevel(default 1), so a MaxHealth tuning entry is optional ± variance.) - [x]
DropTablesoft-ref resolves (when set; null is allowed pre-Phase 7). - [x]
BehaviorTreesoft-class resolves (when set). - [x] Combat skill levels >= 1.
- [x] Pre-req fix in
UEquipmentComponent::GetOwnerASC: route ASC lookup throughIAbilitySystemInterfaceon the owner rather thanGetOwner<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+DropTableDefinitioninConfig/DefaultGame.ini'sPrimaryAssetTypesToScan. Both point at/Game/Definitions/Enemiesand/Game/Definitions/DropTablesdirectories respectively. - [x] Cheat command —
ACradlPlayerController::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 ofDebug_RollSimfrom 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.
BeginPlayserver-side →ApplyDefinitionruns → on first peer,ActiveVariantIndexreplicates → 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 honorsDefinition->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].WeaponItemIdto 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 fromHitpointsLevel.) - 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
PostInitPropertiesor constructor hacks. The CDO is shared across every spawn; mutating it bleeds across instances. Per ENEMY_SYSTEM.md "Pawn Shape" footgun. ApplyDefinitionruns server-only before the BT starts. Phase 3's controllerOnPossessreads the blackboard from the already-populated pawn — but the order must beSpawnDeferred → ApplyDefinition → FinishSpawning → AIController::OnPossess(which fires insideFinishSpawning). For manual editor placement at Phase 2,BeginPlayis fine since no controller possesses yet; Phase 3 wires the deferred-spawn shape into the spawner (Phase 8) and the editor-placement path runsApplyDefinitionfromPossessedByinstead 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
AttributeBaseValuebypasses 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 (anFScalableFloaton a runtime-appendedFGameplayModifierInfo), 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.
StatTuningandVariant.VariantStatsEffectcompose, 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.AAIControllersubclass, implementsIMoveIssuer: - [x]
IMoveIssuer::IssueMoveTo(FVector)→UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, Loc). (Note: theIssueMoveTo(AActor*)overload doesn't exist on the interface — the doc was imprecise; chase uses the FVector overload viaUChaseComponent.) - [x]
IMoveIssuer::StopMove()→AAIController::StopMovement(). - [x]
UBehaviorTreeComponent+UBlackboardComponentconstructed in ctor. - [x] BT-start is pushed from the pawn's
FinalizeBrainSetupafterApplyDefinitionpopulates state —OnPossessleft as a no-op to avoid the race where possession fires before the definition lands. - [x]
AEnemyCharacter::AIControllerClass = AEnemyAIControllerin ctor.AutoPossessAI = PlacedInWorldOrSpawnedso 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.Engage→Wait For Engagement End(clearsActiveTargeton natural end)] || [Idle Wait 1.0s]. - [x]
BTTask_FireCombatEngage— new Source/CRADL/Enemy/BT/BTTask_FireCombatEngage.h +.cpp. Builds anFGameplayEventDatawithEventTag = Action.Trigger.Combat.Engage,Target = ActiveTarget(BB read),Instigator = ControlledPawn, and callsASC->HandleGameplayEvent. ReturnsSucceededif any ability triggered,Failedotherwise. - [x]
BTTask_WaitForEngagementEnd— new Source/CRADL/Enemy/BT/BTTask_WaitForEngagementEnd.h +.cpp. Latent; subscribes toAction.Combat.AutoAttacktag-change on the pawn's ASC; succeeds when count → 0. Clears configured BB key (defaultActiveTarget) on natural end to break the dead-target re-fire loop. - [x] Retaliation event hookup —
AEnemyCharacter::BeginPlay(server) binds to its ASC'sCombat.Event.Hurtevent (new victim-side cousin ofCombat.Event.Hit; the doc said "Hit" but Hit fires on the attacker's ASC — seeUCradlAutoAttackAbilityBase::HandleSwingFinished). On fire: readsInstigatorfrom the payload, resolves PlayerState→Pawn, filters self/dead, writes to BBActiveTarget. Source of truth for "who struck me" is always the fresh event — no last-striker cache. - [x]
Combat.Event.Hurttag — 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'sFireReplicatedGameplayEventhelper 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);
WaitForEngagementEndobserves the tag clear and the BT falls back to idle. No explicitCancelAbilitiesfrom 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::BehaviorTreecorrected fromTSoftClassPtr→TSoftObjectPtr. BT assets are UObject instances, not classes; the original picker would have shown nothing. - [x] Cheat command extension —
Debug_DamageNearestEnemy(int32 Amount, FName TypeLeaf = NAME_None)onACradlPlayerControllerapplies aUGE_Combat_Damagespec 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.Hitfires on first damage → enemy writes player toActiveTarget, firesAction.Trigger.Combat.Engage, starts swinging back. Two-way combat. - Player walks out of melee range → enemy
IPawnCombatant::BeginChasecloses distance viaUChaseComponent+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_WithinLeashaborts the engage branch when the enemy drifts pastLeashRadiusCmfrom 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.Hitpayload, not the previousActiveTarget. 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
UCradlCombatMathand are reached throughUAutoAttackAbility::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
IMoveIssuerwith a directMoveToActorfrom a BT task. The pawn'sUChaseComponentlooks the controller up throughIMoveIssuerfor chase moves; callingSimpleMoveToActorfrom the task bypasses chase rate-limiting. The dedicatedBTTask_FireCombatEngagetriggers the ability; the ability handles its own chase throughIPawnCombatant. - 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
Instigatorfrom the damage event payload, not fromActiveTarget. A multi-attacker enemy re-targets to the most recent striker, not to whichever player happened to be the currentActiveTargetwhen the new hit landed. Action.Combat.AutoAttackis the wait-while-engaged signal, notStatus.InCombat. The latter persists 20s post-fight (its whole purpose); the former clears atEndAbility. 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 toResolveCombatContext; same module-side asResolveEngagementRangeForPawn'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_EnemyPerception— Source/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 whenbRequireLOS→ write nearest survivor (or null) to BBActiveTarget. Service short-circuits whenHostility != Aggressive. - [x]
UBTDecorator_ShouldAutoEngage— Source/CRADL/Enemy/BT/BTDecorator_ShouldAutoEngage.h +.cpp. Gates on Hostility=Aggressive + ActiveTarget set + target alive + CombatLevel < LevelGate.FlowAbortMode = EBTFlowAbortMode::LowerPriorityby 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 Perceptionattaches to the root Selector, not the gated perception Sequence. The decorator on the perception Sequence gates entry onActiveTargetbeing 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.AutoAttackon the pawn's ASC (seeBTService_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-Engage→Fire Combat.Engage→Wait For Engagement End. - Retaliation: unchanged from Phase 3 (Blackboard
ActiveTarget Is Setdecorator + 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
WaitForEngagementEndaborts andActiveTargetis cleared on its abort path (auto-attack ability ends on the disengage; no explicitCancelAbilitiesfrom the BT for Phase 4 — Phase 5's leash decorator adds that bigger hammer). - [x] Phase 2 fields consumed end-to-end —
AggroRadiusCm,LevelGate,bRequireLOS,Hostilityall routed through the new service/decorator. - [x] Cheat command —
Debug_PrintCombatLevelprintsCL + (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_TESTwithHostility = Passive,AggroRadiusCm = 0. Place. Walk past → cow does nothing. Strike the cow → cow retaliates. Debug_PrintCombatLevelat 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.
LevelGateis 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.
UAIPerceptionComponentis deliberately left out of v1. The hand-rolled sphere-overlap + LOS service matches the OSRS "see things in a radius" model with less ceremony. AddingUAIPerceptionComponentlater 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 onUEnemyDefinition.
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 inApplyDefinition/ApplyManualFallbackviaInitializePatrolAndLeashState(). 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.EffectiveLeashRadiusCmreadsDefinition->LeashRadiusCmtoday; the Phase-8 spawner override layers in by writing the field directly afterApplyDefinition. - [x]
BTTask_PatrolStationary— Source/CRADL/Enemy/BT/BTTask_PatrolStationary.h +.cpp. Synchronous-succeed when already at anchor (the common case); else MoveTo anchor viaIMoveIssuer::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_PatrolRandom— Source/CRADL/Enemy/BT/BTTask_PatrolRandom.h +.cpp. Nav-projected pick viaUNavigationSystemV1::GetRandomReachablePointInRadius(anchor-centered so the return-to-home effect falls out — a kited pawn's next pick is inside the radius). Falls through toDefaultPatrolRadiusCmwhen 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_PatrolSpline— Source/CRADL/Enemy/BT/BTTask_PatrolSpline.h +.cpp. Phase 5 stub per the user's decision: whenPatrolSplineRefis null (always the case in v5 since no spawner exists) the task falls back to stationary-at-anchor with aVerboselog. 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-pawnSplineDistance, ping-pong/loop, clamped anchor-update) lands with the spawner in Phase 8. - [x] Spline reference plumbing —
AEnemyCharacter::SetPatrolSplineRef(USplineComponent*)is the public hook the Phase-8 spawner will call post-ApplyDefinitionto wire itsPatrolSpline.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 becauseTWeakObjectPtr<USplineComponent> = T*requires the full type. Manual-placement enemies withPatrolMode = WaypointLoopand no spawner fall back to stationary-at-anchor; the validator now warns on the authoring case. - [x]
BTDecorator_WithinLeash— Source/CRADL/Enemy/BT/BTDecorator_WithinLeash.h +.cpp. ReturnsfalsewhenDistSqXY(pawn, AnchorLocation) > EffectiveLeashRadiusCm². Sentinel:EffectiveLeashRadiusCm <= 0short-circuits totrue(no leash).FlowAbortMode = Bothso a mid-engagement leash break aborts the engage branch immediately. Tick-drivenConditionalFlowAbort(ConditionResultChanged)re-checks every 0.25s (configurable) — the engine doesn't observe pawn-transform changes, so the explicit re-check is required. - [x]
BTTask_LeashReset— Source/CRADL/Enemy/BT/BTTask_LeashReset.h +.cpp. Latent retreat-and-reset task: drives the pawn back toAnchorLocation, then heals to full and clearsUGE_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.Leashingabsent → instant Succeeded no-op (the common BT iteration);Health <= 0→ Failed (death pipeline owns the corpse window); otherwise issuesIssueMoveTo(AnchorLocation)and ticks until arrival withinArrivalToleranceCm(default 50cm, tight — ~character capsule width) orMaxWalkSeconds(default 20s watchdog) elapses. On arrival:Enemy->ApplyAnchorReset()(heal-to-full viaUGE_Enemy_HealToFull, clearUEnemyDropComponentattribution, removeUGE_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). ArrivalToleranceCmandMaxWalkSecondsare 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 ontoUEnemyDefinitionis 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 ofBTTask_LeashReset.h. - [x]
UGE_Enemy_HealToFullfinalize — Source/CRADL/Combat/CombatGameplayEffects.cpp. Instant duration, single modifier onHealth,ModifierOp = Override, magnitude resolved viaFAttributeBasedFloatcapturingMaxHealthfrom Target withbSnapshot = false(so applying the CDO reads the live MaxHealth at execute time, not module-init time). Override bypassesUCradlAttributeSet::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_Defaultadds: - Engage subtree wrapped in
BTDecorator_WithinLeash(engage aborts when out of leash;WaitForEngagementEnd::AbortTasknow owns theCancelAbilities + EndChaseteardown 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 extension — Source/CRADLEditor/Validators/CradlEnemyDefinitionValidator.cpp:
LeashRadiusCm >= 0is enforced viameta=(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_Leashingapplies;BTTask_LeashResetdrives the goblin all the way back to itsAnchorLocation. On arrival (withinArrivalToleranceCm, 50cm default), HP snaps to MaxHealth andStatus.Leashingclears (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 = 0on a "boss" placeholder → the decorator short-circuits to true; kite the boss across the world → it pursues indefinitely (intended —0means "no leash, boss/kiting-ranged archetype"). The only way to end pursuit on aLeashRadiusCm = 0enemy 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
DamageDealtmap (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
Overridemodifier, not additive damage. WritingHealth = MaxHealthbypasses theDamagemeta-attribute path, so the leash heal doesn't fire heal cues, doesn't refreshStatus.InCombat, doesn't grant Hitpoints XP. Intentional — leash-heal is a state reset. LeashRadiusCm = 0is a sentinel for "no leash," not "leash at zero distance." A decorator that reads 0 and computesdistance > 0would 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, notIPawnCombatant::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_BroadcastGroupAggro— Source/CRADL/Enemy/BT/BTTask_BroadcastGroupAggro.h +.cpp. Authored to run as a sibling step on the perception-driven engage sequence, immediately afterBTTask_FireCombatEngage(BT graph wiring is editor-side). Behavior: - Single-hop guard: if own
BBKey_AggroBroadcastReceivedis true, return Succeeded immediately (don't re-broadcast). The flag itself is cleared by the receiver'sBTService_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 asBTService_EnemyPerception). - Filters:
AEnemyCharactercast, exclude self, exclude dead (Status.Deadtag check), neighbor'sActiveDefinition->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 = truedirectly throughUBlackboardComponent::SetValueAsObject/SetValueAsBool. The decorator's LowerPriority value-change observer picks up theActiveTargetwrite 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_ShouldAutoEngagerejects 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 whenBBKey_ActiveTargetis set + Aggressive + alive + LevelGate-pass. The broadcast pathway works throughActiveTarget— 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
bAggroBroadcastReceivedafter engaging (or rejecting per Hostility/LevelGate)". Putting the consume in the decorator is impure (decorators are predicates); putting it inBTTask_FireCombatEngagedoesn't fire for rejected-engage receivers (level-faded / Passive) and would leak the flag forever, permanently pausing perception. Corrected:BTService_EnemyPerceptionis the consumer. On its tick, ifBBKey_AggroBroadcastReceivedis true, the service (1) clears the flag and (2) skips its ownActiveTargetwrite 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!Aggressivewould 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_BroadcastGroupAggroon 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 extension —
GroupAggroRadiusCmis alreadymeta=(ClampMin="0.0")on the UPROPERTY (compile-time enforced ≥ 0). AddedAssetWarningin CradlEnemyDefinitionValidator.cpp forGroupAggroRadiusCm > 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
bAggroBroadcastReceivedand 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.Wizardneighbor 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
TryRegisterAttackerfails 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
MaxAttackersresults in the receiver's authoritativeTryRegisterAttackerfailing 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.Wizardbroadcast does not pull inEnemy.Family.Goblin.Warriorneighbors. 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_BroadcastGroupAggrobeing 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()returnsFPrimaryAssetId("DropTableDefinition", GetFName()). - [x]
FDropTableEntry— inline struct in the same header: - [x]
FName ItemId(resolves throughUItemRegistry::FindItem; empty inWeightedDrops= "no drop" slot per OSRS convention). - [x]
int32 Weight = 1(ignored inAlwaysDrops). - [x]
int32 MinCount = 1,int32 MaxCount = 1. - [x]
TArray<FSkillRequirement> SkillRequirements— mirror ofFItemDrop::SkillRequirementsonUGatheringNodeDefinition. - [x]
UDropTableRegistry— lazyUGameInstanceSubsystemat Source/CRADL/Loot/DropTableRegistry.h +.cpp. Same shape asUEnemyRegistry.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¶meter instead of an explicitTSet<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 insideRollTableRecursive(anonymous-namespace helper in the .cpp). - Walks
AlwaysDrops→ rollsMinCount..MaxCountper entry, skipping empty-ItemId rows (warned at validation). - Weighted pick from
WeightedDropsfiltered bySkillRequirementsagainstAttribution.Tagger'sUSkillsComponent. EmptyItemIdon chosen entry → no output (the "no drop" slot). - Recurses into each
SubTablesindependently; 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.UActorComponentonAEnemyCharacter. 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 toLastStriker. - [x]
void Reset()— empties the map andLastStriker. Called fromBTTask_LeashReseton heal-to-anchor. - [x]
void GetDebugSnapshot(...)— non-shipping, declaration guarded by#if !UE_BUILD_SHIPPING(not aUFUNCTION, so the CLAUDE.md rule on header guards doesn't apply); produces a sorted (desc by damage) snapshot for theDebug_PrintEnemyDropComponentcheat. - [x] Damage hook — deviation from original sketch. The Phase 7 doc's original plan was a branch in
UCradlAttributeSet::PostGameplayEffectExecutethat resolved the victim →AEnemyCharacterand called RegisterDamage. That sketch predates Phase 3's introduction ofCombat.Event.Hurtand theAEnemyCharacter::HandleHurtEventserver-side subscriber. The corrected hook lands insideHandleHurtEvent(which already runs server-only, already extractsPayload->Instigator, and already getsPayload->EventMagnitude = DamageDeltafor free). Recording attribution alongside the existing retaliationSetRetaliationTargetwrite means no Combat→Enemy include reversal inUCradlAttributeSet, no new interface, noFindComponentByClasscross-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]
AEnemyCharacterintegration —UEnemyDropComponent* DropComponentUPROPERTY constructed as a default subobject in the ctor; publicGetDropComponent()accessor;HandleHurtEventrecords damage viaDropComponent->RegisterDamage(AttackerPS, Payload->EventMagnitude)immediately after PS resolution. - [x] Phase 5 leash-reset call site populated —
BTTask_LeashReset.cppnow callsEnemy->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. MirrorsUCradlEnemyDefinitionValidatorshape: - [x] Every
FName ItemIdresolves throughUItemRegistry(empty ItemId inWeightedDropsvalid — the OSRS no-drop slot; empty inAlwaysDropsfails as dead authoring). - [x]
MinCount <= MaxCountenforced; per-array (AlwaysDrops + WeightedDrops). - [x]
Weight = 0warned in WeightedDrops (no-op entry);Weight != 1warned in AlwaysDrops (Weight is ignored there — usually means the author meant to put the entry in WeightedDrops). - [x] All
SubTablessoft-refs resolve throughIAssetRegistry::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 viaUDropTableRegistry, builds a syntheticFDropAttribution { Tagger = local PS }, runsNrolls with one reusedFRandomStream, prints aggregate per-ItemId counts + per-roll averages + no-output count. Mirror ofDebug_RollEnemyStats's output shape. - [x]
Debug_PrintEnemyDropComponent()— finds nearestAEnemyCharacterviaTActorIterator, 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_PrintEnemyDropComponentagainst 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;
ResolveAttributionreturns the higher. - Tie test: scripted equal damage from two players → tagger =
LastStriker. - Validator: introduce
DT_A.SubTables = [DT_B]andDT_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
bIsNoDropflag. 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_Ais finite-but-explosive recursion. Caught at validation; the runtimeVisitedset 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. UEnemyDropComponentdoes 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 toFUniqueNetId"to remember disconnected players" — that re-opens the grief pattern.
Phase 8 — Enemy Death, Ground Drops, Spawners
Goal. Enemy reaching Health = 0 fires UEnemyDeathAbility → Status.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]
UEnemyDeathAbility— Source/CRADL/Enemy/EnemyDeathAbility.h +.cpp: - [x]
UCradlGameplayAbilitysubclass,NetExecutionPolicy = ServerOnly,InstancingPolicy = InstancedPerActor. - [x] Trigger:
Combat.Event.DeathviaFAbilityTriggerData{ 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::PostGameplayEffectExecutefiresCombat.Event.Deathwhenever 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(afterInitAbilityActorInfo, before any path-specific branching) with aFindAbilitySpecFromClassdedupe so a designer who lists it inDefinition->GrantedAbilitiesdoesn't produce two specs. Mirror ofACradlPlayerState's C++ grant ofUDeathAbility. - [x] On activate (server):
- Apply
UGE_Status_Deadto self viaApplyGameplayEffectToSelf(GetDefault<UGE_Status_Dead>(), ...). Status.Dead is inUCradlAutoAttackAbilityBase::CancelOnTagsAdded, so every attacker's swing tears down before the corpse window ends. Attribution = Enemy->GetDropComponent()->ResolveAttribution().- Sync-load
Def->DropTable.LoadSynchronous()— drop tables are small. - 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). - For each
FRolledItem: spawnAGroundItemat corpse + 50cm XY scatter,SetPayload, andSetOwnership(TaggerPS, DropOwnershipSeconds)if the tagger is valid. Null tagger falls through with no gate — free-for-all from spawn. Enemy->GetOwningSpawner()->NotifyDeath()— schedules the spawner's respawn timer. Editor-placed enemies (no spawner) skip this step.- Run
UAbilityTask_WaitDelay::WaitDelay(this, EnemyCorpseSeconds); bindOnFinish → OnCorpseWindowFinished(UFUNCTION on the ability — multicast delegate signature requires it). OnCorpseWindowFinished:Avatar->Destroy();EndAbility(... bWasCancelled=false).
- Apply
- [x]
AGroundItemextension — addedOwnedBy: TObjectPtr<ACradlPlayerState>+bHasOwnershipGate: bool, bothReplicated. Server-sideSetOwnership(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
OwnershipExpiryTimerweak-lambda that clears the gate on expiry. CanInteractreads the gate via the invoker's PS. Disconnect mid-window:OwnedByclears (PS destroyed),bHasOwnershipGatestays 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
SetOwnershipwhen the tagger is null. - [x]
AEnemySpawner— Source/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, embeddedUSplineComponent* PatrolSplinedefault 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:
ApplyDefinitioncallsGiveAbility+ApplyGameplayEffectToSelf, which need the ASC's ActorInfo populated byInitAbilityActorInfo. Init runs inBeginPlay, which doesn't fire untilFinishSpawning. The corrected sequence is:SpawnActorDeferred<AEnemyCharacter>at randomized SpawnTransform.Pawn->SetOwningSpawner(this)BEFORE FinishSpawning soAEnemyCharacter::BeginPlay's spawner-path early-out sees it and skipsApplyManualFallback(no fallback HP=30 to fight over).FinishSpawning: firesBeginPlay→InitAbilityActorInfo→ unconditionalUEnemyDeathAbilitygrant → spawner-path early-out → no fallback ran.Pawn->ApplyDefinition(Def, FMath::Rand()): now the ASC is initialized, so the per-spawn innate-stats GE applies, equipment stamps, abilities grant.InitializePatrolAndLeashStateat its tail captures the post-spawn anchor.- Post-
ApplyDefinitionstamps:SetPatrolSplineRef(independent of init order) andSetEffectiveLeashRadiusCm(LeashRadiusOverrideCm)(set AFTER ApplyDefinition so the override wins over the definition's value).
- [x]
NotifyDeath(): decrementsLiveCount; sets a per-slotFTimerHandleweak-lambda forRespawnSeconds(clamped toKINDA_SMALL_NUMBERso 0 fires next tick). Independent per-slot timers — multiple deaths schedule independent respawns. - [x]
EndPlayclears all timers viaWorld->GetTimerManager().ClearAllTimersForObject(this)so a spawner destroyed during a respawn delay doesn't leak timer entries. - [x] Editor visualization:
WITH_EDITORONLY_DATAUBillboardComponentwith the engine'sS_NavLinksprite (resilientConstructorHelpers::FObjectFinderOptional). - [x]
AEnemyCharacter::OwningSpawner—TWeakObjectPtr<AEnemySpawner>on the pawn withGet/SetOwningSpawneraccessors. Weak so spawner destruction doesn't pin the pawn;NotifyDeathbecomes a no-op against a stale weak ref. - [x]
AEnemyCharacter::SetEffectiveLeashRadiusCm— public setter exposed for the spawner's leash override. Called AFTERApplyDefinitionso it wins over the definition'sLeashRadiusCm. - [x]
AEnemyCharacter::BeginPlayrestructure — three-way branch: 1.OwningSpawner.IsValid()→ return (spawner-path; spawner will callApplyDefinitionnext). 2.!EditorDefinition.IsNull()→ resolve throughUEnemyRegistry, callApplyDefinition(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 →
UEnemyDeathAbilityfires; 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 fromAGroundItem. 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).
MaxAlivecap: if the test map has a placedAEnemyCharacteralready (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 = 600on 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
AEnemyCharacterdirectly (no spawner) withEditorDefinition = DA_Enemy_BossDragon(assume the definition hasLeashRadiusCm = 0). Kill it → corpse + drops spawn, no respawn (no spawner). Pawn destroyed after corpse window. - Spline patrol: set the spawner's
PatrolSplineto a placedUSplineComponentand the definition'sPatrolMode = 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; usingEndPlaywould also fire on map-unload / level-streaming, scheduling phantom respawns. The spawner'sNotifyDeathis called from the ability, exactly once per kill. Per ENEMY_SYSTEM.md "Death" footgun. - Don't extend
UDeathAbilityto handle the enemy case. The combat contract specifiesUDeathAbilityfor the player (cold storage, respawn-self). Branching onIsA<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. AddingbReplicates = trueinflates bandwidth for no consumer. - A spawner whose
EnemyDefinitionfails 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
DropTableat 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 (theUEnemyRegistry::EnsureBuilt's walk could warmDropTablesoft-refs); v1 doesn't pre-optimize. MaxAlive > 1with sharedPatrolSplineis 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 variants —
Enemy.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 overrides —
MaxAttackersper 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
UCastSpellAbilityintoGrantedAbilitiesand the BT's engage task would dispatchAction.Trigger.Combat.Castinstead ofEngage. The plumbing supports it; no v1 archetype demands it. UAIPerceptionComponentmigration — v1 ships the hand-rolled sphere-overlap service. Migrating to Epic's perception component is additive (the sameBTDecorator_ShouldAutoEngagewould 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:
- Spawn the test map with one
AEnemySpawnerof an Aggressive Goblin definition (MaxAlive = 2,RespawnSeconds = 30). - Walk into the spawner's aggro radius → both goblins detect (one initially, the other via group-aggro broadcast if
GroupAggroRadiusCm > 0is set on the definition) → both engage and chase. - Kill the first goblin → corpse + bones + (maybe) coins land at the corpse position; tagger-only ownership window starts; second goblin keeps swinging.
- Kite the second goblin past the leash radius → it disengages, walks back, heals to full on arrival at anchor.
- Re-engage the second goblin → it retaliates; kill it → drops; spawner schedules respawn for both slots.
- Wait 30s → spawner respawns 2 fresh goblins with fresh rolled variants and rolled stats; the cycle restarts.
- 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.