0/0
CRADL // DOCUMENTATION
PORTAL DEV WIKI COMBAT_IMPLEMENTATION
UTC 00:00:00
◀ RETURN
COMBAT_IMPLEMENTATION.md 18247 words ~83 min read Updated 2026-07-03

CRADL Combat Implementation

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

Combat is greenfield — no combat code exists yet. Every phase below is from-zero; all forward references in the contract doc land 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 UCradlDebugComponent (the controller's dev-only console host) exec functions guarded by #if !UE_BUILD_SHIPPING. Per CLAUDE.md, declarations are unconditional; only the body is guarded.
  • Per CLAUDE.md "validators in lockstep": any phase that touches FItemRow, USkillDefinition, 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.

Phase tracking

Phase Title Status Unblocks
0 Tag & data scaffolding [x] All later phases
1 Attribute set & damage pipeline [x] 2, 5
2 Death & cold storage [x] (closes the HP=0 loop)
3 Combat math library [x] 5, 8 (parallel-able with 1/2)
4 Equipment combat data & ammo slot [x] 5
5 Auto-attack, targeting, styles, target dummy [x] 6, 8
6 Engagement entry (click-to-attack) [x] (closes the player-input loop)
6.5 Phase 6 follow-ups (debug HUD + chase primitive) [x] (developer-facing + click-on-mobile-target correctness; pawn-side chase primitive shared with mid-engagement chase and future AI)
7 Animation/FX polish pass [ ] (cosmetic; threads through 5/8)
8 Spellcasting & spellbook [x] (additive on top of 5; back-end landed, content + UI deferred)
9 Stat pipeline migration [x] (refactor — unblocks future buffs/potions/status-effect content composing through GAS)
10 Style → swing-axis mapping (+ DefaultStyle on equip) [~] code + validator + docs landed; awaiting DataTable authoring pass + style-selector subtitle UI

Phase 0 — Tag & Data Scaffolding

Goal. All combat tags declared, FItemRow extended, settings extended, the 5 combat skill assets authored. No behavior yet. This phase produces a compiling, no-op-extended codebase that unblocks every later phase.

Rationale. Phases 1+ all reference these tags / fields / assets. Landing them in a single low-risk PR avoids cross-phase merge churn and gives editor authoring a stable target.

Tasks.

  • [x] Combat tags — Source/CRADL/CradlGameplayTags.{h,cpp} + Config/DefaultGameplayTags.ini:
  • [x] Combat.DamageType.{Stab, Slash, Crush, Ranged, Magic} (declared in C++; referenced by combat math + GE magnitude keys)
  • [x] Combat.Style.{Aggressive, Defensive} (C++; auto-attack reads at swing-start)
  • [x] Combat.Event.{EnterCombat, ExitCombat, Death, Hit, Miss} (C++; FireReplicatedGameplayEvent payloads)
  • [x] GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic} + GameplayCue.Combat.Death (C++; cue notify lookup. Lives under GameplayCue.* so UGameplayCueManager indexes the subtree — cues outside it silently no-op at runtime.)
  • [x] Combat.Spec.* (reserved — .ini parent only, no C++ decl, no leaves yet)
  • [x] Action tags — same files:
  • [x] Action.Trigger.Combat.{Engage, Cast, AutocastSet, Disengage} (C++; HandleGameplayEvent triggers)
  • [x] Action.Combat.{AutoAttack, CastSpell, Death} (C++; ActivationOwnedTags)
  • [x] Status additions:
  • [x] Status.Dead (C++ + .ini; Stunned and InCombat already declared in CradlGameplayTags.h)
  • [x] Item slot:
  • [x] Item.Slot.Ammo — declare in C++ (auto-attack queries the ammo slot by name); add to .ini. First Item.Slot.* to land in C++. Established convention: only declare slots that code references by name; other slots stay data-only.
  • [x] Skill tags:
  • [x] Skill.Combat.{Melee, Ranged, Magic, Defense, Hitpoints} (.ini only — USkillDefinition references skills via FPrimaryAssetId, not tag.)
  • [x] FItemRow extension (Source/CRADL/Inventory/ItemRow.h):
  • [x] TMap<FGameplayTag, int32> AttackBonus (keyed by Combat.DamageType.*)
  • [x] TMap<FGameplayTag, int32> DefenseBonus (same)
  • [x] int32 StrengthBonus
  • [x] int32 RangedStrengthBonus
  • [x] float MagicDamageBonus (percent)
  • [x] FGameplayTag WeaponDamageType (Combat.DamageType.* — empty for non-weapons)
  • [x] float SwingInterval (weapons only)
  • (Skill-level equip gates: reuse existing FEquipRequirements::Skills (TArray<FSkillRequirement> already keys tag → level). No parallel RequiredSkillLevels field.)
  • [x] FGameplayTag DefaultStyle (initial Combat.Style.*)
  • [x] Validator update in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp — checks weapon rows have both WeaponDamageType + SwingInterval, bonus map keys live under Combat.DamageType.*, DefaultStyle is a Combat.Style.* leaf if set.
  • [x] UCradlCombatSettings extension:
  • [x] int32 DeathItemRetention (default 3 — placeholder, tunable via Project Settings → CRADL → Combat → Death)
  • [x] FTransform DefaultRespawnTransform (placeholder identity transform; placed ACradlRespawnPoint in the level wins, this is the fallback)
  • (Originally landed on UCradlInventorySettings in Phase 0; moved to UCradlCombatSettings during Phase 2 — these are combat semantics, not inventory.)
  • [x] Skill assets — five USkillDefinition data assets authored at Content/Definitions/Skills/:
  • [x] DA_Skill_Combat_Melee.uasset
  • [x] DA_Skill_Combat_Ranged.uasset
  • [x] DA_Skill_Combat_Magic.uasset
  • [x] DA_Skill_Combat_Defense.uasset
  • [x] DA_Skill_Combat_Hitpoints.uasset (per memory: XpCurve is optional — left null, fallback algorithmic curve in USkillRegistry is the primary path)
  • [x] Hitpoints starting level: CRADL starts Hitpoints at 1 (default FSkillProgress::Level), departing from OSRS's level-10 start. No code change required; the decision lives in this doc.

Verification.

  • Compile clean (user-side).
  • Editor: open the items DataTable, edit a non-weapon row, save. Validator passes.
  • Editor: edit a weapon row with empty WeaponDamageType, save. Validator fails with clear message.
  • Editor: skill registry shows all 5 combat skills (any pre-existing skill UI lists them).

Exits. Phase 1 has all tags it needs; phase 4 has FItemRow fields to populate.


Phase 1 — Attribute Set & Damage Pipeline

Goal. Damage GEs apply to a pawn, HP drops, cues fire, in-combat tag flips, HP-XP grants. Validates the GAS plumbing without any combat-verb abilities. Tested via self-damage cheat command on the player.

Rationale. Damage flow is the spine of combat — if the meta-attribute consumption / clamp / cue / event chain is wrong, every later phase compounds the bug. Isolating it lets us land cheat-driven validation before any swing-loop complexity exists.

Tasks.

  • [x] UCradlAttributeSet extension (Source/CRADL/Abilities/CradlAttributeSet.h):
  • [x] Health (FGameplayAttributeData, replicated, OnRep_Health)
  • [x] MaxHealth (replicated, OnRep_MaxHealth)
  • [x] Damage (meta — not replicated; lifecycle is execute-and-zero per Lyra pattern)
  • [x] AttackSpeed (replicated, default 1.0, OnRep_AttackSpeed)
  • [x] ATTRIBUTE_ACCESSORS for all four
  • [x] Per-attribute OnRep_* impl with GAMEPLAYATTRIBUTE_REPNOTIFY
  • [x] PostGameplayEffectExecute extension (Source/CRADL/Abilities/CradlAttributeSet.cpp):
  • [x] If Data.EvaluatedData.Attribute == GetDamageAttribute():
    • [x] Pull meta value, set meta back to 0
    • [x] Health = clamp(Health - DamageDelta, 0, MaxHealth)
    • [x] If DamageDelta > 0 (real damage, not heal): fire GameplayCue.Combat.Hit.{Type} via ASC->ExecuteGameplayCue; refresh Status.InCombat GE on both attacker and victim; grant Hitpoints XP to attacker
    • [x] If Health == 0: fire Combat.Event.Death via FireReplicatedGameplayEvent (death ability triggers in phase 2)
  • [x] Footgun guard: never write Health directly outside this method. Comment-document the invariant.
  • [x] Status.InCombat GEUGE_Combat_InCombat C++ subclass at Source/CRADL/Combat/CombatGameplayEffects.h (in lieu of an authored asset — keeps GE shape source-controlled):
  • [x] Duration = 20s, StackingType = AggregateByTarget, StackLimitCount = 1, StackDurationRefreshPolicy = RefreshOnSuccessfulApplication
  • [x] Granted tag Status.InCombat via UTargetTagsGameplayEffectComponent (UE 5.3+ replacement for the deprecated InheritableOwnedTagsContainer)
  • [x] No period
  • [x] UCradlCombatSettingsUDeveloperSettings at Source/CRADL/Combat/CradlCombatSettings.h (Project Settings → CRADL → Combat):
  • [x] int32 MaxHealthPerHitpointsLevel = 4 (OSRS-ish multiplier)
  • [x] TSoftObjectPtr<UCurveFloat> MaxHealthCurve (optional override; level → MaxHealth, mirrors USkillDefinition::XpCurve — algorithmic fallback is the primary path)
  • [x] float HitpointsXpPerDamage = 1.0f (OSRS reference is ~1.33; start at 1.0)
  • [x] HP-XP grantPostGameplayEffectExecute looks up the attacker's USkillsComponent and grants floor(DamageDealt * HitpointsXpPerDamage) to Skill.Combat.Hitpoints. Skipped when attacker == victim (cheat self-damage shouldn't pollute Hitpoints XP).
  • [x] MaxHealth recompute hookACradlPlayerState::BeginPlay (authority) subscribes SkillsComponent->OnLevelUp filtered for Skill.Combat.Hitpoints and calls AttributeSet->SetMaxHealthForLevel(level), which reads UCradlCombatSettings (curve override or multiplier fallback) and writes the BaseValue directly so future buff GEs layer on top via aggregation. Initial recompute also runs at the end of BeginPlay after LoadPlayer so post-load Hitpoints level lands.
  • [x] Saved-Health pipeline — added beyond the original task list to close the quit-to-heal exploit. UCradlPlayerProfile::SavedHealth (sentinel -1 = no value) captured by SavePlayer and staged via UCradlAttributeSet::StageSavedHealth during LoadPlayer. After the MaxHealth recompute lands, ResolveStagedHealth clamps the staged value to [0, MaxHealth] (returning player) or fills to MaxHealth (first-time / pre-field saves).
  • [x] Disconnect / spawn-with-HP=0 contract — captured in COMBAT_SYSTEM.md "In-Combat State" Disconnect rule and queued as Phase 2 tasks (death pipeline owns the actual handler).
  • [x] Hit cue emissionPostGameplayEffectExecute fires GameplayCue.Combat.Hit.{Type} via ASC->ExecuteGameplayCue on the victim's ASC, with FGameplayCueParameters populated (Instigator, EffectCauser, Location, TargetAttachComponent, RawMagnitude=DamageDelta, AggregatedSourceTags carries the type tag). Type lookup walks the spec's SetByCallerTagMagnitudes for Combat.DamageType.*. The #if !UE_BUILD_SHIPPING on-screen log line in the same block confirms the emission fires correctly without a notify asset present.
  • [ ] Hit cue notify assetsdeferred to Phase 7. UGameplayCueNotify_Static (or BP) for GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic} and GameplayCue.Combat.Death. With no notify present, emission is silent on the FX side; phase 7 authors the actual VFX/SFX. Splitting emission (Phase 1, code) from notify authoring (Phase 7, content) keeps the gameplay pipeline verifiable independently of art iteration.
  • [x] Self-damage cheat commandACradlPlayerController::Debug_SelfDamage(int32 Amount, FName TypeName):
  • [x] Resolves Combat.DamageType.{TypeName}, builds outgoing spec via ASC->MakeOutgoingSpec(UGE_Combat_Damage::StaticClass(), ...), sets the SetByCaller magnitude keyed by the type tag, applies to self
  • [x] Declared UFUNCTION(Exec) unconditionally; body wrapped in #if !UE_BUILD_SHIPPING; cross-role correctness via Server_Cheat_ApplyDamage Server RPC

Verification.

  • Run game, attach console.
  • Cheat_SelfDamage 10 Slash → HP drops by 10; Status.InCombat tag appears (verify via showdebug abilitysystem or HUD); Slash hit cue fires; Hitpoints XP increments.
  • Cheat_SelfDamage -5 Slash (heal via negative damage) → HP rises by 5, clamped to MaxHealth; no XP grant; no in-combat refresh on heal-to-self.
  • Wait 20s after last damage → InCombat tag clears.
  • Level Hitpoints (cheat, if exists) → MaxHealth recomputes; current Health unchanged unless above new max.

Exits. Phase 2 has Combat.Event.Death firing on HP=0; phase 5 has the damage application path it'll target via auto-attack.

Footguns.

  • Health-direct-write — call out in code comment per COMBAT_SYSTEM.md "Health & Damage Application."
  • HP-XP grant happens only on successful damage > 0 (not on misses, not on the swing roll itself). Per contract.
  • Healing path: contract permits either negative-damage or a separate Healing meta. Decide here: starting with negative-damage to keep one cue/event channel; if cue fork becomes awkward, refactor to Healing meta and update contract.

Phase 2 — Death & Cold Storage

Goal. Reaching Health=0 fires UDeathAbility, transfers low-value items to cold storage, respawns the player. Tested via self-damage to 0.

Rationale. Closes the HP→0 loop before auto-attack lands. If death cleanup is wrong, every later kill in testing leaves bad state (orphaned items, stuck Status.Dead, etc.).

