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++;FireReplicatedGameplayEventpayloads) - [x]
GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic}+GameplayCue.Combat.Death(C++; cue notify lookup. Lives underGameplayCue.*soUGameplayCueManagerindexes 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++;HandleGameplayEventtriggers) - [x]
Action.Combat.{AutoAttack, CastSpell, Death}(C++;ActivationOwnedTags) - [x] Status additions:
- [x]
Status.Dead(C++ + .ini;StunnedandInCombatalready 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. FirstItem.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 —USkillDefinitionreferences skills viaFPrimaryAssetId, not tag.) - [x]
FItemRowextension (Source/CRADL/Inventory/ItemRow.h): - [x]
TMap<FGameplayTag, int32> AttackBonus(keyed byCombat.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 parallelRequiredSkillLevelsfield.) - [x]
FGameplayTag DefaultStyle(initialCombat.Style.*) - [x] Validator update in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp — checks weapon rows have both
WeaponDamageType+SwingInterval, bonus map keys live underCombat.DamageType.*,DefaultStyleis aCombat.Style.*leaf if set. - [x]
UCradlCombatSettingsextension: - [x]
int32 DeathItemRetention(default 3 — placeholder, tunable via Project Settings → CRADL → Combat → Death) - [x]
FTransform DefaultRespawnTransform(placeholder identity transform; placedACradlRespawnPointin the level wins, this is the fallback) - (Originally landed on
UCradlInventorySettingsin Phase 0; moved toUCradlCombatSettingsduring Phase 2 — these are combat semantics, not inventory.) - [x] Skill assets — five
USkillDefinitiondata 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:XpCurveis optional — left null, fallback algorithmic curve inUSkillRegistryis 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]
UCradlAttributeSetextension (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_ACCESSORSfor all four - [x] Per-attribute
OnRep_*impl withGAMEPLAYATTRIBUTE_REPNOTIFY - [x]
PostGameplayEffectExecuteextension (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): fireGameplayCue.Combat.Hit.{Type}viaASC->ExecuteGameplayCue; refreshStatus.InCombatGE on both attacker and victim; grant Hitpoints XP to attacker - [x] If
Health == 0: fireCombat.Event.DeathviaFireReplicatedGameplayEvent(death ability triggers in phase 2)
- [x] Footgun guard: never write
Healthdirectly outside this method. Comment-document the invariant. - [x]
Status.InCombatGE —UGE_Combat_InCombatC++ 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.InCombatviaUTargetTagsGameplayEffectComponent(UE 5.3+ replacement for the deprecatedInheritableOwnedTagsContainer) - [x] No period
- [x]
UCradlCombatSettings—UDeveloperSettingsat Source/CRADL/Combat/CradlCombatSettings.h (Project Settings → CRADL → Combat): - [x]
int32 MaxHealthPerHitpointsLevel = 4(OSRS-ish multiplier) - [x]
TSoftObjectPtr<UCurveFloat> MaxHealthCurve(optional override; level → MaxHealth, mirrorsUSkillDefinition::XpCurve— algorithmic fallback is the primary path) - [x]
float HitpointsXpPerDamage = 1.0f(OSRS reference is ~1.33; start at 1.0) - [x] HP-XP grant —
PostGameplayEffectExecutelooks up the attacker'sUSkillsComponentand grantsfloor(DamageDealt * HitpointsXpPerDamage)toSkill.Combat.Hitpoints. Skipped when attacker == victim (cheat self-damage shouldn't pollute Hitpoints XP). - [x]
MaxHealthrecompute hook —ACradlPlayerState::BeginPlay(authority) subscribesSkillsComponent->OnLevelUpfiltered forSkill.Combat.Hitpointsand callsAttributeSet->SetMaxHealthForLevel(level), which readsUCradlCombatSettings(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 ofBeginPlayafterLoadPlayerso 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 bySavePlayerand staged viaUCradlAttributeSet::StageSavedHealthduringLoadPlayer. After the MaxHealth recompute lands,ResolveStagedHealthclamps 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 emission —
PostGameplayEffectExecutefiresGameplayCue.Combat.Hit.{Type}viaASC->ExecuteGameplayCueon the victim's ASC, withFGameplayCueParameterspopulated (Instigator, EffectCauser, Location, TargetAttachComponent, RawMagnitude=DamageDelta, AggregatedSourceTags carries the type tag). Type lookup walks the spec's SetByCallerTagMagnitudes forCombat.DamageType.*. The#if !UE_BUILD_SHIPPINGon-screen log line in the same block confirms the emission fires correctly without a notify asset present. - [ ] Hit cue notify assets — deferred to Phase 7.
UGameplayCueNotify_Static(or BP) forGameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic}andGameplayCue.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 command —
ACradlPlayerController::Debug_SelfDamage(int32 Amount, FName TypeName): - [x] Resolves
Combat.DamageType.{TypeName}, builds outgoing spec viaASC->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 viaServer_Cheat_ApplyDamageServer RPC
Verification.
- Run game, attach console.
Cheat_SelfDamage 10 Slash→ HP drops by 10; Status.InCombat tag appears (verify viashowdebug abilitysystemor 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
Healingmeta. Decide here: starting with negative-damage to keep one cue/event channel; if cue fork becomes awkward, refactor toHealingmeta 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
UInventoryComponentper ARCH #11 - [x] Default
SlotCount = 50 - [x] Created in
ACradlPlayerStateconstructor; accessorGetDeathColdStorageComponent() - [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 throughCradlCombat::RunDeathPipelinewhich 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; callCradlCombat::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::BeginPlayso Combat.Event.Death always has a listener — the BP CDO setup is optional. The C++ grant dedupes againstDefaultAbilities, so designers may also listUDeathAbilityin 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
UTargetTagsGameplayEffectComponentnamed-default-subobject pattern asUGE_Combat_InCombat(UE 5.3+ replacement for the deprecatedInheritableOwnedTagsContainer) - [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 bybDiedWhileDisconnected/ spawn-with-0-HP. - [x] Sort key:
UStorePricingSubsystem::GetSellPriceper-unit (descending).FItemRowhas noValuefield 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 explicitFItemRow::DeathValueif/when a non-tradeable-but-valuable item lands. - [x] Top-N (default 3 from
UCradlCombatSettings::DeathItemRetention) stay in their slots; rest drain intoUDeathColdStorageComponentvia the standardCanAccept/AddItems/RemoveAtSlottriplet. 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; pawnTeleportTo(ACradlRespawnPoint::ResolveRespawnTransform(PS)). - [x]
ACradlRespawnPoint— new file Source/CRADL/Combat/CradlRespawnPoint.h + .cpp: - [x] Placeable AActor with billboard (reuses the engine's
S_Playersprite) so designers can drop one in a level and see it. - [x] Static
ResolveRespawnTransform(WorldContext)— first instance found viaTActorIteratorwins; falls back toUCradlCombatSettings::DefaultRespawnTransform. Iterator cost is fine: death is a rare event. If multi-zone respawn (closest-to-player, tagged groups) ever lands, swap to aUWorldSubsystemregistry — comment in the header flags the upgrade path. - [x] Cancellation interaction —
Status.Deadadded toCancelOnTagsAddedon: - [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 trigger —
ACradlPlayerState::EndPlaychecksStatus.InCombatand runsCradlCombat::RunDeathPipeline(this)synchronously beforeSavePlayer. 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::SavePlayeralso writesbDiedWhileDisconnected = ASC->HasMatchingGameplayTag(Status.InCombat). Clean disconnect path always writesfalse(the pipeline already cleared InCombat); autosaves during combat writetrue.UCradlSaveSubsystem::LoadPlayerstashes the flag onto a transientACradlPlayerState::bPendingDisconnectDeath;BeginPlayconsumes it after MaxHealth + StagedHealth resolve and firesCombat.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 trigger —
ACradlPlayerState::BeginPlay, afterResolveStagedHealth(), firesCombat.Event.DeathifHealth <= 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 schema —
UCradlPlayerProfile: SaveVersionbumped 8 → 9 (additive only — older saves load withDeathColdStorageSlots = []andbDiedWhileDisconnected = 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 Slash→Combat.Event.Deathfires; player teleports to the placedACradlRespawnPointif any (elseUCradlCombatSettings::DefaultRespawnTransform); HP back to MaxHealth;Status.Deadflickers 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 inDeathColdStorageComponent(verify viashowdebug-style print or breakpoint on the component'sSlots.Slots). - Cold storage persists across save/load (verify via save then reload —
Profile->DeathColdStorageSlotsround-trips throughCaptureSnapshot/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.
UDeathAbilityis granted unconditionally fromACradlPlayerState::BeginPlay(with dedupe againstDefaultAbilities) so no BP CDO setup is needed; you may optionally list it in the CDO for tracking. Confirm by attachingshowdebug abilitysystemafter first BeginPlay — the spec list should includeUDeathAbilityexactly once.- Drop a
BP_CradlRespawnPoint(or the C++ACradlRespawnPointdirectly) into the test map at the desired respawn location. Without one, the pipeline falls back toUCradlCombatSettings::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.Deadis 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.InCombaton respawn would mean the player respawns with combat tag still active until 20s expiry — handled inRunDeathPipelineviaRemoveActiveEffectsWithGrantedTags. - 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), addFItemRow::DeathValueand 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"; mirrorsCradlCombat::RunDeathPipelineshape). - [x]
int32 EffectiveLevel(int32 BaseLevel, FGameplayTag ActiveStyle, FGameplayTag StyleThatBoostsThisAxis)— returnsBaseLevel + StyleBonus + 8. ~~StyleBonus = +3whenActiveStyle == 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 passesCombat.Style.Aggressivefor Attack/Strength axes;Combat.Style.Defensivefor 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-stepfloor(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 ifMaxHit ≤ 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 cheat —
ACradlPlayerController::Debug_RollSim(int32 N, int32 EffAtk, int32 EquipAtk, int32 EffStr, int32 StrengthBonus, int32 EffDef, int32 EquipDef)runs N rolls, printsAttackRoll/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
effectiveAttackLevelandeffectiveStrengthLevelderive 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
flooroperations 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
FEquipmentSlotDefentry withRequiredTag = Item.Slot.Ammoto the player pawn'sUEquipmentComponent::SlotDefs. - [x]
UEquipmentComponent::SlotAcceptshandled 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/MeetsEquipRequirementspath; 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 separatebRequiresAmmoflag. 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
FRandomStreamlives on theUAutoAttackAbilityinstance, seeded once atActivateAbility(server-only). Cheaper than per-swing reseeding, preserves entropy, matches OSRS's encounter-level continuity. Outcome correctness across peers stays handled byFireReplicatedGameplayEvent+ the damage GE's replication. - Defensive XP = full-Defense. Phase 5: 100% of style XP routes to
Skill.Combat.Defenseunder Defensive; 100% to the weapon-style skill (Melee/Ranged/Magic) under Aggressive. Post-phase-5 evolution: thirdCombat.Style.Controlledstyle added for split routing; all three styles route throughUCradlCombatSettings::{Aggressive,Defensive,Controlled}Routing(FStyleXpRouting) via the canonicalCradlCombat::GrantStyleDamageXphelper. 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) viaMinimalReplicationTags. 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, noCharacterMovementComponent) - [x] Owns
UCradlAbilitySystemComponent+UCradlAttributeSet(so damage GEs apply normally;IAbilitySystemInterfaceexposes the ASC) - [x] Engine-default Cube mesh as a placeholder so a fresh PIE drop is visible without art
- [x] Auto-respawn: bound to
Healthchange delegate; onNewValue == 0scheduleRespawnDelay(default 5s) timer that resets HP and clearsStatus.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 toUCradlCombatSettings::DefaultMaxAttackers).TryRegisterAttackeris idempotent (re-registering the same attacker on a swing-tick refresh is a no-op success). Eviction fires fromUAutoAttackAbility::EndAbility(every termination path). - [x]
UCombatTargetingComponent— new file Source/CRADL/Combat/CombatTargetingComponent.{h,cpp}: - [x] Lives on
ACradlPlayerState(constructed in ctor; accessorGetCombatTargetingComponent()) - [x]
TObjectPtr<AActor> ActiveTarget(replicated) - [x]
FGameplayTag ActiveAttackChannel— Phase 5 stores the leafCombat.DamageType.*(e.g.Combat.DamageType.Slash) verbatim. The contract mentions a 3-bucket {Melee/Ranged/Magic} collapse for AnimBP stance routing; introducing a separateCombat.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 fromUAutoAttackAbility::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.Movingdeliberately omitted — same self-cancellation footgun documented onUQueuedInteractAbility. - [x] Activate flow: validate target (alive, has ASC, in range) → snapshot stats (
SnapshotSwingStatsreturns false if no MainHand weapon — ends with "Equip a weapon to attack" message) → server-only: register onATargetDummy::CurrentAttackers(cap-hit posts "target is overwhelmed" viaPostServerMessage, ends ability) + seedFRandomStream→ setUCombatTargetingComponent::ActiveTarget+ACradlPlayerState::SetActivity→StartNextSwing. - [x]
StartNextSwingflow: 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::BeginChaseand reschedule aChasePollInterval(default 0.1s) re-check — see Phase 6.5B for the chase primitive. MirrorsUGatherAbility::StartNextTickshape; chase-poll is the equivalent of "needed-resource missing this tick" inUGatherAbility. - [x]
HandleSwingFinishedflow: validate target → authority gate (client predicts cadence; server-only roll) →ConsumeAmmoIfRanged(out-of-ammo ends with message) →ResolveTargetDefense(Phase 5 supportsATargetDummyandACradlPlayerState; future NPCs need anICombatantinterface or similar) →EffectiveLevel(with active style as the boost-axis selector) →RollAccuracy→ on hit:RollDamage, buildUGE_Combat_Damagespec withSetByCallerkeyed byWeaponDamageType, apply to target ASC viaOwnerASC->ApplyGameplayEffectSpecToTarget, fireCombat.Event.Hit,GrantStyleXp; on miss: fireCombat.Event.Miss→ re-validate target (it may have died this swing) →StartNextSwingorEndSelf. - [x] End conditions (per COMBAT_SYSTEM.md "Engagement Loop"): target invalid/dead, cancellation tag added (base-class plumbing), owner death (
Status.Deadis inCancelOnTagsAdded), modal opened (Action.Modalparent match), controller-explicit cancel (Phase 6 wires the controller path — any fresh nav input cancelsAction.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 fromENEMY_SYSTEM.mdaborts the engage branch when the pawn wanders pastLeashRadiusCm. - [x]
EndAbilitycleanup: centralizedTeardownEngagementruns on every termination path (cancel, target-dead, modal-open, owner-death). Evicts fromATargetDummy::CurrentAttackers, callsIPawnCombatant::EndChase(idempotent — no-op if no chase ran), clearsUCombatTargetingComponent::ActiveTarget(which firesOnActiveTargetChanged(nullptr)soUMovementModePolicyComponent's facing drive auto-clears), clearsFActivityDescriptor.bRegisteredOnTargetflag 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 castsInstigatorActortoACradlPlayerState, so passing the pawn would silently zero out HP-XP. Same convention asServer_Cheat_ApplyDamage. - [x] Combat styles wiring:
- [x]
Debug_SetCombatStyle Aggressive | Defensive | Controlled | Noneexec onACradlPlayerControllerbuilds the sameFGameplayEventDataas the HUD button and dispatchesAction.Trigger.Combat.SetStylethroughASC->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::SnapshotSwingStatsreads 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.SetStyletag. InstantLocalPredictedability granted inACradlPlayerState::BeginPlayalongside AutoAttack / CastSpell / Death. On authority, strips bothCombat.Style.*leaves and adds the chosen one via pairedAddLooseGameplayTag+AddReplicatedLooseGameplayTag(UE 5.4 dual-call requirement — see Footguns). HUD surface isUCombatStyleSelectorWidget, aUCradlUserWidgetwith twoBindWidgetbuttons in aUCommonButtonGroupBase(radio); embedded inUSkillsPageWidgetas aBindWidgetOptionaland bound through the page's existingBindPlayerforward. Tag-driven highlight viaRegisterGameplayTagEventso external mutations (cheat, save load) sync the selection. Persistence: active leaf saved toUCradlPlayerProfile::CombatStyleand re-applied on load via the same dual-call pair. - [ ] DefaultStyle on equip — deferred. The contract calls for
FItemRow::DefaultStyleto be applied as a replicated loose tag on first weapon equip. Per ARCH #18, this fires throughAction.Trigger.Combat.SetStyle(same ability as above) — not a directAddReplicatedLooseGameplayTagfromUEquipmentComponent'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'sDefaultStyleas 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 nearestATargetDummyviaTActorIterator(noGetAllActorsOfClass) and dispatchesAction.Trigger.Combat.EngagethroughServer_Cheat_Engageso 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 -> Yon-screen). Phase 5 swings funnel through the sameUCradlAttributeSet::PostGameplayEffectExecuteso 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.InCombatflips 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-runningDebug_EngageNearestre-engages cleanly (no stale attacker-set entries). - Walk away mid-engagement past the snapshot's resolved range (per-weapon
AttackRangeor per-type fallback — melee fallback is 250cm, ranged/magic 2000cm; resolver inCradlCombat::ResolveEngagementRange). Phase 5 ended the ability with "Target out of range."; Phase 6.5B replaced this with mid-engagement chase —IPawnCombatant::BeginChaseruns 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 cancelsAction.Combat.AutoAttack) or open a modal (Action.Modalcancel match) — see 6.5B verification for the chase-cancellation tests. - Open the banking modal mid-engagement → ability cancels (parent-tag
Action.Modalmatch inCancelOnTagsAdded). - 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 toSkill.Combat.{Melee, Ranged, Magic}based on weapon.Debug_SetCombatStyle Defensive→ melee XP routes 100% toSkill.Combat.Defense(OSRS-faithful); ranged/magic split perUCradlCombatSettings::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 throughUCradlCombatSettings::{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.MovingtoCancelOnTagsAdded. Per COMBAT_SYSTEM.md "Engagement Loop" and the existingUQueuedInteractAbilitydocumentation. - Stat snapshot timing — snapshot at the top of
StartNextSwing(not activation). Equipment / style / status changes between swings reflect in the next roll, not retroactively. EndAbilitycleanup completeness —TeardownEngagementruns on every termination path.bRegisteredOnTargetflag prevents double-eviction; do not bypass the helper.- Damage Instigator must be PlayerState, not Pawn.
UCradlAttributeSet::PostGameplayEffectExecutecastsCtx.GetInstigator()toACradlPlayerStatefor HP-XP grant; passing the pawn would silently zero out HP-XP without any compile-time signal. Same convention asServer_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
HasMatchingGameplayTagnotHasOwnedTag— replicated loose tags propagate throughMinimalReplicationTagsto all peers'GetOwnedGameplayTags, but the matching check isHasMatchingGameplayTag. Don't switch to a parent-match againstCombat.Stylewithout verifying tag-event subscription semantics (per COMBAT_SYSTEM.md "Combat Styles" — parent-cancellation is OK, parent-subscription is per-leaf). - UE 5.4
AddReplicatedLooseGameplayTagdoes not update the local count container. It only mutatesFMinimalReplicationTagCountMap; the local owned-tags count is updated solely viaUpdateOwnerTagMap()on the receiving end of replication. The server itself (which runs the auto-attack swing-start) reads false fromHasMatchingGameplayTagimmediately after the call.USetCombatStyleAbilityandUCradlSaveSubsystem::LoadPlayerwork around this by pairing every Add/Remove with the localAddLooseGameplayTag/RemoveLooseGameplayTagequivalent. 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
FContextActionflavor. The existing struct is purely tag-driven (everyIInteractablepicks its ownActionTag— gather/bank/store/ground all do this). Combat-engage is just anFContextActionwithActionTag = Action.Trigger.Combat.Engage. The "new variant" the prior pass called for would have been pure ceremony. - No standalone "hostility classification." A pawn implementing
IInteractableand returningAction.Trigger.Combat.EngagefromGetPrimaryActionTag()is the classification. The controller doesn't branch on hostility;ResolveTapActiondispatches 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 inCanInteractso the 4th attacker hears "overwhelmed" before walking. The auto-attack ability'sTryRegisterAttacker(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::ResolveEngagementRangeForPawnresolver the swing-time and click-time gates use, so all three answers stay symmetric. Guarded inInput_Click_StartedbeforeCancelQueuedInteractruns (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::CanInteractandUAutoAttackAbility::IsTargetInRangeconsult 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 theSwingIntervalpattern: per-weapon authoring with a typed runtime fallback. Validator update in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp: warn (not fail) on weapon rows withAttackRange == 0so the fallback path is intentional, not silent. - [x]
UCradlCombatSettings— added per-type fallbacksMeleeEngagementRangeCm(250),RangedEngagementRangeCm(2000),MagicEngagementRangeCm(2000). KeptEngagementRangeCm(250) as the ultimate fallback for unrecognized damage types and unarmed callers (so the click-timeCanInteractcan 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 cmplus convenienceResolveEngagementRangeForPawn(const APawn*)for callers that haven't snapshotted yet (the click-timeCanInteractpath). Resolves: autocast spell viaResolveSpellRange(when the pawn's spellbook has one set — matches the autocast-wins-over-weapon precedenceSnapshotSwingStatsuses) → weapon'sAttackRange(if > 0) → per-type fallback (Stab/Slash/Crush → Melee; Ranged → Ranged; Magic → Magic) →EngagementRangeCmultimate fallback. Single canonical resolver so any future caller gets the same answer. Free function (not aUCradlCombatSettingsmethod) matches theCradlCombat::RunDeathPipelineshape. - [x]
UAutoAttackAbility::IsTargetInRange— readsCurrentSwingStats.EngagementRangeCm(a new field onFAutoAttackSwingStatspopulated bySnapshotSwingStatsvia 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 check —
ATargetDummy::CanInteractcallsCradlCombat::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
IInteractableimpl — Source/CRADL/Combat/TargetDummy.h: - [x]
ATargetDummyimplementsIInteractable. - [x]
CanInteractgates on (1) alive (defeated →InteractCheck.Reason.Depleted, silent drop while respawn timer ticks), (2) engagement range via the resolver above, (3)WouldAcceptAttackerpeek (cap-full →InteractCheck.Reason.MissingRequirementwith "That target is overwhelmed."). LOS deferred to v1+ — gathering nodes don't trace LOS either. - [x]
BeginInteractfiresAction.Trigger.Combat.Engageon the invoker's ASC. Only reached via the legacy direct-interact path (UInteractionComponent::TryInteractWith/Debug_TryInteract); the click path reaches the same trigger viaDispatchContextActionkeyed offGetPrimaryActionTag(). Same verb, two entry points, single ability. - [x]
GetPrimaryActionTagreturnsAction.Trigger.Combat.Engage.GetInteractLabel= "Attack";GetSourceLabel= "Target Dummy". - [x]
WouldAcceptAttacker(invoker)peek — server returns true ifinvokeralready inCurrentAttackers(idempotent re-engage); otherwise compares replicatedCurrentAttackerCountagainst the resolved cap. Authoritative final check still happens inUAutoAttackAbility::ActivateAbilityviaTryRegisterAttacker. - [x] Replicated
int32 CurrentAttackerCount— mirrorsCurrentAttackers.Num()so remote clients can runWouldAcceptAttackerfromCanInteract. Updated server-side inTryRegisterAttacker/UnregisterAttacker/OnHealthChanged(death).CurrentAttackersitself stays server-only; per-attacker identity isn't useful client-side. - [x] Controller routing — Source/CRADL/Player/CradlPlayerController.cpp:
- [x] Click handler: no new code needed. The existing
ResolveTapActionalready builds anFContextActionfrom any clickedIInteractablewithActionTag = I->GetPrimaryActionTag()and dispatches viaAction.Trigger.QueuedInteract. Combat-engage rides this path automatically becauseATargetDummy::GetPrimaryActionTag()returnsAction.Trigger.Combat.Engage. - [x] Fresh-nav cancel extension —
CancelQueuedInteract()now addsAction.Combat.AutoAttackto the cancel container alongsideAction.QueuedInteract. The auto-attack ability'sAbilityTagsgot the same leaf soCancelAbilitiesmatches it (mirror ofUQueuedInteractAbility's pattern). All three existingCancelQueuedInteractcall sites (Input_Click_Started,Input_Click_Heldsteer-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_StartedbeforeCancelQueuedInteract. ReadsUCombatTargetingComponent::ActiveTarget, hit-tests the cursor, and additionally distance-checks viaCradlCombat::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 surfacing —
ResolveTapAction's "anything else (depleted / missing requirement)" else-branch now callsUCradlMessageLogSubsystem::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 inUInteractionComponent::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) andATargetDummy::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". AbilityTagsvsActivationOwnedTags.UAbilitySystemComponent::CancelAbilitiesmatches againstAbilityTags, notActivationOwnedTags. The auto-attack ability now addsAction.Combat.AutoAttackto both; if a future combat ability missesAbilityTags, the controller's fresh-nav cancel won't reach it.CurrentAttackerCountis replicated,CurrentAttackersis not. The TSet stays server-only becauseTWeakObjectPtrdoesn'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 runsTeardownEngagementwhich clearsCombatTargetingComponent::ActiveTarget; if the same-target check ran after, the comparison would always be againstnullptr.
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.UActorComponentsubclass; ticks at frame rate but throttles its render toPollIntervalSeconds(default 0.25s — combat math doesn't change between swings). All work guarded by#if !UE_BUILD_SHIPPING; the class compiles in shipping but itsTickComponentbody is empty afterSuper::TickComponent, so the component is inert in ship without changing its identity. Reads: - From
UEquipmentComponent::GetSlot(MainHand)→FItemRow*viaUItemRegistry:WeaponDamageType,SwingInterval,AttackBonus[type],StrengthBonus,RangedStrengthBonus. - From
UCradlAttributeSet:AttackSpeed,Health,MaxHealth. - From
USkillsComponent:WeaponStyleLevel,DefenseLevelvia the sameWeaponStyleSkillFormappingUAutoAttackAbilityuses (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 (mirroringSnapshotSwingStats). Both sides route throughCradlCombat::ResolveActiveStyleso the read can't drift. - From
UCombatTargetingComponent::GetActiveTarget(and a target-cast forATargetDummy::GetDummyDefenseLevel/GetDummyDefenseBonus, same path asUAutoAttackAbility::ResolveTargetDefense). - [x] Computed display rows (using
CradlCombatMathandCradlCombat::ResolveEngagementRangedirectly 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 – NfromCradlCombatMath::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). CVarcradl.CombatStatsDebug 2adds a verbose row withAttackRoll/DefenseRollints (theRollAccuracyDetailedintermediates). - 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_ToggleCombatStatsexec function: lazy-spawnsUCombatStatsDebugComponenton the local pawn the first time it's invoked, then flipsbDebugDrawon subsequent calls so the per-pawn flag persists across off/on cycles. Function declared unconditionalUFUNCTION(Exec); body lives inside the file's existing#if !UE_BUILD_SHIPPINGblock alongside the other Phase 5/6 cheat exec functions. - CVar override:
cradl.CombatStatsDebug 1forces visible regardless of the per-pawn flag;2adds 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/UFUNCTIONdecl is conditional. The component class compiles in shipping; theTickComponentbody 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_SHIPPINGblock). - [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'sDebug_SelfDamage, Phase 5'sDebug_EngageNearest/Debug_SetCombatStyle, etc.).
Verification.
- Run
Debug_ToggleCombatStatswith the slash sword from Phase 4 equipped → overlay shows weapon row readingSlash, swing interval, MaxHit driven by Strength bonus. - Without a target: target row shows "(none)" / accuracy "—".
Debug_EngageNearestor click a dummy → target row populates with name + accuracy %; withcradl.CombatStatsDebug 2, AttackRoll/DefenseRoll appear underneath.Debug_SetCombatStyle Defensive→ effective-attack row drops by 3 (style bonus moves to Defense), XP-route row flips toSkill.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'sAttackRangeor 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 0while overlay is up → on-screen entries expire after theirTimeToDisplayand the overlay disappears (per-pawn flag stays whatever it was — re-runningDebug_ToggleCombatStatsresumes 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::*andCradlCombat::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
ACradlPlayerControllerexec 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:
UFUNCTIONdeclarations stay unconditional. Only#if !UE_BUILD_SHIPPINGthe function bodies. AddOnScreenDebugMessagerenders 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:
- 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.
- AI must be able to reuse the chase logic without referencing
APlayerController. The original scope'scontroller->TryResolveMoveLocationwas player-flavored; an AI controller would have needed a parallel code path. Splitting the surface acrossIPawnCombatant(pawn-side, what abilities call) andIMoveIssuer(controller-side, the one legitimate point of polymorphism) keeps the ability code controller-flavor-agnostic. - 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 aUChaseComponent. Chase runs indefinitely onceBeginChaseis called — no internal give-up, noIsChaseFailed.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 viaSimpleMoveToActor(UAIBlueprintHelperLibrary).FCradlChaseState(Source/CRADL/Combat/CradlChaseState.h) — controller-agnostic state machine.Update(Chaser, Target, NowSeconds)returnsEAction::{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 owningFCradlChaseState+ poll timer. Lives onACradlCharacter. Tunables (MoveRefreshThresholdCm,MaxMoveRefreshHz,ChasePollHz) areEditDefaultsOnlyper pawn class. CallsIMoveIssuer::IssueMoveTofor re-issues.EndChasestops the poll timer and resets state but does NOT callStopMove— caller pairsEndChasewithStopMovementwhen it wants the pawn halted (arrival case) and leaves the pawn coasting under the prior move otherwise.
Tasks.
- [x]
IMoveIssuerinterface + impl — controller-side primitive.ACradlPlayerControllerimplements:IssueMoveToforwards to existingTryResolveMoveLocation(the path Phase 6 already used for click-to-walk);StopMoveforwards toAPlayerController::StopMovement. Public on the controller header. - [x]
IPawnCombatantinterface + impl — pawn-side surface.ACradlCharacterimplements by delegatingBeginChase/EndChase/IsChaseActivetoUChaseComponent;StopMovementforwards to the controller'sIMoveIssuer::StopMove, with a fallback toUCharacterMovementComponent::StopMovementImmediatelyif the pawn is in a possession-transition window with noIMoveIssuercontroller. - [x]
FCradlChaseStatehelper struct — drift + rate-limit logic.Update()decides per-tick whether to re-issue based on drift threshold and rate gate; returnsIdleorReissueMove. No internal closing-distance check (the prior heuristic produced too many false positives). Self-seedingBegin()so callers canUpdate()without an explicit init.Reset()clears all state. - [x]
UChaseComponent— pawn-side driver. Subobject onACradlCharacter(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 atChasePollHz(default 10Hz; per-issue rate is gated byFCradlChaseState'sMaxMoveRefreshHzregardless). - [x]
UQueuedInteractAbilityports toIPawnCombatant— replaces directcontroller->TryResolveMoveLocationwithIPawnCombatant::BeginChase.StallStartTime/StallTimeoutSecondsremoved. Arrival path addsEndChasebeforeStopMovement. 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]
UAutoAttackAbilityadds mid-engagement chase — out-of-range during the swing loop hands off toIPawnCombatant::BeginChaseand reschedules aChasePollInterval(default 0.1s) re-check.HandleSwingFinishedre-checks range at the swing-fire moment too (so a target that drifted during theSwingIntervalwindow kicks back into the chase loop instead of getting hit at distance).BeginOrContinueChasereturns false only when there's noIPawnCombatanton 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 viaCancelOnTagsAdded.TeardownEngagementcallsEndChaseon every termination path. - [x] Snapshot-before-range-check fix —
StartNextSwingnow 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 ability —
UAutoAttackAbility::ActivateAbilityremoved its activate-time range check. QueuedInteract may dispatchAction.Trigger.Combat.Engagea 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 fix —
ACradlPlayerController::OnNavRequestFinishedskips 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 viaEndAbility's teardown). - [x]
UCombatTargetingComponent::OnActiveTargetChangeddelegate — non-dynamic multicast, fires from server-sideSetActiveTarget/ClearActiveTargetand from owning-clientOnRep_ActiveTarget(bothActiveTargetandActiveAttackChannelshare the rep notify since they're written together). Subscribers can react to engagement state without tick-polling. - [x] Facing pivot — moved from ability to
UMovementModePolicyComponent—UAutoAttackAbility::ActivateAbilityno longer pokes facing directly.UMovementModePolicyComponentsubscribes toOnActiveTargetChangedand tick-rotates the pawn's yaw toward the active target while stationary (velocity ~0 / squared 2D speed belowStationarySpeedSqThreshold). While moving,bOrientRotationToMovementalready 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) isEditDefaultsOnly. Late-binding viaACradlCharacter::OnPlayerStateReplicatedcovers remote-client cases wherePlayerStatearrives after the pawn. - [x] Tick gating —
UMovementModePolicyComponentticks only whileFacingTargetis set;SetFacingTarget/ClearFacingTargetflipSetComponentTickEnabledso pawns that never engage in combat pay zero per-frame cost. - [x] All-IInteractable applicability documented —
UQueuedInteractAbility's class header notes that any future moving IInteractable (talk-to-NPC, pickpocket-NPC, etc.) inherits the moving-target tracking by virtue of routing throughIPawnCombatant. 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 cancelsAction.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.Modalparent 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
UChaseComponentcontinues polling against a null controller; the nextIssueMoveTono-ops (noIMoveIssuerto resolve), the pawn coasts on the previously-issued path, and the chase remains active until the ability is cancelled or re-possession restoresIMoveIssuer. 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.
SimpleMoveToLocationaborts 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) toState.Moving. Same self-cancellation footgun as the rest of the combat abilities: chase causes movement; aState.Movingreaction would self-terminate. StopMovement≠EndChase. Both are onIPawnCombatantand decoupled on purpose. Arrival pairs them (EndChasethenStopMovement); cancel paths typically only end chase. Don't conflate.- No internal give-up — caller owns cancellation. A
BeginChaseagainst an uncatchable target pursues forever if the caller doesn't supply a termination signal. Player pawns get this for free (any fresh nav input cancelsAction.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.
LocalPredictedabilities callBeginChaseon both peers; each peer'sUChaseComponentruns its own poll timer. Server-side movement replicates to remote peers viabReplicateMovement; 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 lettingSimpleMoveToLocation'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 isIMoveIssueron the controller, not the abilities. - Targeting subscriber bind order.
UMovementModePolicyComponent::TryBindToTargetingruns inBeginPlayifPlayerStateis already available; remote clients usually need theOnPlayerStateReplicatedfallback. 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 onClearFacingTargetkeeps 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), callSetFacingTargetdirectly; 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::StartNextSwingkicks offUAbilityTask_PlayMontageAndWaitalongside the gameplayWaitDelayas parallel decoration.PlayRate = MontageLength / WaitSec(whereWaitSec = SwingInterval / AttackSpeed) — sameESkillAnimTiming::ScaleToActionDurationshape asUGatherAbility. No completion binding —EndAbilitytears the task down for free (bStopWhenAbilityEnds=truedefault). Per-engagement async pre-load inActivateAbilityviaUAssetManager::GetStreamableManager().RequestAsyncLoadwarms the soft pointer toward residency so the first swing's.Get()usually has it. - [x]
TSoftObjectPtr<UAnimMontage> SwingMontageonFItemRow. Field at Source/CRADL/Inventory/ItemRow.h withEditCondition="WeaponDamageType.IsValid()"(only shows on weapon rows). Snapshot wiring in both subclasses —UAutoAttackAbility::SnapshotSwingStats(player; routes toUSpellDefinition::CastMontagewhen autocasting) andUEnemyAutoAttackAbility::SnapshotSwingStats(enemy) — copies the soft ref ontoFAutoAttackSwingStats::SwingMontage. Validator warning in Source/CRADLEditor/Validators/CradlItemTableValidator.cpp fires when a weapon row leavesSwingMontageempty (warn, not fail — "invisible swing" should be intentional). - [x] Death cue emit-site.
UCradlAttributeSet::PostGameplayEffectExecutecallsTargetASC->ExecuteGameplayCue(CradlTags::GameplayCue_Combat_Death, …)whenNewHealth <= 0, before firingCombat.Event.Death.FGameplayCueParameters::AggregatedSourceTagscarries 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 passesstatic_cast<float>(DamageDealt)as the magnitude arg toFireReplicatedGameplayEvent. Symmetric with victim-sideCombat.Event.Hurt. Local attacker UI still reads magnitude viaPostGameplayEffectExecute'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::HandleSwingFinishedfiresCombat.Event.Hiton every accuracy-passed swing (with magnitude per above),Combat.Event.Misson every accuracy-failed swing, and a 0-magnitude per-type hit cue viaCradlCombat::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::PostGameplayEffectExecutecallsCradlCombat::FireHitCueon the victim's ASC. Lyra pattern — type-conditional cues fire from the attribute set, not from the GE'sGameplayCuesarray (which stays empty). - Victim-side
Combat.Event.Hurt.UCradlAttributeSet::PostGameplayEffectExecutefires it withEventMagnitude = DamageDelta. Server-only; the channel AI retaliation subscribes to (seeAEnemyCharacter::HandleHurtEvent). - Engagement boundary hook.
UCradlAutoAttackAbilityBase::OnEngagementBegan(Target, WeaponDamageType)virtual on the base, fires once per engagement fromActivateAbility.OnEngagementEnded()fires fromTeardownEngagement. 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 asAnimNotify_PlaySoundinside the montage — author at frame, not as a separate cue tag. Animation-tied VFX (weapon trail flash, dust kick) authored asAnimNotify_GameplayCueinside the montage when needed. No newGameplayCue.Combat.Swing.*namespace — animation owns the swing-cosmetic timing. - [ ] Hit cue notify implementations for
GameplayCue.Combat.Hit.{Stab, Slash, Crush, Ranged, Magic}— five BP-derivedUCradlHitCueNotify_Staticassets, each settingGameplayCueTagto its leaf andHitSystemto a per-type Niagara asset. Miss-vs-hit-for-0 branches offCombat.Event.MissinFGameplayCueParameters::AggregatedSourceTags(already populated byFireHitCuewhenbWasMiss=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.InCombatviaFGameplayTagBlueprintPropertyMaprows inUCradlAnimInstance. 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.Hitpayloads 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
EndAbilitytearing both montage + timer task down. Don't add per-task cancel callbacks. - Per ARCH #16:
LocalPredictedis load-bearing for spectator AnimBPs — don't downgrade. - The damage GE's
GameplayCuesarray stays empty. Per-type hit cues fire fromPostGameplayEffectExecute(damage-landed) andHandleSwingFinished(0-magnitude); adding cues to the GE array would double-fire. - Don't drive the hit cue from an
AnimNotify_GameplayCueon 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. ReserveAnimNotify_GameplayCuefor 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 insideUAutoAttackAbility, not a separate engagement loop. When the player'sUSpellbookComponent::AutocastSpellis 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.UCastSpellAbilityis 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::ActivateAbilityvalidates in-range and posts "Out of range." if it fails. Engagement-time chase staysUAutoAttackAbility'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 intoFStyleXpRoutingstructs (Aggressive/Defensive) — magic still has its own column (MagicToMagic/MagicToDefense) so the independence holds.UAutoAttackAbility::GrantStyleXpandUCastSpellAbility::ApplyMagicDamageAndGrantXpboth route through the canonicalCradlCombat::GrantStyleDamageXphelper, which branches on damage type internally. - Per-spell cue deferred.
USpellDefinition::HitCueTagfield exists for forward-compat but Phase 8 routes hit cues through the existingUCradlAttributeSet::PostGameplayEffectExecutepathway (GameplayCue.Combat.Hit.Magicfor any magic damage). Per-spell custom cues need either aSetByCalleroverride 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 betweenUCastSpellAbilityand the autocast branch inUAutoAttackAbility; per-cast duration =USpellDefinition::CastIntervalvia 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 ownCooldown.Combat.*leaf with its own GE class —CooldownChannelfield onUSpellDefinitionlands 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.hUPrimaryDataAsset: - [x]
DisplayName,Description(multi-line),Icon(softUTexture2D) - [x]
BookTag(FGameplayTag — the spellbook this spell belongs to;USpellbookComponent::HasLearnedderives knowledge fromBookTag == CurrentBookTagmembership) - [x]
RuneCost(TMap) - [x]
BaseDamage,MaxDamage - [x]
RequiredMagicLevel - [x]
CastInterval(autocast swing cadence AND the per-cast duration of the sharedCooldown.Combat.Castgate 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 ofFItemRow::AttackRange) - [x]
HitCueTag(FGameplayTag — reserved; runtime falls back toGameplayCue.Combat.Hit.Magicuntil per-spell cue routing lands) - [x]
CastMontage(soft ref — Phase 7 authors actual content) - [x] (no
DamageTypefield — all v1 spells areCombat.DamageType.Magicper contract) - [x] (no
Splash— single-target v1; AoE deferred) - [x] Validator in Source/CRADLEditor/Validators/CradlSpellDefinitionValidator.h — DisplayName non-empty,
BookTagvalid and underSpellbook.*, RequiredMagicLevel ≥ 1, MaxDamage ≥ BaseDamage, CastInterval > 0, HitCueTag (if set) underGameplayCue.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 everyUSpellDefinition::BookTagreferences),DisplayName,Description. v1 ships one authored book (Spellbook.Standard) + the project default inUCradlCombatSettings::DefaultSpellbook, but the multi-book infrastructure (registry + per-spellBookTag+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
USpellbookDefinitionassets declaring the sameBookTag(would makeUSpellbookRegistry::GetBookByTagambiguous). - [x]
USpellRegistry— lazyUGameInstanceSubsystemat Source/CRADL/Combat/SpellRegistry.h.EnsureBuilton first use; scans theSpellDefinitionPrimaryAssetType; indexes by asset FName.GetAllDefinitionsordered by(RequiredMagicLevel asc, FName asc)—USpellbookRegistry::GetSpellsInBookfilters in place and inherits the ordering, which is the spell page UI's stable display contract. Same shape asUSkillRegistryper ARCH #14. - [x]
USpellbookRegistry— lazyUGameInstanceSubsystemat Source/CRADL/Combat/SpellbookRegistry.h. IndexesUSpellbookDefinitionbyBookTag.IsKnownBook(Tag)is the orphan-checkUSpellbookComponent::ApplyPersistedStateuses to detect aCurrentBookTagwhose book asset was deleted;GetBookByTagresolves the asset (cheap after first call — pinned by the registry's hard ref);GetSpellsInBookfiltersUSpellRegistrybyBookTagfor the "all spells in this book" enumeration the UI uses. Separate subsystem fromUSpellRegistryso each owns its own primary-asset scan andbBuiltlatch. - [x]
USpellbookComponent— new Source/CRADL/Combat/SpellbookComponent.h: - [x] Lives on
ACradlPlayerState(constructed in ctor; accessorGetSpellbookComponent()) - [x]
FGameplayTag CurrentBookTag— which spellbook the player is currently on - [x]
TArray<FGameplayTag> UnlockedBooks— books the player has access to (SetCurrentBookgates on this membership) - [x]
TSoftObjectPtr<USpellDefinition> AutocastSpell— currently-set autocast spell (null = no autocast) - [x] All three replicated
COND_OwnerOnlyvia a singleReplicatedUsing=OnRep_Spellbooknotify - [x] Server-only mutators:
SetCurrentBook(NewBookTag)(gates onUSpellbookRegistry::IsKnownBookANDUnlockedBooksmembership; side effect: clearsAutocastSpellif it doesn't belong to the new book),UnlockBook(NewBookTag)(gates onIsKnownBook; no-op when already unlocked),SetAutocastSpell(Spell)(the chokepoint for the autocast-side level gate — returnsbool, rejects a non-null spell that failsHasLearned(Spell)or whoseRequiredMagicLevelexceeds the player's Magic level),ApplyPersistedState(PersistedBookTag, PersistedUnlockedBooks, InAutocastSpell). Mirrors the manual-cast gates inUCastSpellAbility::ActivateAbilityso cheat / future-UI / scroll-learn callers can't bypass either by routing through autocast. No swing-time re-check inUAutoAttackAbility::SnapshotSwingStats— skill level is treated as monotonic, matchingUCraftAbility/UGatherAbility(gate at activation, not per tick). - [x] Read API:
HasLearned(Spell)(derived — true iffSpell->BookTag == CurrentBookTagAND that tag stillIsKnownBookinUSpellbookRegistry; the registry check is what prunes a player whoseCurrentBookTagwas orphaned between sessions),GetKnownSpells(OutSpells)(delegates toUSpellbookRegistry::GetSpellsInBook),GetAutocastSpell(),GetCurrentBookTag(),HasUnlocked(BookTag),GetUnlockedBooks(). - [x]
ApplyPersistedStateorphan-handling rules (the only orphan-handling site): filterPersistedUnlockedBooksthroughIsKnownBook(deleted-book assets silently drop); resolvePersistedBookTagif known else fall back toUCradlCombatSettings::DefaultSpellbook's tag; union the resolved current book + the default book intoUnlockedBooksso the player can always reach at least the fallback; keepAutocastSpellonly if itsBookTagmatches the resolved current book; triggerUSpellRegistry::EnsureBuiltso the swing path'sAutocastSpell.Get()resolves without a sync load. - [x]
OnSpellbookChangednon-dynamic delegate (server-side mutators + clientOnRep_Spellbookboth fire) - [x] Source-of-truth pivot: the original phase-8 sketch had
LearnedSpellsas 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 parallelCurrentBookTagfield anyway. Landed on the book-derived model instead:HasLearnedis computed fromBookTagmembership, persisted state is justCurrentBookTag+UnlockedBooks, and orphan-handling collapses to the single site inApplyPersistedState. 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 asUCraftAbility/UGatherAbilityso the production cast UI follows the same dispatch shape (client-sideHandleGameplayEventwithOptionalObject = USpellDefinition*, GAS replicates the trigger payload via NetGUID). The cheat path server-fires throughServer_Cheat_CastSpell— that's a valid server-side trigger of a LocalPredicted ability (no client prediction happens for the cheat, same asDebug_TriggerCraft). - [x] Triggers:
Action.Trigger.Combat.Castonly (manual one-shot). Autocast does NOT trigger this ability per the single-loop decision;Action.Trigger.Combat.AutocastSetwould activate a future tiny "set autocast state" ability (currently the cheat sets state directly viaUSpellbookComponent::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), applyUGE_Combat_DamagewithSetByCaller(Combat.DamageType.Magic, …), fireCombat.Event.Hit, grant Magic XP; on miss, fireCombat.Event.Miss. - [x]
ActivationOwnedTags = Action.Combat.CastSpell - [x] Same
CancelOnTagsAddeddiscipline as auto-attack (Status.Stunned,Status.Dead,Action.Modal). Spell is one-shot and causes no movement of its own, so theState.Movingself-cancel footgun that applies to auto-attack / queued-interact doesn't apply here. - [x] Granted unconditionally from
ACradlPlayerState::BeginPlayso the trigger lands without designer setup (dedupe againstDefaultAbilities). - [x] Autocast routing inside
UAutoAttackAbility: - [x]
FAutoAttackSwingStatsextended withAutocastSpell(TObjectPtr),bAutocasting(bool),MagicDamageBonus(float). - [x]
SnapshotSwingStatsreadsUSpellbookComponent::GetAutocastSpell()first; if set, the spell drivesWeaponDamageType=Magic,SwingInterval=CastInterval,EngagementRangeCm=ResolveSpellRange,WeaponStyleLevel=MagicLevel. MainHand still contributesAttackBonus[Magic]andMagicDamageBonusif 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]
HandleSwingFinishedbranches: autocast →ConsumeRunesIfAutocasting(replacesConsumeAmmoIfRanged); damage roll →RollAutocastDamage(uniform [Base, Max] × (1 + bonus)) instead ofCradlCombatMath::RollDamage. Accuracy still usesRollAccuracyagainst the Magic axis bonuses. - [x]
GrantStyleXproutes throughCradlCombat::GrantStyleDamageXp(post-phase-5 refactor). Helper readsUCradlCombatSettings::{Aggressive,Defensive,Controlled}Routingand grants per-axis XP based on damage type and active style. - [x] Spellbook UI — deferred. Back-end is cheat-verifiable; CommonUI page lands when prioritised.
- Dispatch shape mirrors
UCraftingMenuWidget::DispatchCraft. The spellbook page builds anFGameplayEventDatawithEventTag = Action.Trigger.Combat.Cast,Target = victim pawn,OptionalObject = USpellDefinition*, and callsASC->HandleGameplayEvent(...)on the owning client. GAS LocalPredicted plumbing replicates the trigger payload to the server viaServer_TryActivateAbility; server-sideOptionalObjectdeserializes 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 onACradlPlayerControllerneeded; the ability is the dispatch boundary. - Load-bearing invariant:
USpellRegistrymust be built on both peers before the trigger fires. The NetGUID resolution forOptionalObject = USpellDefinition*only succeeds if the asset is loaded on the receiving side. Currently upheld by:- Server-side:
USpellbookComponent::ApplyPersistedState(player profile load) callsEnsureRegistryBuilt.Server_Cheat_SetSpellbook/Server_Cheat_UnlockSpellbookresolve their tag args throughUSpellbookRegistry(which builds it) before calling the matching mutator. Any future "unlock spellbook from scroll" server-only path does the same. - Client-side:
USpellbookComponent::OnRep_SpellbookcallsEnsureRegistryBuilt. The future spellbook UI iteratingUSpellRegistry::GetAllDefinitions(to display "all known spells") naturally triggers it earlier.
- Server-side:
- If a new code path can fire
Action.Trigger.Combat.Castwithout first having gone throughUSpellRegistry, add anEnsureBuiltcall there. A missing pin on either side means the cast trigger arrives,OptionalObjectdeserializes to null, and the activation no-ops without diagnostic — same silent-failure modeUCraftAbilitywould have ifUCradlRecipeRegistryweren't built. The spellbook component's rep + persist hooks make this safe today; new entry points need to think about it. - [x] Persistence —
UCradlPlayerProfile::CurrentBookTag(FGameplayTag),UnlockedBooks(TArray), AutocastSpell(TSoftObjectPtr). All three are additive — older saves land with the empty tag + empty array + null soft pointer, and ApplyPersistedStatefallsCurrentBookTagback toUCradlCombatSettings::DefaultSpellbookand unions the default intoUnlockedBooksso the player can always reach at least the fallback.UCradlSaveSubsystem::SavePlayercaptures viaSpellbook->GetCurrentBookTag()+GetUnlockedBooks()+GetAutocastSpell();LoadPlayercallsSpellbook->ApplyPersistedState. (Save version is at 9 from the Phase 2 death-pipeline work; no further bump here since the fields are additive andApplyPersistedStatehandles the empty-on-old-saves case.) - [x]
UCradlCombatSettings::MagicXpPerDamageadded (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::ResolveSpellRangeadded to Source/CRADL/Combat/CradlCombatRange.h — single canonical resolver (Spell->CastRangeCmif > 0, elseMagicEngagementRangeCmfallback). Used by manual cast (the in-range check), the autocast swing snapshot, and (transitively)ResolveEngagementRangeForPawnat 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.Castdeclared in Source/CRADL/CradlGameplayTags.h + CradlGameplayTags.cpp, registered in Config/DefaultGameplayTags.ini under a newCooldownroot. Convention-only namespace — no engine indexer scans it (unlikeGameplayCue.*). - [x] Failure surface.
ECradlAbilityFailure::Cooldownadded to Source/CRADL/Player/CradlSystemMessage.h;UCradlGameplayAbility::GetGenericFailureTextreturns "Not ready yet." for that case. Posted via the existingPostMessagechannel — no Lyra-style failure-tag map needed. - [x] GE.
UGE_SpellCast_Cooldownin Source/CRADL/Combat/CombatGameplayEffects.h +.cpp.HasDuration,DurationMagnitude = SetByCallerFloatkeyed byCooldown.Combat.Cast(the channel tag itself, for symmetry withUGE_Combat_Damage's SetByCaller keying), granted tag routed through aUTargetTagsGameplayEffectComponentnamed default subobject (same pattern asUGE_Combat_InCombat/UGE_Status_Dead). - [x] Manual cast wiring. Source/CRADL/Abilities/CastSpellAbility.h declares overrides; .cpp sets
CooldownGameplayEffectClass = UGE_SpellCast_Cooldownin the ctor, overridesApplyCooldownto stampSetByCaller(Cooldown.Combat.Cast, Spell->CastInterval), and overridesCanActivateAbilityto post the "Not ready yet." toast whenOptionalRelevantTagscarries the channel tag.ActivateAbilityreordered: all read-only validation gates (target, learned, magic level, in-range) run beforeCommitAbilityso a rejected cast doesn't burn the cooldown.Spellis resolved fromTriggerEventDatabefore commit so the override can read it. - [x] Autocast wiring. Source/CRADL/Abilities/AutoAttackAbility.cpp
HandleSwingFinished's autocast branch: beforeConsumeRunesIfAutocasting, checksOwnerASC->HasMatchingGameplayTag(Cooldown.Combat.Cast)and — if the gate is up — schedules aUAbilityTask_WaitGameplayTagRemoved(withOnlyTriggerOnce=true) that calls back intoHandleSwingFinishedthe moment the tag clears. Fires precisely on the cleared frame instead of burning a fullSwingIntervalwaiting for the next outerWaitDelay. After a successful rune consume, appliesUGE_SpellCast_Cooldownwith magnitude= CurrentSwingStats.AutocastSpell->CastIntervalviaOwnerASC->ApplyGameplayEffectSpecToSelf. Both check + apply are server-only (HandleSwingFinishedis already authority-gated above). - [x] Cheat commands on
ACradlPlayerController: - [x]
Debug_SetSpellbook <BookTagName>→Server_Cheat_SetSpellbook→Spellbook->SetCurrentBook. Tag-name resolves throughUSpellbookRegistry;NAME_None/Cleardrops the player off any book. - [x]
Debug_UnlockSpellbook <BookTagName>→Server_Cheat_UnlockSpellbook→Spellbook->UnlockBook. Grants access without switching the active book (paired withDebug_SetSpellbookfor a full "learn then equip" swing). - [x]
Debug_CastSpell <SpellAssetName>→ finds nearestATargetDummy→Server_Cheat_CastSpellfiresAction.Trigger.Combat.Castwith 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->SetAutocastSpellserver-side. PassNone/Clearto clear. Surfaces a DebugScreen rejection when the spell failsHasLearned(itsBookTagdoesn'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_WindStrikeUSpellDefinition underContent/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_StandardUSpellbookDefinition underContent/Definitions/Spellbooks/withBookTag = Spellbook.Standard; setUCradlCombatSettings::DefaultSpellbookto this asset. The derived-learned-from-membership model requires at least one book — withoutDefaultSpellbookset,ApplyPersistedStatelandsCurrentBookTagempty andHasLearnedreturns false for every spell until aDebug_SetSpellbookcall.
Verification (after authoring at least one rune + one spell):
Debug_UnlockSpellbook Spellbook.Standard→Debug_SetSpellbook Spellbook.Standard→Debug_PrintSpellbookshows the player on the Standard book withDA_Spell_WindStrike(and every other spell taggedBookTag = Spellbook.Standard) in the derived learned-list. The default-book path means a fresh character lands onSpellbook.Standardautomatically — the explicit unlock+set sequence is for testing book-swap flow.- Acquire 5+ air runes (cheat or manual).
Debug_CastSpell DA_Spell_WindStrikefrom in range of a target dummy → runes decrement; magic damage applies;GameplayCue.Combat.Hit.Magicdebug overlay fires; Magic XP increments perAggressiveRouting.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_WindStrike→Debug_EngageNearest→ auto-attack engages and each swing rolls magic damage (consume rune per swing); Magic XP routes; magic cue fires. Mid-engagementDebug_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 Defensivewhile autocasting → next swing splits XP perDefensiveRouting.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_PrintSpellbookpost-load). - Cast cooldown: Spam
Debug_CastSpell DA_Spell_WindStrikerapidly → first cast resolves; subsequent casts withinCastIntervalpost "Not ready yet." with no rune consume / no damage. WaitCastIntervalseconds → next cast resolves. With autocast running, fireDebug_CastSpellof a different spell mid-cadence → blocked with the same toast until the autocast swing's cooldown drains. RunDebug_CastSpellwith a spell whoseCastIntervalexceeds 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.
USpellRegistrybuilds lazily, not eagerly — per ARCH #14.- Rune cost as
FName ItemId(not softUSpellDefinitionref) 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 sameUGE_Combat_DamageGE. 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.bAutocastingdecides the consume / damage path insideHandleSwingFinished. 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, andAutocastSpelldeserialize as defaults on older saves (SaveVersion=9reads as version 9; the new fields populate from CDO defaults — empty tag, empty array, null soft pointer).ApplyPersistedStatethen fallsCurrentBookTagback toUCradlCombatSettings::DefaultSpellbookand unions the default intoUnlockedBooks, 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 onCRADL_SAVE_VERSION. - Renamed / deleted spellbook asset. A persisted
CurrentBookTagpointing at a deleted book asset failsIsKnownBookatApplyPersistedStatetime and falls back toDefaultSpellbook; entries inUnlockedBooksthat no longer resolve are silently dropped. A renamed spell asset'sAutocastSpellsoft 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_CastSpellfiresAction.Trigger.Combat.Castdirectly with target=dummy, but the ability no longer chases on OOR — it ends with "Out of range." Stand within spell range, or useDebug_EngageNearestfirst to let auto-attack's chase pull the avatar in before firing the cast. USpellRegistrymust be built on the side that resolvesOptionalObject = USpellDefinition*. Same trap asUCradlRecipeRegistryfor crafts: a NetGUID-replicated UObject pointer deserializes to null if the asset isn't loaded on the receiver. The spellbook component'sOnRep_Spellbook(client) andApplyPersistedState(server) callEnsureRegistryBuiltso the existing dispatch surfaces are covered. New entry points that fire the cast trigger must either go throughUSpellRegistryfirst (which builds it) or callEnsureBuiltexplicitly.- Cooldown commit ordering.
UCastSpellAbility::ActivateAbilityruns all read-only validation gates (target, learned, magic level, in-range) beforeCommitAbility. The original code committed first then validated — fine whenCooldownGameplayEffectClasswas unset, broken once it was wired up (a "No spell selected" rejection would have burned the gate). New gates added later must slot in beforeCommitAbility, not after. The single legitimate post-commit failure today is rune consume insidePerformCast, which is authority-only and rare under normal play. - Autocast cooldown skip ≠ end. When
Cooldown.Combat.Castis present, the autocast branch inHandleSwingFinishedwaits onUAbilityTask_WaitGameplayTagRemovedfor the tag to clear, then re-enters the swing-fire path — it does notEndSelf. 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::WaitGameplayTagRemovedefaultsOnlyTriggerOnce=false. Load-bearing for the cooldown gate: without the explicittrue, the task persists after firing and re-fires on every subsequent removal ofCooldown.Combat.Cast. Long autocast engagements accumulate tasks (one per blocked swing) — the next removal triggers ALL of them simultaneously, producing a flurry ofHandleSwingFinishedcalls with noWaitDelaybetween (observed as hundreds of back-to-back ranged swings when autocast is toggled off mid-engagement). Always passOnlyTriggerOnce=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,DefensetoUCradlAttributeSet(withOnRep_*and lifetime registration matching the existing pattern). All four default to 0; equipment / loadout / pilot contributions stack via standard GAS aggregation. - [x] Add a
DefenseBonusFlatrow scalar toFItemRowfor the universalDefense(matrixDefenseBonusTMap stays for Stage 2). - [x] Add
UEquipmentComponentGE-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;ApplyEquipEffectsynthesises a spec, sets the SetByCaller"Magnitude"from the row field, and stores the resulting handle in the existingActiveEquipHandles[Index]array so unequip drops it through the same pipeline as authoredEquipEffects. Authoring-time row fields stay; runtime reads only the AttributeSet. - [x] Migrate
UAutoAttackAbility::SnapshotSwingStats(StrengthBonus/RangedStrengthBonusand the autocastMagicDamageBonus) andUCastSpellAbility::ResolveMagicDamageBonusto read from the AttributeSet.ResolveMagicAttackBonusis 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). UniversalDefensescalar composes on top of the matrix bonus insideUAutoAttackAbility::ResolveTargetDefenseand the inline target-defense block inUCastSpellAbility::PerformCast(both dummy and player branches). Ammo's RangedStrengthBonus is now folded intoRangedStrengthvia 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
EquipEffectsGE, never both on the same item). Implementation walks each EquipEffects GE CDO'sModifiers[], comparing each modifier'sAttribute.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 inUCastSpellAbilityhonors it without code change. - Equip a ship whose
InnateStatsEffectbumpsStrength→ contributes. Equip a pilot whoseStatsEffectbumpsMagicDamage→ contributes. Both compose with gear contributions. - Validator rejects an item authoring
StrengthBonusrow scalar AND anEquipEffectsGE that modifiesStrength.
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 throughUItemRegistry, 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/DefenseBonusstay 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::RollDamageremains 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 aTMap<FGameplayTag, FGameplayTag>. ATArray<FStyleSwingAxis>wrapper struct gives each field (Style,DamageType) its own per-fieldCategoriesmeta — UE 5.4's UPROPERTY meta onTMap<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 uniqueStyleacross 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, alongsideResolveActiveStyle/ResolveEngagementRangein Source/CRADL/Combat/CradlCombatRange.h. Free function, same shape as the neighbors; no method onFItemRow(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 inUEquipmentComponent.
Tasks.
- [x]
FStyleSwingAxisUSTRUCT +FItemRow::StyleDamageTypeson 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(`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}andAttackBonus = {Stab:22, Slash:67, Crush:-2}. Debug_SetCombatStyle Aggressive→Debug_ToggleCombatStatsshows 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'sDefenseBonus[Stab]is consulted instead of[Slash]. Verify via Phase 1's on-screen damage line. - Equip an unstyled dagger (no
StyleDamageTypes) → swing falls through toWeaponDamageTypefor 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.Aggressivewhile 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.Defensiveis active → no auto-switch; the player's deliberate Defensive choice survives the swap. - Validator: author
StyleDamageTypes[Aggressive] = Stabon a weapon whoseAttackBonus[Stab]is unset → editor warning fires on save. - Validator: author
StyleDamageTypes[Aggressive] = Slashon a row whereWeaponDamageType = 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
ActiveStylebefore line 126 inSnapshotSwingStats. The current code resolves the style at line 168 (afterWeaponDamageTypeis assigned). Move theResolveActiveStylecall up before the resolver runs, else the snapshot uses an empty tag and always falls through toWeaponDamageType. 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
WeaponDamageTypeoverride. - 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 aHasMatchingGameplayTag(Combat.Style.Aggressive)check (which can't distinguish "explicitly Aggressive" from "no style set"). StyleDamageTypesis melee-only by convention, not by enforcement. A bow author who fills it in produces dead data — the snapshot still usesCombat.DamageType.Ranged. Validator warns; no runtime guard.- Duplicate
StyleacrossStyleDamageTypesentries 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
AttackRangeis independent of damage type. A scimitar that swings Slash under Aggressive and Stab under Controlled uses the sameAttackRangefor both —ResolveEngagementRangekeys on the weapon'sAttackRangefirst, 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.InCombatgate on consume. Rest stations remain future work: no station ability, noStatus.Restingshape, no regen rate. - NPCs / AI —
ATargetDummyexists 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. - Specials —
Combat.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 toStatus.*. - 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::Splashallows 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.Controlledstyle 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-
Damageper CONSUMABLES.md "Healing Channel". TheDamageDelta <= 0short-circuit inPostGameplayEffectExecutekeeps cues/XP/InCombat off the heal path. A separateHealingmeta 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:
- Spawn the test map with ≥2 target dummies.
- Equip a melee weapon, click dummy 1 → combat starts → kill it → see XP gains.
- Re-equip a bow + arrows, click dummy 2 → ranged combat → run out of arrows → see message → re-supply → continue.
- Set autocast on a spell, click dummy 1 → spell-based combat → see Magic XP.
- Take damage to 0 (cheat or mutual combat with a dummy that has bite-back) → die → respawn → low-value items in cold storage.
- Open banking modal mid-fight → engagement cancels; reopen and fight resumes.
- Two more player pawns join the same dummy → 4th attacker rejected with "overwhelmed."
If all 7 work, v1 combat ships.