Tasks.

  • [x] UDeathColdStorageComponent — new file Source/CRADL/Inventory/DeathColdStorageComponent.h + .cpp:
  • [x] Subclass of UInventoryComponent per ARCH #11
  • [x] Default SlotCount = 50
  • [x] Created in ACradlPlayerState constructor; accessor GetDeathColdStorageComponent()
  • [x] Persisted via UCradlPlayerProfile::DeathColdStorageSlots (mirror of bag/bank/equipment snapshot pattern)
  • [x] UDeathAbility — new file Source/CRADL/Abilities/DeathAbility.h + .cpp:
  • [x] Inherits UCradlGameplayAbility
  • [x] NetExecutionPolicy = ServerOnly — chosen because all side effects are server-authoritative (inventory triage, cold-storage transfer, teleport, HP/InCombat reset). Prediction would add rollback complexity for no perceptible client benefit. Force-quit case is handled by routing the side effects through CradlCombat::RunDeathPipeline which the save subsystem can call directly without the GA roundtrip.
  • [x] Trigger: Combat.Event.Death (matches the one-shot replicated event the attribute set fires on HP=0)
  • [x] ActivationOwnedTags = Action.Combat.Death
  • [x] On activate (server): apply UGE_Status_Dead; call CradlCombat::RunDeathPipeline(PS) (the shared helper does snapshot/sort/split/transfer/teleport/HP+InCombat reset).
  • [x] On end: RemoveActiveEffectsWithGrantedTags(Status.Dead) strips the GE.
  • [x] Granted unconditionally from ACradlPlayerState::BeginPlay so Combat.Event.Death always has a listener — the BP CDO setup is optional. The C++ grant dedupes against DefaultAbilities, so designers may also list UDeathAbility in the CDO for visibility/tracking without producing two specs.
  • [x] UGE_Status_Dead — added to Source/CRADL/Combat/CombatGameplayEffects.h + .cpp:
  • [x] Infinite duration, granted tag Status.Dead, no period
  • [x] Same UTargetTagsGameplayEffectComponent named-default-subobject pattern as UGE_Combat_InCombat (UE 5.3+ replacement for the deprecated InheritableOwnedTagsContainer)
  • [x] Removed by UDeathAbility::EndAbility
  • [x] CradlCombat::RunDeathPipeline — new file Source/CRADL/Combat/CradlDeathPipeline.h + .cpp:
  • [x] Free server-only helper called from three sites: UDeathAbility::ActivateAbility (live death), ACradlPlayerState::EndPlay (clean-disconnect-while-in-combat), and the load-time recovery path triggered by bDiedWhileDisconnected / spawn-with-0-HP.
  • [x] Sort key: UStorePricingSubsystem::GetSellPrice per-unit (descending). FItemRow has no Value field today; sell price is the closest semantic match (OSRS uses GE/alch price for the same purpose). Items missing a price sort as 0 → drop first. Limitation: quest items / soulbound goods with no sell price would be lost first. Revisit by adding an explicit FItemRow::DeathValue if/when a non-tradeable-but-valuable item lands.
  • [x] Top-N (default 3 from UCradlCombatSettings::DeathItemRetention) stay in their slots; rest drain into UDeathColdStorageComponent via the standard CanAccept/AddItems/RemoveAtSlot triplet. Cold-storage overflow logs a warning and drops the residual (50-slot default makes this rare).
  • [x] State reset: SetHealth(MaxHealth) direct (not via Damage meta — system reset, not damage event); RemoveActiveEffectsWithGrantedTags(Status.InCombat) so the OOC clock starts clean; pawn TeleportTo(ACradlRespawnPoint::ResolveRespawnTransform(PS)).
  • [x] ACradlRespawnPoint — new file Source/CRADL/Combat/CradlRespawnPoint.h + .cpp:
  • [x] Placeable AActor with billboard (reuses the engine's S_Player sprite) so designers can drop one in a level and see it.
  • [x] Static ResolveRespawnTransform(WorldContext) — first instance found via TActorIterator wins; falls back to UCradlCombatSettings::DefaultRespawnTransform. Iterator cost is fine: death is a rare event. If multi-zone respawn (closest-to-player, tagged groups) ever lands, swap to a UWorldSubsystem registry — comment in the header flags the upgrade path.
  • [x] Cancellation interactionStatus.Dead added to CancelOnTagsAdded on:
  • [x] UGatherAbility, UCraftAbility, UCradlModalAbility — long-running abilities that should terminate on death.
  • [x] UQueuedInteractAbility — overrides its deliberate-empty stance for this one tag (death is unconditional; a queued walk-then-act on a corpse should not resume after respawn).
  • Instant-execute item abilities (Withdraw/Deposit/Pickup/Drop/Equip/Unequip/Move/Transact/Examine/LoadoutSwap) need no change — they fire and end same-tick.
  • [x] Disconnect-while-in-combat triggerACradlPlayerState::EndPlay checks Status.InCombat and runs CradlCombat::RunDeathPipeline(this) synchronously before SavePlayer. Per the user's "no time for server RTT on force-quit" guidance, the side effects live in the helper (not the GA) so the save path can call them without ASC roundtrip. The on-disk profile then reflects post-death state (items in cold storage, full HP, no InCombat).
  • [x] Disconnect-while-in-combat fallback (force quit)UCradlSaveSubsystem::SavePlayer also writes bDiedWhileDisconnected = ASC->HasMatchingGameplayTag(Status.InCombat). Clean disconnect path always writes false (the pipeline already cleared InCombat); autosaves during combat write true. UCradlSaveSubsystem::LoadPlayer stashes the flag onto a transient ACradlPlayerState::bPendingDisconnectDeath; BeginPlay consumes it after MaxHealth + StagedHealth resolve and fires Combat.Event.Death. Routes both live death and force-quit recovery through the same Combat.Event.Death → DeathAbility → pipeline path. Free-pass window is one autosave cycle (~60s) — acceptable cost vs. autosaving more aggressively in combat.
  • [x] Spawn-with-HP=0 triggerACradlPlayerState::BeginPlay, after ResolveStagedHealth(), fires Combat.Event.Death if Health <= 0 (or if the disconnect flag was set). Catches the case where the player hit 0 HP and the autosave fired before the live death event processed. After the death flow respawns at full HP, subsequent saves never persist HP=0 in normal play.
  • [x] Profile schemaUCradlPlayerProfile:
  • SaveVersion bumped 8 → 9 (additive only — older saves load with DeathColdStorageSlots = [] and bDiedWhileDisconnected = false, both safe defaults via tagged-property serialization).
  • TArray<FItemSlot> DeathColdStorageSlots — death cold-storage snapshot.
  • bool bDiedWhileDisconnected — autosave-during-combat flag.

Verification.

  • Cheat_SelfDamage 999 SlashCombat.Event.Death fires; player teleports to the placed ACradlRespawnPoint if any (else UCradlCombatSettings::DefaultRespawnTransform); HP back to MaxHealth; Status.Dead flickers on then clears (per the on-screen "Death: N slot(s) moved to cold storage" debug message).
  • Inventory before cheat: 5 items of varying sell-price. After death with DeathItemRetention=3: top-3 valuable items remain in their slots; bottom-2 found in DeathColdStorageComponent (verify via showdebug-style print or breakpoint on the component's Slots.Slots).
  • Cold storage persists across save/load (verify via save then reload — Profile->DeathColdStorageSlots round-trips through CaptureSnapshot/ApplySnapshot).
  • Disconnect-while-in-combat: take damage, exit (Esc/window-close) → reload → start at full HP, items in cold storage. Force-quit between autosaves to test the flag-on-load fallback (the on-disk profile from the most recent autosave drives the recovery).
  • Spawn-with-HP=0: cheat to 1 HP, save, then re-trigger the load (PIE restart) → if the autosave caught HP=0 mid-flight, BeginPlay fires Combat.Event.Death and the pipeline runs.

Open before testing.

  • UDeathAbility is granted unconditionally from ACradlPlayerState::BeginPlay (with dedupe against DefaultAbilities) so no BP CDO setup is needed; you may optionally list it in the CDO for tracking. Confirm by attaching showdebug abilitysystem after first BeginPlay — the spec list should include UDeathAbility exactly once.
  • Drop a BP_CradlRespawnPoint (or the C++ ACradlRespawnPoint directly) into the test map at the desired respawn location. Without one, the pipeline falls back to UCradlCombatSettings::DefaultRespawnTransform (identity by default — set via Project Settings → CRADL → Combat → Death if you'd rather configure project-wide).

Exits. HP=0 loop is closed; phase 5 can now end engagements with target death without orphaning state.

Footguns.

  • Status.Dead is the source of truth for "is this pawn dead right now" — UI subscribers bind to the tag, not the death event (event fires once; tag persists).
  • Forgetting to clear Status.InCombat on respawn would mean the player respawns with combat tag still active until 20s expiry — handled in RunDeathPipeline via RemoveActiveEffectsWithGrantedTags.
  • The pipeline runs from three sites; if any future site bypasses the helper and inlines the logic, the disconnect-recovery path silently breaks. Always go through CradlCombat::RunDeathPipeline.
  • Cold-storage overflow currently logs and drops — fine until items grow past 50 slots' worth of unique stacks, which is far beyond Phase 2 scope.
  • Sort key uses UStorePricingSubsystem::GetSellPrice; items with no price sort as 0. If/when an unsellable-but-valuable item lands (quest item, soulbound), add FItemRow::DeathValue and update the sort key — don't pollute the store price model.

Phase 3 — Combat Math Library

Goal. UCradlCombatMath exists with EffectiveLevel, RollAccuracy, RollDamage. Self-tested by cheat command that prints expected vs actual hit rates over N rolls.

Rationale. Pure leaf — no dependencies on any other phase except tags (phase 0). Could land parallel to phases 1-2. Codifying the formula in isolation lets us confirm the math against OSRS reference numbers before any ability calls into it.

Tasks.

  • [x] UCradlCombatMath — new file Source/CRADL/Combat/CradlCombatMath.h + .cpp:
  • [x] namespace CradlCombatMath (free functions, not BPFL — C++-only per CLAUDE.md "no BP logic"; mirrors CradlCombat::RunDeathPipeline shape).
  • [x] int32 EffectiveLevel(int32 BaseLevel, FGameplayTag ActiveStyle, FGameplayTag StyleThatBoostsThisAxis) — returns BaseLevel + StyleBonus + 8. ~~StyleBonus = +3 when ActiveStyle == StyleThatBoostsThisAxis, else 0.~~ Extended post-phase-5 with Controlled: Aggressive/Defensive keep the +3-on-match rule; Controlled grants +1 to both axes (OSRS-faithful collapse of "+1 Attack/Strength/Defence"). Caller passes Combat.Style.Aggressive for Attack/Strength axes; Combat.Style.Defensive for Defense.
  • [x] float RollAccuracy(int32 EffAtk, int32 EquipAtk, int32 EffDef, int32 EquipDef) — hit chance ∈ [0, 1] per the contract formula. int64 internally (overflow headroom; clamps negatives to 0 so degenerate inputs return "miss" rather than NaN).
  • [x] float RollAccuracyDetailed(...) — same formula, also exposes the intermediate AttackRoll/DefenseRoll so debug cheats / logs can print them without recomputing.
  • [x] int32 MaxHit(int32 EffStr, int32 StrengthBonus)floor(0.5 + EffStr * (StrengthBonus + 64) / 640). Preserves the per-step floor(0.5 + ...) rounding trick verbatim per the "don't refactor for cleanliness" footgun.
  • [x] int32 RollDamage(int32 EffStr, int32 StrengthBonus, FRandomStream& Rng) — uniform integer in [0, MaxHit]; returns 0 if MaxHit ≤ 0.
  • [x] Server-seeded RNG — math library takes FRandomStream& by reference so the caller owns the stream. Per-encounter vs per-swing seeding decision deferred to Phase 5 (where the auto-attack ability picks the lifetime).
  • [x] Validation cheatACradlPlayerController::Debug_RollSim(int32 N, int32 EffAtk, int32 EquipAtk, int32 EffStr, int32 StrengthBonus, int32 EffDef, int32 EquipDef) runs N rolls, prints AttackRoll/DefenseRoll, expected vs observed hit rate, expected vs observed max-hit, and avg damage on hits. Inputs are pre-computed effective levels (not base levels) — keeps the cheat focused on the math, not the lookup chain. Style modeling is exercised end-to-end at the Phase 5 call site.

Verification.

  • Cheat_RollSim 10000 <bronze-scimitar-vs-goblin> → hit rate within ±2% of OSRS-calculator expected; max-hit matches reference.
  • Repeat with 2-3 loadouts at different level tiers.

Exits. Phase 5's HandleSwingFinished and phase 8's UCastSpellAbility have a single canonical formula site to call.

Footguns.

  • Effective-level math collapses Attack and Strength into the single weapon-style skill — both effectiveAttackLevel and effectiveStrengthLevel derive from the same level. Don't accept separate Attack and Strength params in the API; take a single weapon-style level. Per COMBAT_SYSTEM.md "Combat Math."
  • Integer-vs-float math — OSRS does specific floor operations at specific points. Replicate exactly; don't refactor for "cleanliness."

Phase 4 — Equipment Combat Data & Ammo Slot

Goal. Player can equip a weapon with combat stats; ammo slot exists and accepts ammo items. No swing yet — this is data authoring.

Rationale. Phase 5's auto-attack reads FItemRow combat fields and the equipped ammo. Authoring real items + the ammo slot in advance keeps phase 5 focused on the ability, not on data setup.

Tasks.

  • [x] Ammo slot wiring:
  • [x] Added FEquipmentSlotDef entry with RequiredTag = Item.Slot.Ammo to the player pawn's UEquipmentComponent::SlotDefs.
  • [x] UEquipmentComponent::SlotAccepts handled the new tag with no code change (data-driven path confirmed via routing test).
  • [x] Authored ≥1 weapon per damage type in the items DataTable:
  • [x] Slash melee weapon authored.
  • [x] Ranged weapon authored.
  • [x] (Magic weapons / staves remain deferred to phase 8.)
  • [x] Authored ≥1 ammo item.
  • [x] Equip-gate validation — verified that a level-gated combat weapon is rejected at insufficient skill level via the existing UEquipmentComponent::CanPlaceInSlot / MeetsEquipRequirements path; no entry-point plumbing changes needed.

Verification.

  • Equip the slash weapon → goes into MainHand; right-click context menu shows correct combat data.
  • Equip the bow → goes into MainHand.
  • Equip arrows → goes into Ammo slot. Try to put arrows in the ring slot → rejected.
  • Try to equip a level-40-required weapon at level 1 → equip rejected, system message shown.

Exits. Phase 5 has an equipped weapon to read combat data from; ammo consumption has a slot to consume from.

Footguns.

  • The ammo-required behavior is derived from WeaponDamageType == Combat.DamageType.Ranged, not a separate bRequiresAmmo flag. This is fine for v1 (bows/crossbows). If thrown weapons (which equip as ammo with no MainHand) ever land, revisit.

Phase 5 — Auto-Attack, Targeting, Combat Styles, Target Dummy

Goal. Auto-attack ability swings at a target dummy on a timer, deals damage, drops the dummy. Combat styles route XP. Multi-attacker cap enforced. Triggered via cheat command (phase 6 wires real input).

Rationale. This is the big one — every architectural decision in the contract converges here. Folding combat styles in (rather than deferring to phase 7) avoids shipping a half-functional auto-attack that always trains the weapon-style skill regardless of player choice. Folding the target dummy in (rather than waiting for phase 6 input wiring) lets the ability be tested in isolation: cheat fires Action.Trigger.Combat.Engage, ability runs against the dummy.

Session decisions (locked at the start of phase 5):

  • Target dummy class = APawn. Forward-compat with the NPC arc; ASC + AttributeSet on the pawn directly, no CharacterMovement (dummies are stationary).
  • RNG seeding = per-encounter. A single FRandomStream lives on the UAutoAttackAbility instance, seeded once at ActivateAbility (server-only). Cheaper than per-swing reseeding, preserves entropy, matches OSRS's encounter-level continuity. Outcome correctness across peers stays handled by FireReplicatedGameplayEvent + the damage GE's replication.
  • Defensive XP = full-Defense. Phase 5: 100% of style XP routes to Skill.Combat.Defense under Defensive; 100% to the weapon-style skill (Melee/Ranged/Magic) under Aggressive. Post-phase-5 evolution: third Combat.Style.Controlled style added for split routing; all three styles route through UCradlCombatSettings::{Aggressive,Defensive,Controlled}Routing (FStyleXpRouting) via the canonical CradlCombat::GrantStyleDamageXp helper. Defensive stays OSRS-faithful 100%→Defense for melee; Defensive ranged/magic split because OSRS has no pure-Defense for those types. Controlled splits 2/2 (melee/ranged) or 1/1 (magic). Total XP/damage is mode-invariant for every damage type so no mode is strictly dominated. See COMBAT_SYSTEM.md "Combat Styles" for the current matrix.
  • Combat style = replicated loose tag. Style toggles call a server RPC that does ASC->AddReplicatedLooseGameplayTag(Combat.Style.{Aggressive,Defensive,Controlled}) (Controlled added post-phase-5). Server is authoritative on which style is active; the tag replicates back to the owning client (and spectators) via MinimalReplicationTags. The contract's "loose because style is a player setting, not a buff/duration" still holds — replicated loose is still loose; we just need the server's authoritative auto-attack swing to read the same value the player toggled, otherwise XP routing diverges.

Tasks.

  • [x] ATargetDummy — new file Source/CRADL/Combat/TargetDummy.{h,cpp}:
  • [x] APawn (forward-compat with NPC arc; capsule root + static-mesh placeholder, no CharacterMovementComponent)
  • [x] Owns UCradlAbilitySystemComponent + UCradlAttributeSet (so damage GEs apply normally; IAbilitySystemInterface exposes the ASC)
  • [x] Engine-default Cube mesh as a placeholder so a fresh PIE drop is visible without art
  • [x] Auto-respawn: bound to Health change delegate; on NewValue == 0 schedule RespawnDelay (default 5s) timer that resets HP and clears Status.InCombat. Doesn't go through the player death pipeline.
  • [x] Configurable DummyMaxHealth, DummyDefenseLevel, DefenseBonusByType (per-Combat.DamageType.* map) for testing accuracy edge cases.
  • [x] Multi-attacker tracking: TSet<TWeakObjectPtr<AActor>> CurrentAttackers, MaxAttackers (per-instance override, falls back to UCradlCombatSettings::DefaultMaxAttackers). TryRegisterAttacker is idempotent (re-registering the same attacker on a swing-tick refresh is a no-op success). Eviction fires from UAutoAttackAbility::EndAbility (every termination path).
  • [x] UCombatTargetingComponent — new file Source/CRADL/Combat/CombatTargetingComponent.{h,cpp}:
  • [x] Lives on ACradlPlayerState (constructed in ctor; accessor GetCombatTargetingComponent())
  • [x] TObjectPtr<AActor> ActiveTarget (replicated)
  • [x] FGameplayTag ActiveAttackChannel — Phase 5 stores the leaf Combat.DamageType.* (e.g. Combat.DamageType.Slash) verbatim. The contract mentions a 3-bucket {Melee/Ranged/Magic} collapse for AnimBP stance routing; introducing a separate Combat.AttackChannel.* namespace (or collapsing Stab/Slash/Crush at write-time) is deferred to Phase 7 when the AnimBP rows actually land. Functional equality holds: a HUD checking "is this a melee weapon?" can match the parent or the leaves equivalently.
  • [x] Replicated COND_OwnerOnly
  • [x] Spectator vis rides on ACradlPlayerState::CurrentActivity (FActivityDescriptor.Target) — set/cleared in lockstep with the targeting component from UAutoAttackAbility::ActivateAbility / TeardownEngagement. Do not parallel the spectator channel.
  • [x] UAutoAttackAbility — new file Source/CRADL/Abilities/AutoAttackAbility.{h,cpp}:
  • [x] Inherits UCradlGameplayAbility, InstancingPolicy = InstancedPerActor
  • [x] NetExecutionPolicy = LocalPredicted (load-bearing for spectator AnimBPs — see COMBAT_SYSTEM.md "Net Execution Policy")
  • [x] AbilityTriggers = [{ TriggerTag = Action.Trigger.Combat.Engage, TriggerSource = GameplayEvent }]
  • [x] ActivationOwnedTags = Action.Combat.AutoAttack
  • [x] CancelOnTagsAdded = { Status.Stunned, Status.Dead, Action.Modal } (parent-tag matching for modal). State.Moving deliberately omitted — same self-cancellation footgun documented on UQueuedInteractAbility.
  • [x] Activate flow: validate target (alive, has ASC, in range) → snapshot stats (SnapshotSwingStats returns false if no MainHand weapon — ends with "Equip a weapon to attack" message) → server-only: register on ATargetDummy::CurrentAttackers (cap-hit posts "target is overwhelmed" via PostServerMessage, ends ability) + seed FRandomStream → set UCombatTargetingComponent::ActiveTarget + ACradlPlayerState::SetActivityStartNextSwing.
  • [x] StartNextSwing flow: validate target → re-snapshot stats first (so the resolved engagement range matches the equipped weapon) → range-check → in-range: WaitDelay(SwingInterval / AttackSpeed) → on completion, HandleSwingFinished. Out-of-range: IPawnCombatant::BeginChase and reschedule a ChasePollInterval (default 0.1s) re-check — see Phase 6.5B for the chase primitive. Mirrors UGatherAbility::StartNextTick shape; chase-poll is the equivalent of "needed-resource missing this tick" in UGatherAbility.
  • [x] HandleSwingFinished flow: validate target → authority gate (client predicts cadence; server-only roll) → ConsumeAmmoIfRanged (out-of-ammo ends with message) → ResolveTargetDefense (Phase 5 supports ATargetDummy and ACradlPlayerState; future NPCs need an ICombatant interface or similar) → EffectiveLevel (with active style as the boost-axis selector) → RollAccuracy → on hit: RollDamage, build UGE_Combat_Damage spec with SetByCaller keyed by WeaponDamageType, apply to target ASC via OwnerASC->ApplyGameplayEffectSpecToTarget, fire Combat.Event.Hit, GrantStyleXp; on miss: fire Combat.Event.Miss → re-validate target (it may have died this swing) → StartNextSwing or EndSelf.
  • [x] End conditions (per COMBAT_SYSTEM.md "Engagement Loop"): target invalid/dead, cancellation tag added (base-class plumbing), owner death (Status.Dead is in CancelOnTagsAdded), modal opened (Action.Modal parent match), controller-explicit cancel (Phase 6 wires the controller path — any fresh nav input cancels Action.Combat.AutoAttack). Out-of-range is not an end condition. The swing loop hands off to the chase primitive, which has no internal give-up — chase runs indefinitely until one of the above cancellations fires. AI pawns get the same behavior; the leash decorator from ENEMY_SYSTEM.md aborts the engage branch when the pawn wanders past LeashRadiusCm.
  • [x] EndAbility cleanup: centralized TeardownEngagement runs on every termination path (cancel, target-dead, modal-open, owner-death). Evicts from ATargetDummy::CurrentAttackers, calls IPawnCombatant::EndChase (idempotent — no-op if no chase ran), clears UCombatTargetingComponent::ActiveTarget (which fires OnActiveTargetChanged(nullptr) so UMovementModePolicyComponent's facing drive auto-clears), clears FActivityDescriptor. bRegisteredOnTarget flag prevents double-eviction on re-entrant termination.
  • [x] Damage instigator = PlayerState. Ctx.AddInstigator(PS, PS) on the GE spec — the attribute set's HP-XP grant casts InstigatorActor to ACradlPlayerState, so passing the pawn would silently zero out HP-XP. Same convention as Server_Cheat_ApplyDamage.
  • [x] Combat styles wiring:
  • [x] Debug_SetCombatStyle Aggressive | Defensive | Controlled | None exec on ACradlPlayerController builds the same FGameplayEventData as the HUD button and dispatches Action.Trigger.Combat.SetStyle through ASC->HandleGameplayEvent (per ARCH #18). One channel — cheat and HUD share the production path. (Controlled added post-phase-5 alongside the third-style addition.)
  • [x] UAutoAttackAbility::SnapshotSwingStats reads the replicated loose tag at swing-start (matches the contract: switching mid-engagement affects the next swing's XP routing). Aggressive is the silent default if neither tag is set.
  • [x] USetCombatStyleAbility + Action.Trigger.Combat.SetStyle tag. Instant LocalPredicted ability granted in ACradlPlayerState::BeginPlay alongside AutoAttack / CastSpell / Death. On authority, strips both Combat.Style.* leaves and adds the chosen one via paired AddLooseGameplayTag + AddReplicatedLooseGameplayTag (UE 5.4 dual-call requirement — see Footguns). HUD surface is UCombatStyleSelectorWidget, a UCradlUserWidget with two BindWidget buttons in a UCommonButtonGroupBase (radio); embedded in USkillsPageWidget as a BindWidgetOptional and bound through the page's existing BindPlayer forward. Tag-driven highlight via RegisterGameplayTagEvent so external mutations (cheat, save load) sync the selection. Persistence: active leaf saved to UCradlPlayerProfile::CombatStyle and re-applied on load via the same dual-call pair.
  • [ ] DefaultStyle on equip — deferred. The contract calls for FItemRow::DefaultStyle to be applied as a replicated loose tag on first weapon equip. Per ARCH #18, this fires through Action.Trigger.Combat.SetStyle (same ability as above) — not a direct AddReplicatedLooseGameplayTag from UEquipmentComponent's slot-changed pipeline, which would re-introduce the "verb scattered across components" anti-pattern. Hook the equip path's slot-change observer to dispatch the gameplay event with the equipped weapon's DefaultStyle as payload. Not gating Phase 5 (Aggressive default and the cheat command cover testing); slated as a small follow-up alongside the HUD button.
  • [x] Cheat command: ACradlPlayerController::Debug_EngageNearest() finds the nearest ATargetDummy via TActorIterator (no GetAllActorsOfClass) and dispatches Action.Trigger.Combat.Engage through Server_Cheat_Engage so the gameplay event lands on the server's ASC and the authoritative ability instance activates. Bypasses the click/walk path (that's Phase 6).
  • [x] Placeholder hit cues — Phase 1 already produced the debug-text cue path (Hit: N Slash HP X -> Y on-screen). Phase 5 swings funnel through the same UCradlAttributeSet::PostGameplayEffectExecute so the cue lights up identically — no new code.

Verification.

Drop a placed ATargetDummy (or its BP child) into the test map first — Debug_EngageNearest TActorIterators for them. Equip a melee weapon (slash sword from Phase 4) before running.

  • Debug_EngageNearest → player auto-attacks the dummy on the weapon's swing interval; dummy HP drops; "Hit: N {Type} HP X -> Y" debug overlay fires (Phase 1 cue path); Status.InCombat flips on both sides; Hitpoints XP grants on every successful hit.
  • Dummy HP reaches 0 → ability ends with target-dead exit; dummy auto-respawns to full HP after RespawnDelay. Re-running Debug_EngageNearest re-engages cleanly (no stale attacker-set entries).
  • Walk away mid-engagement past the snapshot's resolved range (per-weapon AttackRange or per-type fallback — melee fallback is 250cm, ranged/magic 2000cm; resolver in CradlCombat::ResolveEngagementRange). Phase 5 ended the ability with "Target out of range."; Phase 6.5B replaced this with mid-engagement chaseIPawnCombatant::BeginChase runs and the swing loop resumes the moment the chaser closes back into range. Chase has no internal give-up; to end the engagement, issue a fresh nav input (controller cancels Action.Combat.AutoAttack) or open a modal (Action.Modal cancel match) — see 6.5B verification for the chase-cancellation tests.
  • Open the banking modal mid-engagement → ability cancels (parent-tag Action.Modal match in CancelOnTagsAdded).
  • Spawn / take over 4 player pawns engaging the same dummy → 4th gets "That target is overwhelmed." server message and the engage ability ends without swinging.
  • Equip a bow + ammo (Phase 4 ranged weapon + ammo authored) → Debug_EngageNearest → swings consume one ammo each; emptying the ammo slot ends the engagement with "Out of ammo."
  • Debug_SetCombatStyle Aggressive → XP routes to Skill.Combat.{Melee, Ranged, Magic} based on weapon. Debug_SetCombatStyle Defensive → melee XP routes 100% to Skill.Combat.Defense (OSRS-faithful); ranged/magic split per UCradlCombatSettings::DefensiveRouting. Debug_SetCombatStyle Controlled → XP always splits across the weapon-style skill and Defense. See COMBAT_SYSTEM.md "Combat Styles" for the full matrix. Verify via skill panel / Debug_PrintSkills. Toggling mid-engagement affects the next swing's routing, not retroactive.
  • Debug_SetCombatStyle None → all style tags cleared; auto-attack falls back to silent Aggressive default.
  • Hitpoints XP grants on damage > 0 only (Phase 1 contract); HP-XP rate is UCradlCombatSettings::HitpointsXpPerDamage * Damage. Style XP routes through UCradlCombatSettings::{Aggressive,Defensive,Controlled}Routing × Damage (see XP matrix in COMBAT_SYSTEM.md).

Exits. Phase 6 added the click-on-IInteractable-pawn → QueuedInteract walk → arrival → engage path alongside the cheat (Debug_EngageNearest stays as a no-walk shortcut for fast iteration). Phase 8 has the swing-loop pattern to reuse for autocast (per-encounter RNG, snapshot-at-swing-start, ammo-equivalent rune consumption).

Footguns.

  • Self-cancellation footgun — auto-attack causes its own movement when chase lands. Do not add State.Moving to CancelOnTagsAdded. Per COMBAT_SYSTEM.md "Engagement Loop" and the existing UQueuedInteractAbility documentation.
  • Stat snapshot timing — snapshot at the top of StartNextSwing (not activation). Equipment / style / status changes between swings reflect in the next roll, not retroactively.
  • EndAbility cleanup completenessTeardownEngagement runs on every termination path. bRegisteredOnTarget flag prevents double-eviction; do not bypass the helper.
  • Damage Instigator must be PlayerState, not Pawn. UCradlAttributeSet::PostGameplayEffectExecute casts Ctx.GetInstigator() to ACradlPlayerState for HP-XP grant; passing the pawn would silently zero out HP-XP without any compile-time signal. Same convention as Server_Cheat_ApplyDamage.
  • Predicted vs server attacker-cap rejection — server is authoritative; cap-hit on the client's prediction reconciles via standard GAS prediction rollback. Test with simulated latency once Phase 6 wires real input.
  • Style read uses HasMatchingGameplayTag not HasOwnedTag — replicated loose tags propagate through MinimalReplicationTags to all peers' GetOwnedGameplayTags, but the matching check is HasMatchingGameplayTag. Don't switch to a parent-match against Combat.Style without verifying tag-event subscription semantics (per COMBAT_SYSTEM.md "Combat Styles" — parent-cancellation is OK, parent-subscription is per-leaf).
  • UE 5.4 AddReplicatedLooseGameplayTag does not update the local count container. It only mutates FMinimalReplicationTagCountMap; the local owned-tags count is updated solely via UpdateOwnerTagMap() on the receiving end of replication. The server itself (which runs the auto-attack swing-start) reads false from HasMatchingGameplayTag immediately after the call. USetCombatStyleAbility and UCradlSaveSubsystem::LoadPlayer work around this by pairing every Add/Remove with the local AddLooseGameplayTag / RemoveLooseGameplayTag equivalent. Any future loose-tag write that server-side code reads back needs the same pair — see COMBAT_SYSTEM.md "Combat Styles" for the full rationale.

Phase 6 — Engagement Entry (Click-to-Attack)

Goal. Click a hostile pawn → walk into range → auto-attack starts. Real player input replaces the cheat command from phase 5.

Rationale. Reuses UQueuedInteractAbility (existing) rather than building a parallel input path. Per the contract, click-on-hostile-pawn is "click-on-out-of-range-interactable" with a different action verb on arrival.

Session decisions (locked at start of phase 6):

  • No new FContextAction flavor. The existing struct is purely tag-driven (every IInteractable picks its own ActionTag — gather/bank/store/ground all do this). Combat-engage is just an FContextAction with ActionTag = Action.Trigger.Combat.Engage. The "new variant" the prior pass called for would have been pure ceremony.
  • No standalone "hostility classification." A pawn implementing IInteractable and returning Action.Trigger.Combat.Engage from GetPrimaryActionTag() is the classification. The controller doesn't branch on hostility; ResolveTapAction dispatches whatever ActionTag the interactable provides. Adding a separate hostility tag/flag would duplicate that signal.
  • Cap-check timing = click-time peek + arrival enforce. ATargetDummy::WouldAcceptAttacker (peek) runs in CanInteract so the 4th attacker hears "overwhelmed" before walking. The auto-attack ability's TryRegisterAttacker (authoritative register) still runs on activate; if the cap fills mid-walk, the arrival path catches it. Accepts a small TOCTOU window (cap fills while you're walking) — the fallback is the existing post-walk rejection.
  • Same-target re-click = no-op only when in range (combat-only). Diverges from skilling on purpose. The "in range" gate matters because the target is mobile: a re-click on a fleeing engaged target needs to actually re-position the player. Phase 5 deferred mid-engagement chase; Phase 6.5B added it via the pawn-side chase primitive, so an in-range re-click now stays a no-op (auto-attack is already chasing under the hood) and an out-of-range re-click falls through to the normal cancel→re-queue cycle as a redundant — but harmless — explicit chase request. Range is checked via the same CradlCombat::ResolveEngagementRangeForPawn resolver the swing-time and click-time gates use, so all three answers stay symmetric. Guarded in Input_Click_Started before CancelQueuedInteract runs (otherwise the engagement is gone before we can compare).

Tasks.

  • [x] Per-type engagement range (Phase 5 carry-over — must land before the click-to-walk-then-engage flow because both IInteractable::CanInteract and UAutoAttackAbility::IsTargetInRange consult the same number; if they disagree the player walks into "in range for QueuedInteract → out of range for swing" and ping-pongs forever).
  • [x] FItemRow::AttackRange (float cm, 0 = "use settings fallback for this damage type"). Mirrors the SwingInterval pattern: per-weapon authoring with a typed runtime fallback. Validator update in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp: warn (not fail) on weapon rows with AttackRange == 0 so the fallback path is intentional, not silent.
  • [x] UCradlCombatSettings — added per-type fallbacks MeleeEngagementRangeCm (250), RangedEngagementRangeCm (2000), MagicEngagementRangeCm (2000). Kept EngagementRangeCm (250) as the ultimate fallback for unrecognized damage types and unarmed callers (so the click-time CanInteract can stay permissive without crashing on missing weapon).
  • [x] Resolution helper — free function in Source/CRADL/Combat/CradlCombatRange.h: CradlCombat::ResolveEngagementRange(const FItemRow* Weapon, FGameplayTag DamageType) → float cm plus convenience ResolveEngagementRangeForPawn(const APawn*) for callers that haven't snapshotted yet (the click-time CanInteract path). Resolves: autocast spell via ResolveSpellRange (when the pawn's spellbook has one set — matches the autocast-wins-over-weapon precedence SnapshotSwingStats uses) → weapon's AttackRange (if > 0) → per-type fallback (Stab/Slash/Crush → Melee; Ranged → Ranged; Magic → Magic) → EngagementRangeCm ultimate fallback. Single canonical resolver so any future caller gets the same answer. Free function (not a UCradlCombatSettings method) matches the CradlCombat::RunDeathPipeline shape.
  • [x] UAutoAttackAbility::IsTargetInRange — reads CurrentSwingStats.EngagementRangeCm (a new field on FAutoAttackSwingStats populated by SnapshotSwingStats via the resolver). The activate-time range check now runs after the first snapshot (the prior order would have used the default 250cm fallback before the snapshot resolved a ranged weapon's actual reach).
  • [x] Symmetry checkATargetDummy::CanInteract calls CradlCombat::ResolveEngagementRangeForPawn(InteractingPawn) so the same resolver runs against the same caller's autocast spell (when set) or equipped weapon. Both range gates land on the same number.
  • [x] Hostile pawn IInteractable implSource/CRADL/Combat/TargetDummy.h:
  • [x] ATargetDummy implements IInteractable.
  • [x] CanInteract gates on (1) alive (defeated → InteractCheck.Reason.Depleted, silent drop while respawn timer ticks), (2) engagement range via the resolver above, (3) WouldAcceptAttacker peek (cap-full → InteractCheck.Reason.MissingRequirement with "That target is overwhelmed."). LOS deferred to v1+ — gathering nodes don't trace LOS either.
  • [x] BeginInteract fires Action.Trigger.Combat.Engage on the invoker's ASC. Only reached via the legacy direct-interact path (UInteractionComponent::TryInteractWith / Debug_TryInteract); the click path reaches the same trigger via DispatchContextAction keyed off GetPrimaryActionTag(). Same verb, two entry points, single ability.
  • [x] GetPrimaryActionTag returns Action.Trigger.Combat.Engage. GetInteractLabel = "Attack"; GetSourceLabel = "Target Dummy".
  • [x] WouldAcceptAttacker(invoker) peek — server returns true if invoker already in CurrentAttackers (idempotent re-engage); otherwise compares replicated CurrentAttackerCount against the resolved cap. Authoritative final check still happens in UAutoAttackAbility::ActivateAbility via TryRegisterAttacker.
  • [x] Replicated int32 CurrentAttackerCount — mirrors CurrentAttackers.Num() so remote clients can run WouldAcceptAttacker from CanInteract. Updated server-side in TryRegisterAttacker / UnregisterAttacker / OnHealthChanged (death). CurrentAttackers itself stays server-only; per-attacker identity isn't useful client-side.
  • [x] Controller routingSource/CRADL/Player/CradlPlayerController.cpp:
  • [x] Click handler: no new code needed. The existing ResolveTapAction already builds an FContextAction from any clicked IInteractable with ActionTag = I->GetPrimaryActionTag() and dispatches via Action.Trigger.QueuedInteract. Combat-engage rides this path automatically because ATargetDummy::GetPrimaryActionTag() returns Action.Trigger.Combat.Engage.
  • [x] Fresh-nav cancel extensionCancelQueuedInteract() now adds Action.Combat.AutoAttack to the cancel container alongside Action.QueuedInteract. The auto-attack ability's AbilityTags got the same leaf so CancelAbilities matches it (mirror of UQueuedInteractAbility's pattern). All three existing CancelQueuedInteract call sites (Input_Click_Started, Input_Click_Held steer-promote, Input_OnWASDActive) now end auto-attack too. No parallel cancel system added.
  • [x] Same-target re-click no-op (in-range only) — added in Input_Click_Started before CancelQueuedInteract. Reads UCombatTargetingComponent::ActiveTarget, hit-tests the cursor, and additionally distance-checks via CradlCombat::ResolveEngagementRangeForPawn. In-range match → early-return. Out-of-range match → fall through to the normal cancel→re-queue cycle so the player can chase a fleeing target. The dismiss-context-menu still runs above; only the cancel + ResolveTapAction are skipped on the in-range branch.
  • [x] Click-time failure-message surfacingResolveTapAction's "anything else (depleted / missing requirement)" else-branch now calls UCradlMessageLogSubsystem::PostInteractFailure. Driven by the cap-full case (4th attacker click would otherwise be silent), but generalizes harmlessly to skilling: a left-click on a depleted gather node now shows "Depleted." instead of doing nothing visible. Matches the existing posting site in UInteractionComponent::DispatchContextAction.

Verification.

Drop ≥4 placed ATargetDummy instances in the test map.

  • Click a target dummy across the map → player walks to engagement range → auto-attack starts on arrival → combat continues per Phase 5 verification.
  • Click again on the same engaged dummy while still in range → no-op (debug log: "Re-click on engaged combat target ... in range — no-op"). Engagement continues without stutter.
  • Same-target re-click after the dummy walked out of range (or you walked off, with a moving target) → log says "out of range — re-walking", auto-attack cancels, fresh walk-to-target queues, engagement resumes on arrival. (After Phase 6.5B's mid-engagement chase landed, the in-range re-click no-op continues to apply but the out-of-range re-walk is largely redundant — auto-attack itself is chasing.)
  • Click a different dummy mid-fight → switches target (current auto-attack cancels, walk-to-new-target queues, fresh engage starts on arrival).
  • WASD movement mid-fight → fresh-nav cancel fires; auto-attack ends; player walks freely.
  • Click-and-hold to steer mid-fight → steer promotion cancels auto-attack via the same path.
  • Esc / explicit dismiss / Banking modal mid-fight → engagement cancels (Status.Dead / Action.Modal already in CancelOnTagsAdded).
  • Spawn 4 player pawns clicking the same dummy → 4th hears "That target is overwhelmed." at click-time without walking. (Earlier 3 succeed normally.)
  • Equip nothing, click a dummy → player walks to engagement range (the click-time path is permissive about no-weapon — it uses the ultimate-fallback range), then ActivateAbility fails the snapshot's "no weapon" gate and posts "Equip a weapon to attack." This is intentional: a misclicked nudity moment shouldn't be silent. Future iteration may surface "no weapon" earlier.
  • Click a dummy in mid-respawn (Health == 0) → "Already defeated." surfaces via the new failure-post path; no walk.
  • Click on a depleted gather node → "Depleted." now surfaces via the new failure-post (regression-test for the generalized UX change).
  • Place a longbow with AttackRange = 1000, click a dummy at 800 cm → walks to inside 1000 cm and engages. Place an unranged sword (uses Melee fallback 250 cm), click at 800 cm → walks all the way to 250 cm.

Exits. Click-to-attack works end-to-end. v1 PvE combat is functionally complete pending cosmetic polish (phase 7) and spells (phase 8).

Footguns.

  • Don't add a parallel "click-to-attack" controller path that bypasses QueuedInteract. Every "I want to attack this" intent funnels through the queued channel so chase / cancel / range-gate behave consistently. Per COMBAT_SYSTEM.md "Targeting & Engagement Entry."
  • Range-resolver symmetry is load-bearing. Both UAutoAttackAbility::IsTargetInRange (snapshot-driven) and ATargetDummy::CanInteract (ResolveEngagementRangeForPawn-driven) must hit the same number for the same caller. Don't inline a flat distance check at either site; always go through the resolver. Per COMBAT_SYSTEM.md "Engagement Range".
  • AbilityTags vs ActivationOwnedTags. UAbilitySystemComponent::CancelAbilities matches against AbilityTags, not ActivationOwnedTags. The auto-attack ability now adds Action.Combat.AutoAttack to both; if a future combat ability misses AbilityTags, the controller's fresh-nav cancel won't reach it.
  • CurrentAttackerCount is replicated, CurrentAttackers is not. The TSet stays server-only because TWeakObjectPtr doesn't replicate well and per-attacker identity is server-only data. Anything reading the count remotely (HUD indicator, future cap-aware AI) should use the int32; only authority code can iterate the set.
  • Same-target check must run before CancelQueuedInteract. The cancel runs TeardownEngagement which clears CombatTargetingComponent::ActiveTarget; if the same-target check ran after, the comparison would always be against nullptr.

Phase 6.5 — Phase 6 Follow-Ups

Two discrete deferrals from Phase 6, grouped here because they're both "pawn-mobility correctness / dev-iteration speed" tasks that fall out of Phase 6's actually working but don't gate Phase 7. Either can land independently; pick whichever unblocks the user first.

6.5A — Combat-Stats Debug Overlay [x]

Goal. A toggleable on-screen overlay that shows, for the locally controlled player, the currently evaluated combat math the next swing would use: weapon damage type, effective attack/strength/defense levels, equipment attack + strength bonuses, computed MaxHit, accuracy chance against the active target (or "no target"), engagement range, ammo state. Updates live as equipment / style / target / skills change. Stripped from shipping by guarding the body, not by stripping the UPROPERTY (per CLAUDE.md "UFUNCTION + #if !UE_BUILD_SHIPPING in headers — never combine").

Rationale. Combat math is currently only verifiable through the Debug_RollSim cheat (Phase 3) which takes pre-computed effective levels as inputs. There's no live "what does my swing do right now with what I have equipped" surface, which makes balance iteration slow — every weapon swap or skill level-up requires running the cheat with fresh numbers. A live overlay closes the loop. Sized as a half-phase because it's developer-facing: not part of the v1 player experience, no localization, no ship gating. Slots between 6 and 7 because Phase 5/6 left enough surface area (snapshot path, resolver, targeting component) to feed it cleanly, and because Phase 7's animation pass is more pleasant to author against a working live-stats display.

Implementation pivot. Originally scoped as a UUserWidget on a UI.Layer.Debug layer; landed instead as a UActorComponent lazy-spawned on the local pawn, rendered through GEngine->AddOnScreenDebugMessage with stable per-row keys. The component shape mirrors the HoverManagerComponent reference verbatim (the original spec's pattern reference), avoids any UMG/WBP authoring, and the screen-message API was already the project's dev-overlay convention. The layer-based widget approach would have required adding a UI.Layer.Debug tag and a corresponding DebugStack on the HUD WBP — pure ceremony for an inert read-only display.

Pattern reference. Mirror the per-instance debug toggle shape on Source/CRADL/Player/HoverManagerComponent.h — a UPROPERTY bDebugDraw flag plus a CVar (cradl.CombatStatsDebug) that overrides globally at runtime. Stripped from shipping builds via #if !UE_BUILD_SHIPPING around the body (not the declaration).

Tasks.

  • [x] UCombatStatsDebugComponent — new file Source/CRADL/Combat/CombatStatsDebugComponent.h + .cpp. UActorComponent subclass; ticks at frame rate but throttles its render to PollIntervalSeconds (default 0.25s — combat math doesn't change between swings). All work guarded by #if !UE_BUILD_SHIPPING; the class compiles in shipping but its TickComponent body is empty after Super::TickComponent, so the component is inert in ship without changing its identity. Reads:
  • From UEquipmentComponent::GetSlot(MainHand)FItemRow* via UItemRegistry: WeaponDamageType, SwingInterval, AttackBonus[type], StrengthBonus, RangedStrengthBonus.
  • From UCradlAttributeSet: AttackSpeed, Health, MaxHealth.
  • From USkillsComponent: WeaponStyleLevel, DefenseLevel via the same WeaponStyleSkillFor mapping UAutoAttackAbility uses (Stab/Slash/Crush → Melee, Ranged → Ranged, Magic → Magic).
  • From the player ASC: Combat.Style.{Aggressive,Defensive,Controlled} matching → active style (post-phase-5 update — Controlled added); Aggressive is the silent default if no leaf is set (mirroring SnapshotSwingStats). Both sides route through CradlCombat::ResolveActiveStyle so the read can't drift.
  • From UCombatTargetingComponent::GetActiveTarget (and a target-cast for ATargetDummy::GetDummyDefenseLevel / GetDummyDefenseBonus, same path as UAutoAttackAbility::ResolveTargetDefense).
  • [x] Computed display rows (using CradlCombatMath and CradlCombat::ResolveEngagementRange directly so the numbers can't drift from the ability):
  • Weapon: id, damage type.
  • Swing: scaled interval (SwingInterval / AttackSpeed), raw interval, AttackSpeed, engagement range.
  • Style: active style (with "(silent default)" marker when no Combat.Style.* tag is held) + the Skill.Combat.* the next hit's XP routes to.
  • Levels: WeaponStyle, Defense, Effective Atk/Str (= base + style bonus + 8), Effective Def.
  • Equip: AttackBonus, StrengthBonus, MaxHit (0 – N from CradlCombatMath::MaxHit).
  • Ammo (ranged only): equipped ammo id + count, or yellow "(none equipped — next swing will fail)".
  • Target + Accuracy: Target: name Accuracy: %.1f%% (Eff Def=N Def Bonus=N). CVar cradl.CombatStatsDebug 2 adds a verbose row with AttackRoll / DefenseRoll ints (the RollAccuracyDetailed intermediates).
  • Distance: live distance vs engagement range, green/yellow depending on in-range — useful when chase-mid-engagement is being designed (Phase 6 deferred chase).
  • HP: Health / MaxHealth.
  • [x] Toggle path.
  • Default off. ACradlPlayerController::Debug_ToggleCombatStats exec function: lazy-spawns UCombatStatsDebugComponent on the local pawn the first time it's invoked, then flips bDebugDraw on subsequent calls so the per-pawn flag persists across off/on cycles. Function declared unconditional UFUNCTION(Exec); body lives inside the file's existing #if !UE_BUILD_SHIPPING block alongside the other Phase 5/6 cheat exec functions.
  • CVar override: cradl.CombatStatsDebug 1 forces visible regardless of the per-pawn flag; 2 adds AttackRoll/DefenseRoll detail. Defined alongside the component (not the controller) so it travels with the rendering code. The component re-reads the CVar every tick so runtime flips take effect without re-spawn.
  • [x] Shipping strip. No UPROPERTY / UFUNCTION decl is conditional. The component class compiles in shipping; the TickComponent body is wrapped in #if !UE_BUILD_SHIPPING. The exec function follows the existing Phase 5/6 cheat pattern (declared unconditionally; defined inside the file's outer #if !UE_BUILD_SHIPPING block).
  • [x] No mutation. The component is read-only — no GE application, no skill grants, no inventory writes. Polls and renders via GEngine->AddOnScreenDebugMessage. Anything that needs to poke combat state lives behind a separate cheat (Phase 1's Debug_SelfDamage, Phase 5's Debug_EngageNearest / Debug_SetCombatStyle, etc.).

Verification.

  • Run Debug_ToggleCombatStats with the slash sword from Phase 4 equipped → overlay shows weapon row reading Slash, swing interval, MaxHit driven by Strength bonus.
  • Without a target: target row shows "(none)" / accuracy "—".
  • Debug_EngageNearest or click a dummy → target row populates with name + accuracy %; with cradl.CombatStatsDebug 2, AttackRoll/DefenseRoll appear underneath.
  • Debug_SetCombatStyle Defensive → effective-attack row drops by 3 (style bonus moves to Defense), XP-route row flips to Skill.Combat.Defense.
  • Equip a bow + ammo → damage-type row flips to Ranged; strength bonus row sums bow + ammo (matches SnapshotSwingStats); engagement-range row jumps to the bow's AttackRange or the Ranged fallback (2000cm); ammo row shows count.
  • Engage a moving dummy (DebugMoveDistance > 0) → distance row updates live and flips green ↔ yellow as the dummy oscillates in/out of range.
  • cradl.CombatStatsDebug 0 while overlay is up → on-screen entries expire after their TimeToDisplay and the overlay disappears (per-pawn flag stays whatever it was — re-running Debug_ToggleCombatStats resumes posting).
  • Re-run Debug_ToggleCombatStats → toggles off; rows fade. Toggle back on → rows resume in place.

Exits. Phase 7 has a live read-out to author hit cues / VFX timing against. Phase 8 spell authoring has a target for "what's my magic max hit?" without re-running the roll-sim cheat.

Footguns.

  • Don't route the overlay's numbers through any custom helper. Always call CradlCombatMath::* and CradlCombat::ResolveEngagementRange* directly so the overlay displays exactly what the auto-attack will compute. Any drift is a bug; if a number looks wrong on screen, the bug is in the math library, not the display.
  • Don't add a CVar that changes combat math. The overlay is observe-only. Mutating cheats stay on ACradlPlayerController exec functions.
  • Don't poll faster than ~4Hz. Combat cadence is per-second at fastest; tick-rate polling burns CPU on data that hasn't changed since the last swing.
  • Per CLAUDE.md: UFUNCTION declarations stay unconditional. Only #if !UE_BUILD_SHIPPING the function bodies.
  • AddOnScreenDebugMessage renders newest-first. Keep emitting rows in reverse so the printed top-to-bottom order matches the array order (header first, footer last). Stable per-row keys (BaseKey + RowIndex) prevent flicker; reusing one big multi-line message would lose the per-row colour gating (yellow "no ammo!", green/yellow distance).

6.5B — Pawn-Side Chase Primitive [x]

Goal. Make the click-on-moving-target path actually reach its target, and land the chase primitive that mid-engagement chase (the long-deferred "what happens when the dummy walks away while I'm fighting it?") and future AI pursuit will reuse. The original 6.5B scope ("re-issue moves on poll inside UQueuedInteractAbility") expanded mid-implementation when it became clear the same machinery was needed by the swing loop and would need to be controller-flavor-agnostic for AI later.

Rationale. Phase 6 was designed against the existing IInteractable population — gather nodes, bank/store terminals, ground items. None of them move, so the activation-time TryResolveMoveLocation(target->GetActorLocation()) was a one-shot snapshot that always pointed at the right spot. Combat is the first IInteractable where the target itself moves (target dummy with DebugMoveDistance > 0, future PvE NPCs, eventual PvP pawns). The poll loop re-ran CanInteract and stall-detected, but did not re-issue the move command, so a pawn walked to the click-time location, the target drifted, and the queue died after StallTimeoutSeconds with no on-screen explanation. Mid-engagement chase had been deferred since Phase 5 with the same "ends with Out-of-Range message" placeholder. 6.5B fixes both at once with one shared primitive instead of two parallel ones.

Implementation pivot. Originally scoped as in-place edits on UQueuedInteractAbility (track LastIssuedMoveLocation, replace stall detection with closing-distance tracking, expose tunables on the ability). Landed instead as a pawn-side chase primitive split across two interfaces and a helper struct, used by both UQueuedInteractAbility (entry-time walk) and UAutoAttackAbility (mid-engagement chase). The pivot was driven by the realization that:

  1. Chase is a property of the body, not the input source. Tunables (re-issue threshold, refresh rate) are properties of the creature — a heavy pawn pursues differently than a light one regardless of who's commanding. Putting them on the ability would have re-stated the same defaults across every chase-using ability.
  2. AI must be able to reuse the chase logic without referencing APlayerController. The original scope's controller->TryResolveMoveLocation was player-flavored; an AI controller would have needed a parallel code path. Splitting the surface across IPawnCombatant (pawn-side, what abilities call) and IMoveIssuer (controller-side, the one legitimate point of polymorphism) keeps the ability code controller-flavor-agnostic.
  3. Mid-engagement chase needs the same primitive. The AutoAttack swing loop also wanted "out of range → chase the target → re-poll fast → resume swinging" — re-implementing it inside the ability would have duplicated FCradlChaseState's drift + rate-limit logic. One primitive, two callers.

Architecture.

  • IPawnCombatant (Source/CRADL/Combat/PawnCombatantInterface.h) — pawn-side surface. BeginChase, EndChase, IsChaseActive, StopMovement. Abilities address the pawn through this; concrete pawns (ACradlCharacter, future NPCs) implement by delegating to a UChaseComponent. Chase runs indefinitely once BeginChase is called — no internal give-up, no IsChaseFailed.
  • IMoveIssuer (Source/CRADL/Player/MoveIssuerInterface.h) — controller-side primitive. IssueMoveTo(WorldLocation), StopMove(). The single legitimate polymorphism point between PC and AI: PC implements via navmesh-project + SimpleMoveToLocation + nav-target marker; future AI controllers implement via SimpleMoveToActor (UAIBlueprintHelperLibrary).
  • FCradlChaseState (Source/CRADL/Combat/CradlChaseState.h) — controller-agnostic state machine. Update(Chaser, Target, NowSeconds) returns EAction::{Idle, ReissueMove}. No engine-actor coupling; AI behavior-tree tasks can re-use the struct verbatim. No "can't catch up" detection: an earlier closing-distance heuristic produced too many false positives on legitimate slow-pursuit cases (sloped terrain, narrow doorways), and a stuck chaser is functionally the same UX as a forced give-up. Cancellation is the caller's responsibility — see end-condition discussion in the auto-attack task.
  • UChaseComponent (Source/CRADL/Combat/ChaseComponent.h) — pawn-side component owning FCradlChaseState + poll timer. Lives on ACradlCharacter. Tunables (MoveRefreshThresholdCm, MaxMoveRefreshHz, ChasePollHz) are EditDefaultsOnly per pawn class. Calls IMoveIssuer::IssueMoveTo for re-issues. EndChase stops the poll timer and resets state but does NOT call StopMove — caller pairs EndChase with StopMovement when it wants the pawn halted (arrival case) and leaves the pawn coasting under the prior move otherwise.

Tasks.

  • [x] IMoveIssuer interface + impl — controller-side primitive. ACradlPlayerController implements: IssueMoveTo forwards to existing TryResolveMoveLocation (the path Phase 6 already used for click-to-walk); StopMove forwards to APlayerController::StopMovement. Public on the controller header.
  • [x] IPawnCombatant interface + impl — pawn-side surface. ACradlCharacter implements by delegating BeginChase/EndChase/IsChaseActive to UChaseComponent; StopMovement forwards to the controller's IMoveIssuer::StopMove, with a fallback to UCharacterMovementComponent::StopMovementImmediately if the pawn is in a possession-transition window with no IMoveIssuer controller.
  • [x] FCradlChaseState helper struct — drift + rate-limit logic. Update() decides per-tick whether to re-issue based on drift threshold and rate gate; returns Idle or ReissueMove. No internal closing-distance check (the prior heuristic produced too many false positives). Self-seeding Begin() so callers can Update() without an explicit init. Reset() clears all state.
  • [x] UChaseComponent — pawn-side driver. Subobject on ACradlCharacter (constructed in ctor). BeginChase: idempotent — same target refreshes tunables, different target supersedes. Issues the first move synchronously so the chaser starts moving the same frame. EndChase: clears poll timer + state. Polls at ChasePollHz (default 10Hz; per-issue rate is gated by FCradlChaseState's MaxMoveRefreshHz regardless).
  • [x] UQueuedInteractAbility ports to IPawnCombatant — replaces direct controller->TryResolveMoveLocation with IPawnCombatant::BeginChase. StallStartTime/StallTimeoutSeconds removed. Arrival path adds EndChase before StopMovement. Cancellation: the existing controller fresh-nav cancel kills the queued interact's ability on player input; modal open and target invalidation flow through the standard ability cancel paths. No active "give up" poll inside the ability — chase runs as long as the ability is alive. Class header now mentions the broadened scope (any future moving IInteractable inherits this for free).
  • [x] UAutoAttackAbility adds mid-engagement chase — out-of-range during the swing loop hands off to IPawnCombatant::BeginChase and reschedules a ChasePollInterval (default 0.1s) re-check. HandleSwingFinished re-checks range at the swing-fire moment too (so a target that drifted during the SwingInterval window kicks back into the chase loop instead of getting hit at distance). BeginOrContinueChase returns false only when there's no IPawnCombatant on the avatar (a wiring bug, not a runtime failure mode) — there is no closing-distance give-up. Cancellation flows through the standard channels: player nav input cancels the ability via the controller's fresh-nav path; modal open / target-dead / owner-death cancel via CancelOnTagsAdded. TeardownEngagement calls EndChase on every termination path.
  • [x] Snapshot-before-range-check fixStartNextSwing now snapshots stats before the range check (previously the activation-time range check ran against the default 250cm fallback before the snapshot resolved a ranged weapon's actual reach). Phase 6 caught most of this for engage-time; 6.5B closes it for the swing loop.
  • [x] Activation-time out-of-range no longer ends the abilityUAutoAttackAbility::ActivateAbility removed its activate-time range check. QueuedInteract may dispatch Action.Trigger.Combat.Engage a frame out of range against a moving target (drift between arrival check and event dispatch), and the swing loop's chase handling is now the right answer.
  • [x] Marker flicker fixACradlPlayerController::OnNavRequestFinished skips its usual nav-target clear while a chase is active (IPawnCombatant::IsChaseActive). Without this, every chase re-issue cleared the marker for a frame before the next issue re-set it. Marker clean-up on ability end flows through the standard nav-target paths (player input replaces the target; modal/cancel cases clear via EndAbility's teardown).
  • [x] UCombatTargetingComponent::OnActiveTargetChanged delegate — non-dynamic multicast, fires from server-side SetActiveTarget/ClearActiveTarget and from owning-client OnRep_ActiveTarget (both ActiveTarget and ActiveAttackChannel share the rep notify since they're written together). Subscribers can react to engagement state without tick-polling.
  • [x] Facing pivot — moved from ability to UMovementModePolicyComponentUAutoAttackAbility::ActivateAbility no longer pokes facing directly. UMovementModePolicyComponent subscribes to OnActiveTargetChanged and tick-rotates the pawn's yaw toward the active target while stationary (velocity ~0 / squared 2D speed below StationarySpeedSqThreshold). While moving, bOrientRotationToMovement already orients along velocity (which, after chase tracking, points at the target); the facing drive only takes over once velocity drops to zero so it doesn't fight the movement-orient path during chase. FacingTurnRateDegPerSec (default 540) is EditDefaultsOnly. Late-binding via ACradlCharacter::OnPlayerStateReplicated covers remote-client cases where PlayerState arrives after the pawn.
  • [x] Tick gatingUMovementModePolicyComponent ticks only while FacingTarget is set; SetFacingTarget/ClearFacingTarget flip SetComponentTickEnabled so pawns that never engage in combat pay zero per-frame cost.
  • [x] All-IInteractable applicability documentedUQueuedInteractAbility's class header notes that any future moving IInteractable (talk-to-NPC, pickpocket-NPC, etc.) inherits the moving-target tracking by virtue of routing through IPawnCombatant. No combat-specific code path.

Verification (uses Phase 6's DebugMoveDistance dummy displacement; combine with the 6.5A combat-stats overlay's live distance row).

  • Place a dummy with DebugMoveDistance = 500, DebugMoveFrequency = 0.5. Click from far out → walk path updates as the dummy moves; player intercepts on the near-side peak. (Pre-6.5B behavior was "walk to click-time spot, arrive at empty space, queue stalls.")
  • Faster oscillation (Frequency = 2.0, Distance = 200) → engagement starts within a swing or two; no infinite chase.
  • Configure a dummy moving faster than player walk speed (Distance = 2000, Frequency = 2.0) → player pursues indefinitely (no closing-distance give-up). To end the chase, issue a fresh nav click (controller cancels Action.Combat.AutoAttack) or open a modal — the engagement ends cleanly through the standard cancel path. This is the intended design: chase has no internal give-up.
  • Stationary dummy (DebugMoveDistance = 0) → behavior unchanged (single walk attempt, arrive, dispatch).
  • Mid-engagement chase: engage a stationary dummy, then walk it (or yourself) out of range → auto-attack does not end; the ability hands off to chase, the marker tracks the dummy, swings resume on closure. (Pre-6.5B behavior was "Target out of range." and the engagement ended.)
  • Mid-engagement cancellation: engage a dummy moving faster than walk speed → fresh nav input (right-click elsewhere) cancels the engagement immediately via the controller's fresh-nav-cancel path; alternatively, opening a modal (e.g. inventory) cancels via Action.Modal parent match. Either way the pawn stops chasing and the ability ends cleanly.
  • Facing: stand stationary in front of a moving dummy → pawn yaw rotates smoothly toward the dummy at ~540 deg/sec. Walk during chase → pawn orients along velocity (CMC bOrientRotationToMovement). Stop again at engagement range → facing drive resumes.
  • Marker stability: chase a moving target → nav-target marker tracks the target without flicker on every re-issue.
  • Possession-transition resilience: in PIE, control-detach (Eject) mid-chase → the pawn's UChaseComponent continues polling against a null controller; the next IssueMoveTo no-ops (no IMoveIssuer to resolve), the pawn coasts on the previously-issued path, and the chase remains active until the ability is cancelled or re-possession restores IMoveIssuer. Re-attach control → fresh chase on next click.
  • Same-target re-click on an out-of-range moving target → the in-range no-op gate from Phase 6 still applies for in-range; out-of-range falls through to a fresh queue (redundant after 6.5B since auto-attack itself is chasing, but harmless).

Footguns.

  • Re-issuing too aggressively cancels in-flight path-following. SimpleMoveToLocation aborts and restarts; if the threshold is too low or the rate is too high, the pawn never finishes a path and walks in place. The 100cm / 4Hz defaults are conservative — tune through testing, not theory. Both gates (drift threshold + rate limit) are necessary; either alone has pathological cases.
  • Don't subscribe UChaseComponent (or any chase consumer) to State.Moving. Same self-cancellation footgun as the rest of the combat abilities: chase causes movement; a State.Moving reaction would self-terminate.
  • StopMovementEndChase. Both are on IPawnCombatant and decoupled on purpose. Arrival pairs them (EndChase then StopMovement); cancel paths typically only end chase. Don't conflate.
  • No internal give-up — caller owns cancellation. A BeginChase against an uncatchable target pursues forever if the caller doesn't supply a termination signal. Player pawns get this for free (any fresh nav input cancels Action.Combat.AutoAttack); AI pawns get it via the leash decorator (ENEMY_SYSTEM.md). A new chase caller without either path will produce a pawn that pursues indefinitely — add an ability-level condition or a BT decorator, don't reach for closing-distance heuristics inside the chase struct. The prior closing-distance heuristic produced too many false positives on legitimate slow-pursuit cases (sloped terrain, narrow doorways, kiting at the edge of catchup) and was deliberately removed in favor of caller-side cancellation.
  • Don't predict the target's future position. Tempting to walk to "where the target will be in N seconds" — separate design problem (lead/lookahead) with its own gotchas. Naive "walk to current location, refresh on a cadence" is the contract.
  • Authority surface. LocalPredicted abilities call BeginChase on both peers; each peer's UChaseComponent runs its own poll timer. Server-side movement replicates to remote peers via bReplicateMovement; remote peers don't drive their own chase. For dedicated-server / lossy-client jitter (not relevant in PIE / listen-server), consider running re-issue on authority only and letting SimpleMoveToLocation's built-in network smoothing handle remote display.
  • Don't branch the ability on controller flavor. Reach the pawn via IPawnCombatant. AI-controlled pawns (when they land) reuse the exact ability path because the polymorphism point is IMoveIssuer on the controller, not the abilities.
  • Targeting subscriber bind order. UMovementModePolicyComponent::TryBindToTargeting runs in BeginPlay if PlayerState is already available; remote clients usually need the OnPlayerStateReplicated fallback. Don't move the bind to a tick-poll; the explicit late-binding hook is cleaner and the existing delegate is already there for unrelated reasons.
  • Tick-gating the facing drive — re-enabling tick on SetFacingTarget / disabling on ClearFacingTarget keeps the cost zero for non-combat pawns. If a future caller wants to drive facing for non-combat reasons (e.g. a "look at NPC during dialogue" ability), call SetFacingTarget directly; later targeting-changed callbacks will overwrite, so combat resumes priority when a new engagement starts.

Exits. Phase 6's click-on-hostile-pawn flow is correct against any mobile target; mid-engagement chase fills in the long-standing Phase 5 deferral; Phase 7 animation work has a stable yaw-toward-target signal to author against. Future AI / NPC work inherits chase verbatim by implementing IPawnCombatant (typically by hosting a UChaseComponent) and pairing with an IMoveIssuer-implementing controller.


Phase 7 — Animation/FX Polish Pass

Goal. Combat looks like combat: real swing montages, hit VFX/SFX, AnimBP transitions for stance state, and the back-fill of the four code-side cosmetic emitters earlier phases left unwired.

Rationale. Phases 1/2/5 shipped the gameplay rail correctly but left the cosmetic-emission surface incomplete — the resolution-side cues fired, but the swing-start signal, the death cue, and the attacker-side damage magnitude didn't. Phase 7 closed those four emit-site gaps in code and now exists to drop the authored content in on top. Separating cosmetic emission from gameplay timing means art iteration doesn't block gameplay validation and vice versa.

Code-side back-fills (complete)

All four back-fills landed alongside the base-class extraction (UCradlAutoAttackAbilityBase) so player + enemy auto-attack share the same emit surface. References below use symbol names rather than line numbers (file restructured significantly during the split; line refs would re-rot).

  • [x] Per-swing montage at swing-start. UCradlAutoAttackAbilityBase::StartNextSwing kicks off UAbilityTask_PlayMontageAndWait alongside the gameplay WaitDelay as parallel decoration. PlayRate = MontageLength / WaitSec (where WaitSec = SwingInterval / AttackSpeed) — same ESkillAnimTiming::ScaleToActionDuration shape as UGatherAbility. No completion binding — EndAbility tears the task down for free (bStopWhenAbilityEnds=true default). Per-engagement async pre-load in ActivateAbility via UAssetManager::GetStreamableManager().RequestAsyncLoad warms the soft pointer toward residency so the first swing's .Get() usually has it.
  • [x] TSoftObjectPtr<UAnimMontage> SwingMontage on FItemRow. Field at Source/CRADL/Inventory/ItemRow.h with EditCondition="WeaponDamageType.IsValid()" (only shows on weapon rows). Snapshot wiring in both subclasses — UAutoAttackAbility::SnapshotSwingStats (player; routes to USpellDefinition::CastMontage when autocasting) and UEnemyAutoAttackAbility::SnapshotSwingStats (enemy) — copies the soft ref onto FAutoAttackSwingStats::SwingMontage. Validator warning in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp fires when a weapon row leaves SwingMontage empty (warn, not fail — "invisible swing" should be intentional).
  • [x] Death cue emit-site. UCradlAttributeSet::PostGameplayEffectExecute calls TargetASC->ExecuteGameplayCue(CradlTags::GameplayCue_Combat_Death, …) when NewHealth <= 0, before firing Combat.Event.Death. FGameplayCueParameters::AggregatedSourceTags carries the instigator's damage type so a notify can branch per-type (e.g. fire-death vs ice-death) without a per-type cue tag fan-out.
  • [x] Damage magnitude on attacker-side Combat.Event.Hit. UCradlAutoAttackAbilityBase::HandleSwingFinished's on-hit branch passes static_cast<float>(DamageDealt) as the magnitude arg to FireReplicatedGameplayEvent. Symmetric with victim-side Combat.Event.Hurt. Local attacker UI still reads magnitude via PostGameplayEffectExecute's cue on the victim's ASC; the event magnitude is for spectators and non-GE-callback consumers.

Cosmetic surface (reference, do not regress)

  • Hit / Miss / Hit-for-0 distinction. UCradlAutoAttackAbilityBase::HandleSwingFinished fires Combat.Event.Hit on every accuracy-passed swing (with magnitude per above), Combat.Event.Miss on every accuracy-failed swing, and a 0-magnitude per-type hit cue via CradlCombat::FireHitCue(..., bWasMiss) on every zero-magnitude outcome (miss or hit-for-0) to disambiguate at the render layer.
  • Per-type hit cue with magnitude. UCradlAttributeSet::PostGameplayEffectExecute calls CradlCombat::FireHitCue on the victim's ASC. Lyra pattern — type-conditional cues fire from the attribute set, not from the GE's GameplayCues array (which stays empty).
  • Victim-side Combat.Event.Hurt. UCradlAttributeSet::PostGameplayEffectExecute fires it with EventMagnitude = DamageDelta. Server-only; the channel AI retaliation subscribes to (see AEnemyCharacter::HandleHurtEvent).
  • Engagement boundary hook. UCradlAutoAttackAbilityBase::OnEngagementBegan(Target, WeaponDamageType) virtual on the base, fires once per engagement from ActivateAbility. OnEngagementEnded() fires from TeardownEngagement. Subclasses use these to drive targeting subscription / facing pivot / BB writes.

Content tasks (data + assets, remaining)

  • [ ] Per-weapon swing montages authored against FItemRow::SwingMontage. Per-swing SFX (bow-draw, sword windup, footstep) live as AnimNotify_PlaySound inside the montage — author at frame, not as a separate cue tag. Animation-tied VFX (weapon trail flash, dust kick) authored as AnimNotify_GameplayCue inside the montage when needed. No new GameplayCue.Combat.Swing.* namespace — animation owns the swing-cosmetic timing.
  • [ ] Hit cue notify implementations for GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic} — five BP-derived UCradlHitCueNotify_Static assets, each setting GameplayCueTag to its leaf and HitSystem to a per-type Niagara asset. Miss-vs-hit-for-0 branches off Combat.Event.Miss in FGameplayCueParameters::AggregatedSourceTags (already populated by FireHitCue when bWasMiss=true).
  • [ ] Death cue notify for GameplayCue.Combat.Death — one-shot ragdoll/dissolve + audio. Reachable once the emit-site back-fill above lands.
  • [ ] AnimBP tag-map rows — bind Action.Combat.AutoAttack, Action.Combat.CastSpell, Status.InCombat via FGameplayTagBlueprintPropertyMap rows in UCradlAnimInstance. Drive transition state from boolean properties.
  • [ ] Ranged projectile actor — minimal arrow/bolt visual that travels from caster → target. Pure cosmetic; damage already applied at swing-tick resolution. v1 fits the projectile-flight window inside the swing montage's back half (release notify mid-interval, visual lands at WaitDelay end) — splat and HP-drop stay atomic.

Verification.

  • Visual smoke test: melee swing plays the right montage at the right pace; hit cues fire with VFX/SFX; death plays death cue; AnimBP shows in-combat stance during engagement.
  • Spectator client (third peer) sees Combat.Event.Hit payloads carrying the damage magnitude.
  • Death of a target triggers the death cue (not just the event) on every peer.
  • AnimBP tag-map row names match contract — renaming a BP variable silently no-ops the row, per ARCH #16. Document the row names as load-bearing.

Exits. Combat is ship-quality cosmetically; the cosmetic-emission backlog from Phases 1/2/5 is closed.

Footguns.

  • Per ARCH #16: AnimBP tag map matches by string. Renaming or deleting a BP variable silently no-ops the row.
  • Per ARCH #16: cancellation correctness flows through EndAbility tearing both montage + timer task down. Don't add per-task cancel callbacks.
  • Per ARCH #16: LocalPredicted is load-bearing for spectator AnimBPs — don't downgrade.
  • The damage GE's GameplayCues array stays empty. Per-type hit cues fire from PostGameplayEffectExecute (damage-landed) and HandleSwingFinished (0-magnitude); adding cues to the GE array would double-fire.
  • Don't drive the hit cue from an AnimNotify_GameplayCue on the montage's contact frame. The cue already fires from C++ at the resolution moment; a notify would duplicate it and reintroduce frame-skew between damage application and splat render. Reserve AnimNotify_GameplayCue for animation-tied VFX that isn't already routed.

Phase 8 — Spellcasting & Spellbook

Goal. Player can learn spells, cast them manually (one-shot) or set autocast (per-swing). Magic skill levels up.

Rationale. Architecturally additive on top of auto-attack — UCastSpellAbility reuses the swing-loop pattern. Lands after phase 5 so it's not blocked by core combat plumbing.

Session decisions (locked at the start of phase 8):

  • Single engagement loop, not two. The contract's literal wording was "Manual cast and autocast both flow through UCastSpellAbility … autocast: long-running like auto-attack; reads autocast spell selection each swing." Read literally that's two parallel engagement abilities (auto-attack vs. cast-spell), with engagement entry choosing one based on autocast state. Phase 8 lands the cleaner interpretation: autocast is a routing branch inside UAutoAttackAbility, not a separate engagement loop. When the player's USpellbookComponent::AutocastSpell is set, the swing tick reads the spellbook at snapshot time and routes to magic-flavoured damage (rune consume + magic damage GE + Magic XP) instead of weapon damage. UCastSpellAbility is manual one-shot only. Pros: one cancellation path, one cadence source, OSRS-faithful (autocast modifies your auto-attack). The contract doc gets a one-line edit to match.
  • Manual cast fails on out-of-range — no walk-up plumbing. (Reversed 2026-05-11; the original phase-8 design had cast share the chase primitive.) CRADL has no "click spell on target" UX (vs OSRS's use-on pipeline), and spells are used mid-engagement where auto-attack has already pulled the avatar into melee range (and spell range ≥ melee range). UCastSpellAbility::ActivateAbility validates in-range and posts "Out of range." if it fails. Engagement-time chase stays UAutoAttackAbility's job.
  • UI deferred. Phase 5 cheat-driven verification preceded the eventual HUD button; Phase 8 follows the same shape — back-end + cheats now, CommonUI spellbook page later.
  • Magic XP rate held separately from melee/ranged. ~~New UCradlCombatSettings::MagicXpPerDamage (default 2.0) so balance can tune magic without dragging melee/ranged XP rates with it.~~ Superseded post-phase-5: per-axis rates collapsed into FStyleXpRouting structs (Aggressive/Defensive) — magic still has its own column (MagicToMagic / MagicToDefense) so the independence holds. UAutoAttackAbility::GrantStyleXp and UCastSpellAbility::ApplyMagicDamageAndGrantXp both route through the canonical CradlCombat::GrantStyleDamageXp helper, which branches on damage type internally.
  • Per-spell cue deferred. USpellDefinition::HitCueTag field exists for forward-compat but Phase 8 routes hit cues through the existing UCradlAttributeSet::PostGameplayEffectExecute pathway (GameplayCue.Combat.Hit.Magic for any magic damage). Per-spell custom cues need either a SetByCaller override key or an instigator-side lookup; that's polish and lands with the FX pass.
  • Shared cast cooldown (added 2026-05-11). Manual cast originally had no re-activation gate — spam-clicking fired as fast as input drained. Phase 8 follow-up adds Cooldown.Combat.Cast (one channel) shared between UCastSpellAbility and the autocast branch in UAutoAttackAbility; per-cast duration = USpellDefinition::CastInterval via SetByCaller. Shared (vs per-spell) so a slow manual cast freezes autocast cadence for its residual window, and an in-flight autocast can't be interjected by a manual spell mid-cadence. Future utility spells (alch, telegrab) that need true tick-manipulation room get their own Cooldown.Combat.* leaf with its own GE class — CooldownChannel field on USpellDefinition lands then, not now (per CLAUDE.md "no features beyond what the task requires"). See COMBAT_SYSTEM.md "Spellbook & Spells" / "Cast Cooldown" for the full architecture.

Tasks.

  • [x] USpellDefinition — new Source/CRADL/Combat/SpellDefinition.h UPrimaryDataAsset:
  • [x] DisplayName, Description (multi-line), Icon (soft UTexture2D)
  • [x] BookTag (FGameplayTag — the spellbook this spell belongs to; USpellbookComponent::HasLearned derives knowledge from BookTag == CurrentBookTag membership)
  • [x] RuneCost (TMap)
  • [x] BaseDamage, MaxDamage
  • [x] RequiredMagicLevel
  • [x] CastInterval (autocast swing cadence AND the per-cast duration of the shared Cooldown.Combat.Cast gate that rate-limits manual casts; default 3.0s, OSRS standard spell speed)
  • [x] CastRangeCm (per-spell override; 0 falls back to settings Magic range — mirror of FItemRow::AttackRange)
  • [x] HitCueTag (FGameplayTag — reserved; runtime falls back to GameplayCue.Combat.Hit.Magic until per-spell cue routing lands)
  • [x] CastMontage (soft ref — Phase 7 authors actual content)
  • [x] (no DamageType field — all v1 spells are Combat.DamageType.Magic per contract)
  • [x] (no Splash — single-target v1; AoE deferred)
  • [x] Validator in Source/CRADLEditor/Validators/CradlSpellDefinitionValidator.h — DisplayName non-empty, BookTag valid and under Spellbook.*, RequiredMagicLevel ≥ 1, MaxDamage ≥ BaseDamage, CastInterval > 0, HitCueTag (if set) under GameplayCue.Combat.*, RuneCost keys non-None and resolve in items DataTable, counts > 0, Icon (if set) resolves.
  • [x] USpellbookDefinition — new Source/CRADL/Combat/SpellbookDefinition.h. Fields: BookTag (the identity tag every USpellDefinition::BookTag references), DisplayName, Description. v1 ships one authored book (Spellbook.Standard) + the project default in UCradlCombatSettings::DefaultSpellbook, but the multi-book infrastructure (registry + per-spell BookTag + SetCurrentBook) lands now since the runtime model is derived-from-book-membership.
  • [x] Validator in Source/CRADLEditor/Validators/CradlSpellbookDefinitionValidator.h — BookTag valid and under Spellbook.*, DisplayName non-empty.
  • [x] Tag-uniqueness validator in Source/CRADLEditor/Validators/CradlSpellbookTagUniquenessValidator.cpp — guards against two USpellbookDefinition assets declaring the same BookTag (would make USpellbookRegistry::GetBookByTag ambiguous).
  • [x] USpellRegistry — lazy UGameInstanceSubsystem at Source/CRADL/Combat/SpellRegistry.h. EnsureBuilt on first use; scans the SpellDefinition PrimaryAssetType; indexes by asset FName. GetAllDefinitions ordered by (RequiredMagicLevel asc, FName asc)USpellbookRegistry::GetSpellsInBook filters in place and inherits the ordering, which is the spell page UI's stable display contract. Same shape as USkillRegistry per ARCH #14.
  • [x] USpellbookRegistry — lazy UGameInstanceSubsystem at Source/CRADL/Combat/SpellbookRegistry.h. Indexes USpellbookDefinition by BookTag. IsKnownBook(Tag) is the orphan-check USpellbookComponent::ApplyPersistedState uses to detect a CurrentBookTag whose book asset was deleted; GetBookByTag resolves the asset (cheap after first call — pinned by the registry's hard ref); GetSpellsInBook filters USpellRegistry by BookTag for the "all spells in this book" enumeration the UI uses. Separate subsystem from USpellRegistry so each owns its own primary-asset scan and bBuilt latch.
  • [x] USpellbookComponent — new Source/CRADL/Combat/SpellbookComponent.h:
  • [x] Lives on ACradlPlayerState (constructed in ctor; accessor GetSpellbookComponent())
  • [x] FGameplayTag CurrentBookTag — which spellbook the player is currently on
  • [x] TArray<FGameplayTag> UnlockedBooks — books the player has access to (SetCurrentBook gates on this membership)
  • [x] TSoftObjectPtr<USpellDefinition> AutocastSpell — currently-set autocast spell (null = no autocast)
  • [x] All three replicated COND_OwnerOnly via a single ReplicatedUsing=OnRep_Spellbook notify
  • [x] Server-only mutators: SetCurrentBook(NewBookTag) (gates on USpellbookRegistry::IsKnownBook AND UnlockedBooks membership; side effect: clears AutocastSpell if it doesn't belong to the new book), UnlockBook(NewBookTag) (gates on IsKnownBook; no-op when already unlocked), SetAutocastSpell(Spell) (the chokepoint for the autocast-side level gate — returns bool, rejects a non-null spell that fails HasLearned(Spell) or whose RequiredMagicLevel exceeds the player's Magic level), ApplyPersistedState(PersistedBookTag, PersistedUnlockedBooks, InAutocastSpell). Mirrors the manual-cast gates in UCastSpellAbility::ActivateAbility so cheat / future-UI / scroll-learn callers can't bypass either by routing through autocast. No swing-time re-check in UAutoAttackAbility::SnapshotSwingStats — skill level is treated as monotonic, matching UCraftAbility / UGatherAbility (gate at activation, not per tick).
  • [x] Read API: HasLearned(Spell) (derived — true iff Spell->BookTag == CurrentBookTag AND that tag still IsKnownBook in USpellbookRegistry; the registry check is what prunes a player whose CurrentBookTag was orphaned between sessions), GetKnownSpells(OutSpells) (delegates to USpellbookRegistry::GetSpellsInBook), GetAutocastSpell(), GetCurrentBookTag(), HasUnlocked(BookTag), GetUnlockedBooks().
  • [x] ApplyPersistedState orphan-handling rules (the only orphan-handling site): filter PersistedUnlockedBooks through IsKnownBook (deleted-book assets silently drop); resolve PersistedBookTag if known else fall back to UCradlCombatSettings::DefaultSpellbook's tag; union the resolved current book + the default book into UnlockedBooks so the player can always reach at least the fallback; keep AutocastSpell only if its BookTag matches the resolved current book; trigger USpellRegistry::EnsureBuilt so the swing path's AutocastSpell.Get() resolves without a sync load.
  • [x] OnSpellbookChanged non-dynamic delegate (server-side mutators + client OnRep_Spellbook both fire)
  • [x] Source-of-truth pivot: the original phase-8 sketch had LearnedSpells as an explicit per-player array (TArray<TSoftObjectPtr<USpellDefinition>>). That model required ongoing orphan-pruning on every load and couldn't model "swap to a different book" without a parallel CurrentBookTag field anyway. Landed on the book-derived model instead: HasLearned is computed from BookTag membership, persisted state is just CurrentBookTag + UnlockedBooks, and orphan-handling collapses to the single site in ApplyPersistedState. Matches the OSRS conceptual model — you don't learn spells individually, you swap spellbooks.
  • [x] UCastSpellAbility — new Source/CRADL/Abilities/CastSpellAbility.h:
  • [x] Inherits UCradlGameplayAbility, NetExecutionPolicy = LocalPredicted, InstancingPolicy = InstancedPerActor. Same policy as UCraftAbility / UGatherAbility so the production cast UI follows the same dispatch shape (client-side HandleGameplayEvent with OptionalObject = USpellDefinition*, GAS replicates the trigger payload via NetGUID). The cheat path server-fires through Server_Cheat_CastSpell — that's a valid server-side trigger of a LocalPredicted ability (no client prediction happens for the cheat, same as Debug_TriggerCraft).
  • [x] Triggers: Action.Trigger.Combat.Cast only (manual one-shot). Autocast does NOT trigger this ability per the single-loop decision; Action.Trigger.Combat.AutocastSet would activate a future tiny "set autocast state" ability (currently the cheat sets state directly via USpellbookComponent::SetAutocastSpell).
  • [x] Manual cast: validate target → validate spell learned + Magic level → validate in-range (post "Out of range." and end on fail) → consume runes (server) → roll accuracy via CradlCombatMath::RollAccuracy → on hit, roll [BaseDamage, MaxDamage] × (1 + MagicDamageBonus), apply UGE_Combat_Damage with SetByCaller(Combat.DamageType.Magic, …), fire Combat.Event.Hit, grant Magic XP; on miss, fire Combat.Event.Miss.
  • [x] ActivationOwnedTags = Action.Combat.CastSpell
  • [x] Same CancelOnTagsAdded discipline as auto-attack (Status.Stunned, Status.Dead, Action.Modal). Spell is one-shot and causes no movement of its own, so the State.Moving self-cancel footgun that applies to auto-attack / queued-interact doesn't apply here.
  • [x] Granted unconditionally from ACradlPlayerState::BeginPlay so the trigger lands without designer setup (dedupe against DefaultAbilities).
  • [x] Autocast routing inside UAutoAttackAbility:
  • [x] FAutoAttackSwingStats extended with AutocastSpell (TObjectPtr), bAutocasting (bool), MagicDamageBonus (float).
  • [x] SnapshotSwingStats reads USpellbookComponent::GetAutocastSpell() first; if set, the spell drives WeaponDamageType=Magic, SwingInterval=CastInterval, EngagementRangeCm=ResolveSpellRange, WeaponStyleLevel=MagicLevel. MainHand still contributes AttackBonus[Magic] and MagicDamageBonus if the player has a magic-flavoured weapon equipped, but autocast tolerates an unarmed caster (vanilla magic accuracy + no damage bonus) so weapon-less spell-only builds work.
  • [x] HandleSwingFinished branches: autocast → ConsumeRunesIfAutocasting (replaces ConsumeAmmoIfRanged); damage roll → RollAutocastDamage (uniform [Base, Max] × (1 + bonus)) instead of CradlCombatMath::RollDamage. Accuracy still uses RollAccuracy against the Magic axis bonuses.
  • [x] GrantStyleXp routes through CradlCombat::GrantStyleDamageXp (post-phase-5 refactor). Helper reads UCradlCombatSettings::{Aggressive,Defensive,Controlled}Routing and grants per-axis XP based on damage type and active style.
  • [x] Spellbook UIdeferred. Back-end is cheat-verifiable; CommonUI page lands when prioritised.
  • Dispatch shape mirrors UCraftingMenuWidget::DispatchCraft. The spellbook page builds an FGameplayEventData with EventTag = Action.Trigger.Combat.Cast, Target = victim pawn, OptionalObject = USpellDefinition*, and calls ASC->HandleGameplayEvent(...) on the owning client. GAS LocalPredicted plumbing replicates the trigger payload to the server via Server_TryActivateAbility; server-side OptionalObject deserializes through NetGUID. Same shape for the autocast slot setter (Action.Trigger.Combat.AutocastSet — that ability is the deferred tiny "set autocast" GA in this checklist). No new server RPCs on ACradlPlayerController needed; the ability is the dispatch boundary.
  • Load-bearing invariant: USpellRegistry must be built on both peers before the trigger fires. The NetGUID resolution for OptionalObject = USpellDefinition* only succeeds if the asset is loaded on the receiving side. Currently upheld by:
    • Server-side: USpellbookComponent::ApplyPersistedState (player profile load) calls EnsureRegistryBuilt. Server_Cheat_SetSpellbook / Server_Cheat_UnlockSpellbook resolve their tag args through USpellbookRegistry (which builds it) before calling the matching mutator. Any future "unlock spellbook from scroll" server-only path does the same.
    • Client-side: USpellbookComponent::OnRep_Spellbook calls EnsureRegistryBuilt. The future spellbook UI iterating USpellRegistry::GetAllDefinitions (to display "all known spells") naturally triggers it earlier.
  • If a new code path can fire Action.Trigger.Combat.Cast without first having gone through USpellRegistry, add an EnsureBuilt call there. A missing pin on either side means the cast trigger arrives, OptionalObject deserializes to null, and the activation no-ops without diagnostic — same silent-failure mode UCraftAbility would have if UCradlRecipeRegistry weren't built. The spellbook component's rep + persist hooks make this safe today; new entry points need to think about it.
  • [x] PersistenceUCradlPlayerProfile::CurrentBookTag (FGameplayTag), UnlockedBooks (TArray), AutocastSpell (TSoftObjectPtr). All three are additive — older saves land with the empty tag + empty array + null soft pointer, and ApplyPersistedState falls CurrentBookTag back to UCradlCombatSettings::DefaultSpellbook and unions the default into UnlockedBooks so the player can always reach at least the fallback. UCradlSaveSubsystem::SavePlayer captures via Spellbook->GetCurrentBookTag() + GetUnlockedBooks() + GetAutocastSpell(); LoadPlayer calls Spellbook->ApplyPersistedState. (Save version is at 9 from the Phase 2 death-pipeline work; no further bump here since the fields are additive and ApplyPersistedState handles the empty-on-old-saves case.)
  • [x] UCradlCombatSettings::MagicXpPerDamage added (default 2.0). Held separate from melee/ranged so magic balance is independent. Superseded post-phase-5: fields collapsed into {Aggressive,Defensive,Controlled}Routing (FStyleXpRouting) — the magic column (MagicToMagic / MagicToDefense) preserves the independence.
  • [x] CradlCombat::ResolveSpellRange added to Source/CRADL/Combat/CradlCombatRange.h — single canonical resolver (Spell->CastRangeCm if > 0, else MagicEngagementRangeCm fallback). Used by manual cast (the in-range check), the autocast swing snapshot, and (transitively) ResolveEngagementRangeForPawn at click-time when the pawn has an autocast spell set — same resolver, same number, all three gates.
  • [x] Shared cast cooldown (added 2026-05-11):
  • [x] Tag. Cooldown.Combat.Cast declared in Source/CRADL/CradlGameplayTags.h + CradlGameplayTags.cpp, registered in Config/DefaultGameplayTags.ini under a new Cooldown root. Convention-only namespace — no engine indexer scans it (unlike GameplayCue.*).
  • [x] Failure surface. ECradlAbilityFailure::Cooldown added to Source/CRADL/Player/CradlSystemMessage.h; UCradlGameplayAbility::GetGenericFailureText returns "Not ready yet." for that case. Posted via the existing PostMessage channel — no Lyra-style failure-tag map needed.
  • [x] GE. UGE_SpellCast_Cooldown in Source/CRADL/Combat/CombatGameplayEffects.h + .cpp. HasDuration, DurationMagnitude = SetByCallerFloat keyed by Cooldown.Combat.Cast (the channel tag itself, for symmetry with UGE_Combat_Damage's SetByCaller keying), granted tag routed through a UTargetTagsGameplayEffectComponent named default subobject (same pattern as UGE_Combat_InCombat / UGE_Status_Dead).
  • [x] Manual cast wiring. Source/CRADL/Abilities/CastSpellAbility.h declares overrides; .cpp sets CooldownGameplayEffectClass = UGE_SpellCast_Cooldown in the ctor, overrides ApplyCooldown to stamp SetByCaller(Cooldown.Combat.Cast, Spell->CastInterval), and overrides CanActivateAbility to post the "Not ready yet." toast when OptionalRelevantTags carries the channel tag. ActivateAbility reordered: all read-only validation gates (target, learned, magic level, in-range) run before CommitAbility so a rejected cast doesn't burn the cooldown. Spell is resolved from TriggerEventData before commit so the override can read it.
  • [x] Autocast wiring. Source/CRADL/Abilities/AutoAttackAbility.cpp HandleSwingFinished's autocast branch: before ConsumeRunesIfAutocasting, checks OwnerASC->HasMatchingGameplayTag(Cooldown.Combat.Cast) and — if the gate is up — schedules a UAbilityTask_WaitGameplayTagRemoved (with OnlyTriggerOnce=true) that calls back into HandleSwingFinished the moment the tag clears. Fires precisely on the cleared frame instead of burning a full SwingInterval waiting for the next outer WaitDelay. After a successful rune consume, applies UGE_SpellCast_Cooldown with magnitude = CurrentSwingStats.AutocastSpell->CastInterval via OwnerASC->ApplyGameplayEffectSpecToSelf. Both check + apply are server-only (HandleSwingFinished is already authority-gated above).
  • [x] Cheat commands on ACradlPlayerController:
  • [x] Debug_SetSpellbook <BookTagName>Server_Cheat_SetSpellbookSpellbook->SetCurrentBook. Tag-name resolves through USpellbookRegistry; NAME_None / Clear drops the player off any book.
  • [x] Debug_UnlockSpellbook <BookTagName>Server_Cheat_UnlockSpellbookSpellbook->UnlockBook. Grants access without switching the active book (paired with Debug_SetSpellbook for a full "learn then equip" swing).
  • [x] Debug_CastSpell <SpellAssetName> → finds nearest ATargetDummyServer_Cheat_CastSpell fires Action.Trigger.Combat.Cast with payload (target = dummy, OptionalObject = spell). Tester must stand within spell range — the ability no longer walks up; OOR fails with "Out of range."
  • [x] Debug_SetAutocast <SpellAssetName | None>Spellbook->SetAutocastSpell server-side. Pass None / Clear to clear. Surfaces a DebugScreen rejection when the spell fails HasLearned (its BookTag doesn't match the current book) or the player's Magic level is too low (mirrors the manual-cast warnings).
  • [x] Debug_PrintSpellbook → prints current book + unlocked-books list + derived learned-list (every spell in the current book) + current autocast.
  • [ ] Author ≥1 rune item + ≥1 spell asset for testing (content authoring, user-side):
  • [ ] Add a rune item row to the items DataTable (e.g. ItemId rune.air, stackable consumable).
  • [ ] Author DA_Spell_WindStrike USpellDefinition under Content/Definitions/Spells/: BookTag = Spellbook.Standard, RequiredMagicLevel 1, RuneCost {rune.air → 1}, BaseDamage 1, MaxDamage 5, CastInterval 3.0, CastRangeCm 0 (settings fallback). Run the new validator after authoring.
  • [ ] Author DA_Spellbook_Standard USpellbookDefinition under Content/Definitions/Spellbooks/ with BookTag = Spellbook.Standard; set UCradlCombatSettings::DefaultSpellbook to this asset. The derived-learned-from-membership model requires at least one book — without DefaultSpellbook set, ApplyPersistedState lands CurrentBookTag empty and HasLearned returns false for every spell until a Debug_SetSpellbook call.

Verification (after authoring at least one rune + one spell):

  • Debug_UnlockSpellbook Spellbook.StandardDebug_SetSpellbook Spellbook.StandardDebug_PrintSpellbook shows the player on the Standard book with DA_Spell_WindStrike (and every other spell tagged BookTag = Spellbook.Standard) in the derived learned-list. The default-book path means a fresh character lands on Spellbook.Standard automatically — the explicit unlock+set sequence is for testing book-swap flow.
  • Acquire 5+ air runes (cheat or manual). Debug_CastSpell DA_Spell_WindStrike from in range of a target dummy → runes decrement; magic damage applies; GameplayCue.Combat.Hit.Magic debug overlay fires; Magic XP increments per AggressiveRouting.MagicToMagic (or splits with Defense under Defensive / Controlled); Hitpoints XP also grants on damage > 0 via the existing attribute-set path.
  • Cast from out of range → "Out of range." message; no rune consume; no damage.
  • Cast with 0 air runes → "Out of runes." message; no damage.
  • Cast at Magic level below RequiredMagicLevel → "Need Magic level N to cast this spell." message.
  • Debug_SetAutocast DA_Spell_WindStrikeDebug_EngageNearest → auto-attack engages and each swing rolls magic damage (consume rune per swing); Magic XP routes; magic cue fires. Mid-engagement Debug_SetAutocast None → next swing reverts to weapon damage (snapshot at swing start picks up the change).
  • Run out of runes mid-autocast → engagement ends with "Out of runes."
  • Debug_SetCombatStyle Defensive while autocasting → next swing splits XP per DefensiveRouting.MagicToMagic / MagicToDefense (post-phase-5 update — defaults 1/1, total-preserving split of Aggressive's 2× Magic).
  • Save (autosave or quit) and reload → learned spells + autocast slot survive (verify via Debug_PrintSpellbook post-load).
  • Cast cooldown: Spam Debug_CastSpell DA_Spell_WindStrike rapidly → first cast resolves; subsequent casts within CastInterval post "Not ready yet." with no rune consume / no damage. Wait CastInterval seconds → next cast resolves. With autocast running, fire Debug_CastSpell of a different spell mid-cadence → blocked with the same toast until the autocast swing's cooldown drains. Run Debug_CastSpell with a spell whose CastInterval exceeds the autocast cadence → next autocast swing should skip (no rune consume that tick) until the manual residual clears.

Exits. All three combat styles (melee / ranged / magic) are first-class. v1 combat back-end scope is complete pending content (Phase 7 polish + per-spell content) and UI (deferred).

Footguns.

  • USpellRegistry builds lazily, not eagerly — per ARCH #14.
  • Rune cost as FName ItemId (not soft USpellDefinition ref) reuses the standard inventory consumption path. Don't introduce a parallel "spell consumable" type.
  • HP-XP grant is on the attribute set's PostGameplayEffectExecute (Phase 1), agnostic of damage type — magic damage automatically grants HP-XP because it lands as the same UGE_Combat_Damage GE. Don't double-grant inside the spell ability.
  • Autocast routes inside auto-attack, not as a parallel ability. If a future feature wants to "make cast spell into a long-running ability," resist; the cancellation paths already work because there's only one engagement loop. Adding a parallel loop reintroduces the "which ability owns engagement state right now" question.
  • Stats.bAutocasting decides the consume / damage path inside HandleSwingFinished. The two branches (rune consume + magic damage roll vs. ammo consume + weapon damage roll) must always pair correctly — don't cross-reference (e.g. magic damage with ammo consume) by re-shuffling the snapshot fields.
  • Save schema is additive — no version bump. CurrentBookTag, UnlockedBooks, and AutocastSpell deserialize as defaults on older saves (SaveVersion=9 reads as version 9; the new fields populate from CDO defaults — empty tag, empty array, null soft pointer). ApplyPersistedState then falls CurrentBookTag back to UCradlCombatSettings::DefaultSpellbook and unions the default into UnlockedBooks, so an old save lands on the default book with no extra glue. Bumping the version solely to "mark a new field" pollutes the migration channel; that's reserved for real migrations per the comment on CRADL_SAVE_VERSION.
  • Renamed / deleted spellbook asset. A persisted CurrentBookTag pointing at a deleted book asset fails IsKnownBook at ApplyPersistedState time and falls back to DefaultSpellbook; entries in UnlockedBooks that no longer resolve are silently dropped. A renamed spell asset's AutocastSpell soft pointer resolves to null and auto-attack falls through to the weapon path. Silent degradation, not a crash. Don't rename spellbook tags or spell assets without a migration plan.
  • Cheat path requires in-range. Debug_CastSpell fires Action.Trigger.Combat.Cast directly with target=dummy, but the ability no longer chases on OOR — it ends with "Out of range." Stand within spell range, or use Debug_EngageNearest first to let auto-attack's chase pull the avatar in before firing the cast.
  • USpellRegistry must be built on the side that resolves OptionalObject = USpellDefinition*. Same trap as UCradlRecipeRegistry for crafts: a NetGUID-replicated UObject pointer deserializes to null if the asset isn't loaded on the receiver. The spellbook component's OnRep_Spellbook (client) and ApplyPersistedState (server) call EnsureRegistryBuilt so the existing dispatch surfaces are covered. New entry points that fire the cast trigger must either go through USpellRegistry first (which builds it) or call EnsureBuilt explicitly.
  • Cooldown commit ordering. UCastSpellAbility::ActivateAbility runs all read-only validation gates (target, learned, magic level, in-range) before CommitAbility. The original code committed first then validated — fine when CooldownGameplayEffectClass was unset, broken once it was wired up (a "No spell selected" rejection would have burned the gate). New gates added later must slot in before CommitAbility, not after. The single legitimate post-commit failure today is rune consume inside PerformCast, which is authority-only and rare under normal play.
  • Autocast cooldown skip ≠ end. When Cooldown.Combat.Cast is present, the autocast branch in HandleSwingFinished waits on UAbilityTask_WaitGameplayTagRemoved for the tag to clear, then re-enters the swing-fire path — it does not EndSelf. The engagement loop continues until something else (target died, modal opened, player fresh-nav cancel) ends it. Don't conflate "this swing was waiting on the cooldown" with "engagement is over."
  • WaitGameplayTagRemoved::WaitGameplayTagRemove defaults OnlyTriggerOnce=false. Load-bearing for the cooldown gate: without the explicit true, the task persists after firing and re-fires on every subsequent removal of Cooldown.Combat.Cast. Long autocast engagements accumulate tasks (one per blocked swing) — the next removal triggers ALL of them simultaneously, producing a flurry of HandleSwingFinished calls with no WaitDelay between (observed as hundreds of back-to-back ranged swings when autocast is toggled off mid-engagement). Always pass OnlyTriggerOnce=true.

Phase 9 — Stat Pipeline Migration

Goal. Bring combat numerical-stat reads into alignment with the project's standard GAS pipeline so gear, ship (loadout), pilot (loadout modifier), and future buffs all compose into one number per stat. Current state: combat reads FItemRow directly, MainHand-only — silently ignoring contributions that ships, pilots, and non-MainHand gear are already authored to provide.

Rationale. Decided 2026-05-11. Contract: STAT_PIPELINE.md. Discussion archive: DesignDiscussionsArchive/APPENDIX_STAT_PIPELINE.md. Two stages, each independently shippable; together they implement the contract's Bucket 1 (buffable scalars → attributes) and Bucket 2 (matrix → equipment aggregator). Stage 3 of the contract (cross-references) is already done.

Tasks — Stage 1 (Buffable scalars → attributes). Authoritative bullet list lives in STAT_PIPELINE.md "Stage 1". Summary:

  • [x] Add Strength, RangedStrength, MagicDamage, Defense to UCradlAttributeSet (with OnRep_* and lifetime registration matching the existing pattern). All four default to 0; equipment / loadout / pilot contributions stack via standard GAS aggregation.
  • [x] Add a DefenseBonusFlat row scalar to FItemRow for the universal Defense (matrix DefenseBonus TMap stays for Stage 2).
  • [x] Add UEquipmentComponent GE-synthesis path that emits a transient infinite-duration GE per nonzero Bucket-1 row scalar on equip and removes on unequip. Four parameterised GE classes (UGE_Equip_StrengthBonus, UGE_Equip_RangedStrengthBonus, UGE_Equip_MagicDamageBonus, UGE_Equip_DefenseBonus) live in Source/CRADL/Combat/CombatGameplayEffects.h; ApplyEquipEffect synthesises a spec, sets the SetByCaller "Magnitude" from the row field, and stores the resulting handle in the existing ActiveEquipHandles[Index] array so unequip drops it through the same pipeline as authored EquipEffects. Authoring-time row fields stay; runtime reads only the AttributeSet.
  • [x] Migrate UAutoAttackAbility::SnapshotSwingStats (StrengthBonus/RangedStrengthBonus and the autocast MagicDamageBonus) and UCastSpellAbility::ResolveMagicDamageBonus to read from the AttributeSet. ResolveMagicAttackBonus is the matrix-axis read — it migrates to the aggregator in Stage 2 (the contract's "read from the AttributeSet" wording for that function would have been incorrect; tracked under Stage 2 instead). Universal Defense scalar composes on top of the matrix bonus inside UAutoAttackAbility::ResolveTargetDefense and the inline target-defense block in UCastSpellAbility::PerformCast (both dummy and player branches). Ammo's RangedStrengthBonus is now folded into RangedStrength via the equip-time synthesis at the ammo slot — the snapshot no longer sums ammo's row directly, fixing the "ammo's bonus only counts because we manually added it" coupling.
  • [x] Update Source/CRADLEditor/Validators/CradlItemTableValidator.cpp — enforce the one-path-per-stat-per-item rule (a stat is authored either as a row scalar OR via a hand-attached EquipEffects GE, never both on the same item). Implementation walks each EquipEffects GE CDO's Modifiers[], comparing each modifier's Attribute.GetUProperty() against the four Bucket-1 attribute properties; nonzero row scalar + matching GE modifier on the same item fails with a row-specific message identifying the offending field name and EquipEffects index.

Verification (Stage 1).

  • Cheat: equip a +5 strength weapon → AttrSet->GetStrength() == 5. Equip a non-MainHand item with a strength row scalar → also contributes (current MainHand-only bug fixed in passing for the scalar axes).
  • Apply a temporary "+10% magic damage" GE → AttrSet->GetMagicDamage() reflects it; spell damage in UCastSpellAbility honors it without code change.
  • Equip a ship whose InnateStatsEffect bumps Strength → contributes. Equip a pilot whose StatsEffect bumps MagicDamage → contributes. Both compose with gear contributions.
  • Validator rejects an item authoring StrengthBonus row scalar AND an EquipEffects GE that modifies Strength.

Tasks — Stage 2 (Equipment aggregator for matrices). Authoritative bullets in STAT_PIPELINE.md "Stage 2". Summary:

  • [x] Add UEquipmentComponent::SumAttackBonus(DmgType) / SumDefenseBonus(DmgType) — sum the per-Combat.DamageType.* bonus across all equipped slots. Implementation is a shared private template in Source/CRADL/Inventory/EquipmentComponent.cpp that walks every occupied slot, fetches the row through UItemRegistry, and sums the matrix entry; the two public methods just pick which row map to read.
  • [x] Migrate UAutoAttackAbility::SnapshotSwingStats (both weapon path and autocast path), UCastSpellAbility::ResolveMagicAttackBonus, and target-side defense reads (player branch) to call the aggregator. Resolves the long-standing MainHand-only authoring/runtime divergence — amulets, rings, off-hand, ammo, etc. now contribute their matrix bonuses through the same call site.
  • [x] FItemRow::AttackBonus / DefenseBonus stay in place — only the read path changes
  • [x] Combat stats debug overlay ports its parallel reads to the same surfaces (AttributeSet for Bucket 1, aggregator for Bucket 2) so the displayed numbers stay in lock-step with what the next swing will roll. Per the 6.5A footgun: any drift between the overlay and the ability is a bug.

Verification (Stage 2).

  • Equip an amulet authoring AttackBonus[Stab] = +10 → swing accuracy roll uses 10 from the amulet plus whatever the MainHand contributes (current MainHand-only bug fully fixed for the matrix axes).
  • A non-MainHand item authoring matrix bonuses now contributes (today they're silently ignored).
  • UCradlCombatMath::RollDamage remains the single damage choke point — preserve this property; it's what keeps the future per-DamageType debuff multiplier path (STAT_PIPELINE.md "Defense Granularity") a one-line formula change.

Exits. Combat numerical contributions compose through GAS in alignment with skilling/movement/loadout. Future buffs, potions, and status effects plug in as standard GEs against the buffable scalars without combat-side code changes. STAT_PIPELINE.md is fully realized; subsequent extensions (per-DamageType debuffs, target-conditional bonuses) become additive content work when the design need arrives.


Phase 10 — Style → Swing-Axis Mapping (+ DefaultStyle on Equip)

Goal. Combat style picks which Combat.DamageType.* axis a melee swing consumes, via per-weapon FItemRow::StyleDamageTypes authoring. Scimitar under Aggressive swings Slash; under Controlled swings Stab; the matrix entries authored in PROGRESSION_RECIPES.md become reachable from gameplay instead of display-only. Bundles the Phase 5 DefaultStyle-on-equip follow-up because picking up a weapon now determines the swing axis, not just XP routing.

Rationale. PROGRESSION_RECIPES.md authors all three melee AttackBonus axes per weapon (Stab/Slash/Crush) on the OSRS shape, but the swing pipeline reads only AttackBonus[WeaponDamageType] — the non-primary entries today only surface in PlayerStatsPageWidget tooltips. The fix is additive and small: one new authoring field, one new resolver helper, one snapshot-site change. Phase 5 already established that style propagation is server-authoritative via replicated loose tags and that swing stats snapshot once at swing-start; this phase reuses both invariants. Ranged and magic are untouched — OSRS has no per-style damage-type branching for either.

Session decisions (lock at start of phase):

  • StyleDamageTypes = TArray<FStyleSwingAxis>, not three scalar fields, not a TMap<FGameplayTag, FGameplayTag>. A TArray<FStyleSwingAxis> wrapper struct gives each field (Style, DamageType) its own per-field Categories meta — UE 5.4's UPROPERTY meta on TMap<FGameplayTag, FGameplayTag> filters BOTH key and value pickers to the same namespace, which prevents authoring "Style → DamageType" cleanly. Three scalars (DamageTypeAggressive / DamageTypeDefensive / DamageTypeControlled) hard-code the three-styles design at the schema layer; the wrapper struct keeps the door open. Validator enforces unique Style across entries (the only TMap invariant the array doesn't naturally satisfy).
  • Empty map falls through to WeaponDamageType. No "you must author all three styles" requirement. Most melee weapons in OSRS share an axis across two-of-three styles; some share across all three. Empty = single-type weapon, fully backwards-compatible with every existing row.
  • Resolution lives in CradlCombat::ResolveSwingDamageType, alongside ResolveActiveStyle / ResolveEngagementRange in Source/CRADL/Combat/CradlCombatRange.h. Free function, same shape as the neighbors; no method on FItemRow (the row stays a passive data struct per ARCH #5).
  • DefaultStyle on equip routes through Action.Trigger.Combat.SetStyle — the same ability the HUD button and the cheat use. Per ARCH #18 and the existing deferred note: do not add a parallel "apply default style" path in UEquipmentComponent.

Tasks.

  • [x] FStyleSwingAxis USTRUCT + FItemRow::StyleDamageTypes on Source/CRADL/Inventory/ItemRow.h: ```cpp USTRUCT(BlueprintType) struct CRADL_API FStyleSwingAxis { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadOnly, meta=(Categories="Combat.Style")) FGameplayTag Style; UPROPERTY(EditAnywhere, BlueprintReadOnly, meta=(Categories="Combat.DamageType")) FGameplayTag DamageType; };

// On FItemRow (next to DefaultStyle): UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Combat", meta=(EditCondition="WeaponDamageType.IsValid()")) TArray StyleDamageTypes; (`TMap<FGameplayTag, FGameplayTag>` filters both key and value pickers to the same `Categories` list in UE 5.4 — the wrapper struct lets each field carry its own filter.) - [x] **`CradlCombat::ResolveSwingDamageType`** — add to [Source/CRADL/Combat/CradlCombatRange.h](Source/CRADL/Combat/CradlCombatRange.h):cpp CRADL_API FGameplayTag ResolveSwingDamageType(const FItemRow& Weapon, FGameplayTag ActiveStyle); Body: scan `StyleDamageTypes` for an entry whose `Style == ActiveStyle` with a valid `DamageType`; return that, else return `Weapon.WeaponDamageType`. ~10 LOC. Linear scan is fine — at most 3 entries. - [x] **`UAutoAttackAbility::SnapshotSwingStats`** — single change in [Source/CRADL/Abilities/AutoAttackAbility.cpp](Source/CRADL/Abilities/AutoAttackAbility.cpp:126). Replace:cpp OutStats.WeaponDamageType = WeaponRow->WeaponDamageType; `` with the resolver call. Move theActiveStyleresolve up so it's available before line 126 (currently set at line 168). Every downstream consumer already readsOutStats.WeaponDamageType; no other edits in this file. - [x] **UEnemyAutoAttackAbility::SnapshotSwingStats** — same one-line change in [Source/CRADL/Enemy/EnemyAutoAttackAbility.cpp](Source/CRADL/Enemy/EnemyAutoAttackAbility.cpp) for parity (enemies typically don't toggle style —ActiveStyleresolves to silent-Aggressive, which falls through toWeaponDamageTypefor any weapon that doesn't author an Aggressive entry — but the parity keeps the two snapshot paths from drifting). - [x] **Validator update** in [Source/CRADLEditor/Validators/CradlItemTableValidator.cpp](Source/CRADLEditor/Validators/CradlItemTableValidator.cpp): - Each entry'sStylemust be underCombat.Style.;DamageTypemust be underCombat.DamageType.. *Fail* on invalid or out-of-namespace tags. - DuplicateStyleacross entries: *fail* — the resolver returns the first match; relying on entry order is a footgun the validator catches. - Each authoredDamageTypeshould appear as a key in the row'sAttackBonusmap. *Warn*: "Authored Style → DamageType but AttackBonus has no entry for that axis — swing will use a 0-bonus axis." -StyleDamageTypesshould be empty whenWeaponDamageTypeisCombat.DamageType.{Ranged, Magic}. *Warn*: "Per-style axis branching is melee-only in v1." - [x] **DefaultStyle-on-equip** (Phase 5 deferral): - [x] Hook [UEquipmentComponent::HandleSlotChanged](Source/CRADL/Inventory/EquipmentComponent.cpp): when the MainHand slot transitions to a row with a validDefaultStyle, the owning player ASC dispatchesAction.Trigger.Combat.SetStylewithInstigatorTags = { DefaultStyle }through the existingUSetCombatStyleAbilitychannel. - [x] **Only fire when noCombat.Style.*is currently active** — preserves an explicit player toggle across a weapon swap. Implementation walksCradlTags::AllCombatStyleLeaves()directly (notResolveActiveStyle, which collapses no-leaf-set to Aggressive). - [x] No mutation inUEquipmentComponent— dispatch only. The verb stays on the ability per ARCH #18. - [ ] **Authoring sweep** — populateStyleDamageTypeson every melee weapon row using the OSRS mapping table below. Single edit pass on the items DataTable; noStyleDamageTypes` entries on ranged/magic/ammo rows.

Family Aggressive Defensive Controlled
Scimitar Slash Slash Stab
Longsword Slash Slash Stab
Dagger Stab Stab Slash
Mace Crush Crush Stab
2h greatsword Slash Slash Crush
Godsword (varies — author per blade per PROGRESSION_RECIPES.md) ditto ditto
- [x] UCombatStatsDebugComponent — re-routed the displayed "damage type" row through ResolveSwingDamageType; overlay tracks style switches live. Source/CRADL/Combat/CombatStatsDebugComponent.cpp.
- [ ] UCombatStyleSelectorWidget — each style button shows a per-weapon subtitle ("Aggressive — Slash", "Controlled — Stab") when a melee weapon is equipped. Reads through ResolveSwingDamageType against the equipped MainHand row for each leaf. No-op subtitle for ranged/magic (or hide it). Without this, the new mechanic is invisible to the player and the choice is a black box.
- [x] Docs: PROGRESSION_RECIPES.md updated. Approach (b) — per-axis AttackBonus columns retained as authoring values; a per-family StyleDamageTypes mapping note added after each melee table; Field Mapping table extended; intro paragraph at "Melee Weapons" explains the new mechanic.

Verification.

  • Equip a scimitar authored with {Aggressive→Slash, Defensive→Slash, Controlled→Stab} and AttackBonus = {Stab:22, Slash:67, Crush:-2}.
  • Debug_SetCombatStyle AggressiveDebug_ToggleCombatStats shows damage type = Slash, EquipAttackBonus = 67.
  • Debug_SetCombatStyle Controlled → next swing shows damage type = Stab, EquipAttackBonus = 22 (plus any non-MainHand contributors). MaxHit recomputes accordingly.
  • Mid-engagement toggle: engage a dummy on Aggressive, swap to Controlled between swings → next hit cue fires as GameplayCue.Combat.Hit.Stab (not Slash); the target's DefenseBonus[Stab] is consulted instead of [Slash]. Verify via Phase 1's on-screen damage line.
  • Equip an unstyled dagger (no StyleDamageTypes) → swing falls through to WeaponDamageType for every style; behavior identical to pre-Phase-10.
  • Equip a bow (Ranged) → style toggles don't change the swing's damage type; ammo gate and ranged engagement range unaffected.
  • Equip a weapon with DefaultStyle = Combat.Style.Aggressive while no style is set → style auto-applies; replicated loose tag visible on the ASC, XP routes per Aggressive immediately.
  • Equip a different weapon (also with a DefaultStyle) while Combat.Style.Defensive is active → no auto-switch; the player's deliberate Defensive choice survives the swap.
  • Validator: author StyleDamageTypes[Aggressive] = Stab on a weapon whose AttackBonus[Stab] is unset → editor warning fires on save.
  • Validator: author StyleDamageTypes[Aggressive] = Slash on a row where WeaponDamageType = Ranged → editor warning fires.

Exits. Per-axis weapon attack bonuses become functionally meaningful. PROGRESSION_RECIPES authoring matches runtime behavior. The Phase 5 DefaultStyle-on-equip deferral is closed.

Footguns.

  • Resolve ActiveStyle before line 126 in SnapshotSwingStats. The current code resolves the style at line 168 (after WeaponDamageType is assigned). Move the ResolveActiveStyle call up before the resolver runs, else the snapshot uses an empty tag and always falls through to WeaponDamageType. Silent regression with no compile signal.
  • Don't add a parallel "set damage type" path. The damage type is the resolver's output — there's no separate authoring axis for "what does this weapon swing." If a future requirement wants to override the resolved type mid-swing (a "transformed-weapon" buff?), express it as a style change, not a WeaponDamageType override.
  • Don't put DefaultStyle dispatch inside UEquipmentComponent::ApplyEquipEffect. That path is for GE synthesis — adding a side-effect ability dispatch is the verb-scattering anti-pattern ARCH #18 calls out. Slot-change observer only.
  • DefaultStyle dispatch must check "no active style" first. Otherwise every weapon swap stomps the player's chosen style; the playtest signal is "I keep getting put back on Aggressive." Use CradlCombat::ResolveActiveStyle's silent-default detection (the no-leaf-set path), not a HasMatchingGameplayTag(Combat.Style.Aggressive) check (which can't distinguish "explicitly Aggressive" from "no style set").
  • StyleDamageTypes is melee-only by convention, not by enforcement. A bow author who fills it in produces dead data — the snapshot still uses Combat.DamageType.Ranged. Validator warns; no runtime guard.
  • Duplicate Style across StyleDamageTypes entries is silently first-match-wins at runtime. The validator catches this as a fail, but if it ever slips through (validator bypassed, hand-edited asset, etc.) the resolver returns whichever entry comes first in the array. Don't rely on this.
  • Per-weapon AttackRange is independent of damage type. A scimitar that swings Slash under Aggressive and Stab under Controlled uses the same AttackRange for both — ResolveEngagementRange keys on the weapon's AttackRange first, then per-type fallback. Don't author different ranges per style; there's no field for it.

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.

  • Out-of-combat HP recovery — rest stations only. Food consumption is shipped (see CONSUMABLES.md and CONSUMABLES_IMPLEMENTATION.md); food runs free of combat by contract — mid-combat eats land without disturbing the swing rhythm, no Status.InCombat gate on consume. Rest stations remain future work: no station ability, no Status.Resting shape, no regen rate.
  • NPCs / AIATargetDummy exists as a non-AI test fixture (phase 5). Real AI (perception, threat, leashing, behavior trees) is its own document and its own phase set.
  • PvP — code paths target APawn* indiscriminately, but no zone gating, no PvP-specific death drops, no P2P latency mitigation.
  • SpecialsCombat.Spec.* namespace reserved in phase 0 (parent only); no spec resource, no per-weapon spec authoring.
  • Status effects beyond Stunned — poison, freeze, bind catalog deferred. All cleanly map to Status.*.
  • Ammo recovery semantics — phase 5 treats every fired round as consumed. Ground-pickup / partial-recovery is its own design pass.
  • Loot tables — couples to AI/NPC scope.
  • Combat-level scalar — single matchmaking number (OSRS-style) — TBD whether CRADL wants one.
  • Hitsplat UI / damage feedback widgets — cue side is locked (phase 7); floating-number widget pattern is open.
  • Multi-target / AoE sizing for splash spells — USpellDefinition::Splash allows it; concrete spells TBD.
  • Equipment swap restrictions during combat — default freely allowed (OSRS standard); revisit if exploited.
  • ~~Defensive-style XP split~~ — Resolved post-phase-5: added third Combat.Style.Controlled style with 50/50 weapon-style+Defense routing across all damage types. Defensive reverted to OSRS-faithful 100% Defense for melee; ranged/magic stay split since OSRS has no pure-Defense mode for those types. See COMBAT_SYSTEM.md "Combat Styles".
  • Healing cue fork — settled: heal path stays on negative-Damage per CONSUMABLES.md "Healing Channel". The DamageDelta <= 0 short-circuit in PostGameplayEffectExecute keeps cues/XP/InCombat off the heal path. A separate Healing meta would duplicate the clamp; no longer on the deferred list.
  • Cold-storage retrieval gameplay — gravestone? recovery quest? expiry timer? PvP looting? Cold storage exists as an inventory bucket in phase 2; retrieval gameplay is downstream.

Cross-cutting verification

End-state of v1 combat is a single demo:

  1. Spawn the test map with ≥2 target dummies.
  2. Equip a melee weapon, click dummy 1 → combat starts → kill it → see XP gains.
  3. Re-equip a bow + arrows, click dummy 2 → ranged combat → run out of arrows → see message → re-supply → continue.
  4. Set autocast on a spell, click dummy 1 → spell-based combat → see Magic XP.
  5. Take damage to 0 (cheat or mutual combat with a dummy that has bite-back) → die → respawn → low-value items in cold storage.
  6. Open banking modal mid-fight → engagement cancels; reopen and fight resumes.
  7. Two more player pawns join the same dummy → 4th attacker rejected with "overwhelmed."

If all 7 work, v1 combat ships.