Files
UnrealPrototyping/Docs/Spec/Stats.md
T
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

Rewrites the design and engineering docs of the two earlier Unity projects
(Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that
builds and proves three things in order: a movement controller, fighting and
crafting. Neither earlier core loop is carried; their ideas are catalogued as
features with what each needs.

- Docs/Design.md: the human summary (pillars, fixed decisions, glossary)
- Docs/Steps.md: a ladder of fifteen proofs, the first six in full
- Docs/Ideas.md: salvaged ideas from both projects, none scheduled
- Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals
- Docs/Spec/: notation, Architecture, Movement, Interaction, Combat,
  Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons
- CLAUDE.md and README.md for the repository


* Add the stat block: one attribute set on every body, every influence an effect

Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and
every outside influence on it is a gameplay effect: base values, buffs,
debuffs, ground surfaces, carried weight, gear, being downed. Counters are
tags and attributes on the receiver: immunity blocks by tag, resistance
scales by attribute, cleanse removes by tag. Same status from several
sources: strongest wins; different statuses multiply.

- Docs/Spec/Stats.md: the block, effects and their five sources (abilities,
  areas, surfaces, items, the world), stacking, counters, readers, tests
- Movement: the movement component's tagged speed-multiplier map is removed;
  speed is tuning times the MoveSpeed attribute; the ground surface trace
  turns mud into an effect; the gym gets mud and ice patches
- Combat: the attribute set moves to Stats; enemies and kits carry
  FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags;
  taunt and mark are statuses
- Interaction: carried weight is GE_Encumbered against CarryCapacity
- Crafting: class bonuses are the WorkSpeed and WorkQuality attributes;
  enchantments may grant a holder effect
- Steps: step 5 builds the block with mud, a volume and an immunity
- Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
2026-09-15 17:54:00 +03:00

308 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Stats and effects
Owns the one stat block every body carries, the vocabulary of effects that change it, where effects come from
(abilities, areas, surfaces, items, the world), how they stack, and the counters (immunity, resistance, cleanse)
that stop them. It does **not** own what any stat means to a system: the movement component reads `MoveSpeed`
([Movement.md](Movement.md)), the damage funnel reads `Armour` ([Combat.md](Combat.md)), the activity runtime
reads `WorkSpeed` ([Crafting.md](Crafting.md)). This document says what the numbers are and how they change; the
system docs say what they do.
Read [Architecture.md](Architecture.md) first. Built in step 5, before combat, because the first debuff and the
first patch of mud must land on a system that already exists rather than each inventing one.
## The problem this exists to prevent
Every system that can affect a body is tempted to keep its own number: a speed multiplier on the movement
component, a slow timer on the enemy brain, a carry penalty on the carry component, a mud check in the character.
Then the goblin is not slowed by the mud the player is slowed by, the haste buff cannot cancel the carry penalty
because they live in different places, and nobody can say why a body is at forty percent speed. That is the
spaghetti, and it is not hypothetical: the first draft of [Movement.md](Movement.md) had exactly that multiplier
map. It is gone.
```
[DECIDED] One stat block, and everything with a body has it.
Players, enemies, NPCs: one UAttributeSet on the body's ability system component, the same class for all of
them. A thing that does not have one cannot be affected, and giving it one is how a thing is made affectable.
A wooden crate that should burn gets a stat block; a wall that should not does not.
```
```
[DECIDED] Every outside influence is a gameplay effect on the block. No exceptions.
A buff, a debuff, muddy ground, carried weight, an aura, gear, a class's base numbers, being downed: each is a
UGameplayEffect applied to the body's component. No system writes an attribute. No system keeps its own list
of modifiers. If you find yourself adding `float SpeedMultiplier` to a component, stop: it is an effect.
```
```
[DECIDED] Counters live on the receiver, as tags and attributes.
Immunity blocks an effect by tag. Resistance scales an effect by attribute. Cleanse removes effects by tag.
A counter is therefore something a kit, an enchantment, a piece of gear or an ability GRANTS, and it works
against every source of that status at once, including ones written next year. Every status has a visible
tell, and being immune to one is visible too.
```
```
[DECIDED] The same status from several sources: the strongest applies and the longest remaining duration is
kept. Different statuses multiply. A slow and a haste both apply and the product is the speed.
```
## Layout
```
Source/<Project>Core/Stats/
├── StatMath.h / .cpp // pure: Resisted(magnitude, kind, resist)
└── CarryMath.h / .cpp // pure: SpeedFactor(weight, capacity)
Source/<Project>/Stats/
├── StatBlockAttributeSet.h // the block
├── StatBlockDefaults.h // FStatBlockDefaults
├── ExtendedAbilitySystemComponent.h // input-tag routing (Combat.md), ApplyDefaults, ApplyStatus
├── EffectVolume.h // areas
└── PhysicalMaterialWithTags.h // surfaces
Content/Stats/
├── Effects/GE_InitStats, GE_Status_Slow, GE_Status_Haste, GE_Encumbered, GE_Downed, GE_Immune_Slow, GE_Cleanse ...
├── Cues/GC_Status_*, GC_Status_Blocked
└── DT_StatusIcons // Status.* tag -> icon, read by the HUD
```
## The block
One `UAttributeSet` subclass, `UStatBlockAttributeSet`, in `Source/<Project>/Stats/`. Multipliers have a
baseline of one; flat values of zero; fractions run from zero to one. Every attribute clamps in
`PreAttributeChange` to the range in the table, so no effect can push a body into nonsense.
| Attribute | Kind | Baseline | Range | Read by |
| --- | --- | --- | --- | --- |
| `Health`, `MaxHealth` | flat | from defaults | 0 .. Max | Combat: downed at zero |
| `HealthRegen` | flat per second | 0 | 0 .. | a periodic regen effect, when one exists |
| `Stamina`, `MaxStamina`, `StaminaRegen` | flat | from defaults | 0 .. Max | reserved; nothing draws yet |
| `MoveSpeed` | multiplier | 1 | 0 .. 3 | Movement: every state's speed times this |
| `JumpPower` | multiplier | 1 | 0 .. 3 | Movement: jump velocity times this |
| `AttackPower` | multiplier | 1 | 0 .. 5 | Combat: outgoing damage |
| `AttackSpeed` | multiplier | 1 | 0.25 .. 3 | Combat: wind-up and swing divided by this |
| `Armour` | flat | 0 | 0 .. | Combat: mitigation on physical damage |
| `MagicResist` | fraction | 0 | 0 .. 0.9 | Combat: mitigation on magic damage |
| `StatusResist` | fraction | 0 | 0 .. 0.9 | Stats: scales every incoming status toward neutral |
| `KnockbackResist` | fraction | 0 | 0 .. 1 | Combat: scales impulses; 1 is unmovable |
| `WorkSpeed` | multiplier | 1 | 0.25 .. 3 | Crafting: activity baseline duration divided by this |
| `WorkQuality` | flat | 0 | -0.5 .. 0.5 | Crafting: added to an activity's quality floor |
| `CarryCapacity` | flat | 1 | 0 .. | Interaction: weight a body carries before slowing |
| `IncomingDamage`, `IncomingHeal` | meta | | | the funnel writes, `PostGameplayEffectExecute` consumes, never replicated |
The block is the whole list of what an outside influence may touch. A system that wants to be affected by
something not on it adds an attribute here, in its own pull request, with a row in this table and a reader. A
system that wants to affect something reads this table to learn the name, and never invents a second one.
### Base values are an effect too
```cpp
USTRUCT(BlueprintType)
struct FStatBlockDefaults // the numbers a designer authors on a kit or an enemy definition
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly) float MaxHealth = 100.f;
UPROPERTY(EditDefaultsOnly) float MaxStamina = 100.f;
UPROPERTY(EditDefaultsOnly) float Armour = 0.f;
UPROPERTY(EditDefaultsOnly) float AttackPower = 1.f;
UPROPERTY(EditDefaultsOnly) float MoveSpeed = 1.f;
UPROPERTY(EditDefaultsOnly) float CarryCapacity = 1.f;
// and so on for every non-meta attribute, each with the table's baseline as its default
};
// UExtendedAbilitySystemComponent
void ApplyDefaults(const FStatBlockDefaults& Defaults);
// One GE_InitStats with an Override modifier per attribute, magnitudes SetByCaller from the struct, applied
// once on grant (a kit) or spawn (an enemy), then Health = MaxHealth and Stamina = MaxStamina.
// One way in, so that everything after the defaults is an effect like everything else.
```
A kit's `BaseStats` and an enemy definition's `BaseStats` are both this struct. Designers edit numbers; code
applies them through one effect; there is no per-kit or per-enemy effect asset to keep in step.
## Effects
An effect in this project is a `UGameplayEffect` asset that follows one shape, so that every reader (the HUD,
the cleanse, the immunity, telemetry) can treat them alike:
| Part | Engine mechanism | Rule here |
| --- | --- | --- |
| What it is | asset tags (`UAssetTagsGameplayEffectComponent`) | exactly one `Status.*` tag; `Status.Buff` or `Status.Debuff` as its parent |
| What it grants while active | granted tags (`UTargetTagsGameplayEffectComponent`) | the same `Status.*` tag, so `HasTag(Status.Slow)` answers "is this body slowed" |
| What it changes | modifiers | attributes from the table only; multiply for multipliers, add for flats |
| How strong | magnitude | `SetByCaller` under `Data.Magnitude`, or attribute-based when the source's stats matter |
| How long | duration policy | instant (a hit), timed (a debuff), infinite (ground, weight, a state); never a hand-rolled timer |
| Over time | period | periodic effects tick the funnel or a regen; never a tick in a component |
| What it looks like | gameplay cues | `GameplayCue.Status.<Name>` while active: the visible tell, on every peer |
| Who it stacks with | stacking | aggregate by target, limit one; the helper below decides who wins |
```cpp
// UExtendedAbilitySystemComponent, the one helper every status goes through
FActiveGameplayEffectHandle ApplyStatus(TSubclassOf<UGameplayEffect> Status, float Magnitude, float DurationSeconds,
const FGameplayEffectContextHandle& Context);
// SERVER, or a locally predicted ability applying to its own owner.
// 1. If this body is immune (the effect's asset tags match an active immunity), the engine blocks it and raises
// OnImmunityBlockGameplayEffectDelegate; play GameplayCue.Status.Blocked, emit status_blocked, return null.
// 2. Magnitude = StatMath::Resisted(Magnitude, kind, StatusResist): mult' = 1 + (mult - 1) * (1 - resist);
// flat' = flat * (1 - resist). Pure, in the core module, tested.
// 3. If an active effect with the same Status.* tag exists: keep the stronger magnitude and the longer remaining
// duration, refreshed, and return the existing handle. Strongest wins; durations do not add.
// 4. Otherwise apply with SetByCaller Data.Magnitude and Data.Duration. Emit status_applied.
```
The `Status.*` vocabulary, top level. A status is a name for an effect's *kind*, not for its source: a slow from
mud and a slow from a frost bolt are both `Status.Slow` and the counter for one is the counter for the other.
```
Status.Buff.* Haste, Fortified, Empowered, Regenerating, Focused (work speed), Steady (work quality)
Status.Debuff.* Slow, Rooted, Weakened, Exposed (armour down), Poisoned, Burning, Marked, Taunted, Stunned, Encumbered
```
`State.*` tags are different and stay: they describe what a body *is* (`State.Downed`, `State.Carrying`,
`State.Dodging`, `State.Invulnerable`), granted by the systems that own those states. Some states are also
effects (downed applies `GE_Downed`, which overrides `MoveSpeed` to zero); the tag says what the body is, the
effect says what it does to the numbers.
## Where effects come from
Five sources, and every one ends in the same call on the receiver's component.
| Source | Mechanism | Who applies | Example |
| --- | --- | --- | --- |
| **An ability** | the ability applies to its target or itself | server; the owner predicts self-applied ones | Haste on self; Weakened on a struck enemy; Taunted on everything in range |
| **An area** | `AEffectVolume`: an actor with an overlap shape, an effect class and a magnitude; applies on enter, removes on leave, with the handle kept per body | server | a frost cloud, a healing circle, a poison bog |
| **A surface** | `UPhysicalMaterialWithTags` on the floor material carries `Surface.*` tags and an effect; the movement component's ground trace applies it when the surface changes and removes it when it changes again | server | mud slows, ice removes braking, a hot plate burns |
| **An item or a kit** | granted on equip or grant, removed on unequip; infinite duration | server | a kit's base stats; an enchanted blade's `Immune.Status.Burning`; a class's `WorkSpeed` |
| **The world** | the same as an ability, from a non-body instigator | server | fall damage through the funnel; carried weight through `GE_Encumbered` |
```cpp
UCLASS()
class AEffectVolume : public AActor
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> Effect; // a Status.* effect
UPROPERTY(EditAnywhere) float Magnitude = 0.6f; // Data.Magnitude
UPROPERTY(EditAnywhere) bool bAffectsPlayers = true, bAffectsEnemies = true;
UPROPERTY(VisibleAnywhere) TObjectPtr<UShapeComponent> Shape;
// Server: OnActorBeginOverlap -> if the actor has a component, ApplyStatus(Effect, Magnitude, infinite) and
// remember the handle by body; OnActorEndOverlap -> RemoveActiveGameplayEffect(handle). A body that dies inside
// is cleaned up by the component's own teardown. Nothing here ticks.
};
UCLASS()
class UPhysicalMaterialWithTags : public UPhysicalMaterial
{
GENERATED_BODY()
UPROPERTY(EditAnywhere) FGameplayTagContainer Tags; // Surface.Mud, Surface.Ice, Surface.Hot
UPROPERTY(EditAnywhere) TSubclassOf<UGameplayEffect> SurfaceEffect; // optional: what standing on it does
UPROPERTY(EditAnywhere) float SurfaceMagnitude = 1.f;
};
// UExtendedCharacterMovement::UpdateGroundSurface(), server, on floor change (a short trace with
// bReturnPhysicalMaterial at ~5 Hz, the same trace footsteps will use): if the surface's effect differs from the
// one applied, remove the old and ApplyStatus the new. Enemies use the same movement component, so the goblin
// is slowed by the same mud, which is the whole point.
```
Carried weight is the world case: `UCarryComponent` applies `GE_Encumbered` on pick-up with a magnitude from
`CarryMath::SpeedFactor(weight, CarryCapacity)` (pure, in the core module) and removes it on drop. A haste that
multiplies `MoveSpeed` therefore counteracts a heavy crate exactly as it counteracts mud, because they are the
same number.
## Counters
Three kinds, each doing one thing, each granted like any other effect so that a kit, an enchantment or a piece of
gear can hand it out.
| Counter | Mechanism | Effect on the incoming status | Example grant |
| --- | --- | --- | --- |
| **Immunity** | an infinite `GE_Immune_<Status>` with an immunity component (`UImmunityGameplayEffectComponent`) matching `Status.<Name>` | blocked entirely; the blocked cue plays; nothing is applied | a boss immune to `Status.Taunted`; boots granting `Immune.Status.Slow`; a dodge's `State.Invulnerable` |
| **Resistance** | the `StatusResist` attribute (and `MagicResist`, `KnockbackResist` for their kinds) | scaled toward neutral by the helper before application | a Warrior's base 0.25 status resist; a Fortified buff |
| **Cleanse** | an instant effect with a remove-by-tag component (`URemoveOtherGameplayEffectComponent`) matching `Status.Debuff` | every active debuff removed | the Cleric's Mend removes one debuff; a shrine removes all |
| **Suspension** | ongoing tag requirements on the status (`UTargetTagRequirementsGameplayEffectComponent`) | the effect stays applied but stops modifying while a tag is present | `Status.Slow` suspended while `State.Dodging` |
Immunity tags are `Immune.Status.<Name>` and `Immune.Damage.<Type>`; the damage funnel reads the second kind and
discards matching damage, so fire immunity is one tag and not a branch in every fire ability. Being immune is
never silent: the blocked cue is the "Immune" flash every player has seen in every game with a status system.
**Counters are obvious by construction.** A player learns that mud slows and that the frost cloud slows; the
same boots stop both, because both are `Status.Slow`. If two things that feel the same need different counters,
they are different statuses and should be named so.
## Stacking
- **One status class per kind.** Every slow is `GE_Status_Slow`; a frost bolt and a mud patch differ in magnitude,
duration and cue context, never in class. This is what makes the helper's strongest-wins rule possible without
a custom aggregator.
- **Strongest wins within a status**, longest remaining duration is kept, durations never add. Three goblins
hitting you with the same slow is one slow.
- **Different statuses multiply.** `MoveSpeed` is the product of every active multiplier on it; a 0.6 slow and a
1.3 haste give 0.78. That is the engine's default aggregation for multiplicative modifiers and it is kept.
- **Flat values add.** Armour from gear and Armour from Fortified sum.
- **Overrides win and do not stack.** Downed overrides `MoveSpeed` to zero whatever else is applied.
## Who reads what
The readers, so that a new effect knows what it will move and a new reader knows what to read.
| System | Reads | How |
| --- | --- | --- |
| Movement | `MoveSpeed`, `JumpPower` | `GetMaxSpeed` = the state's tuning speed × `MoveSpeed`; `JumpZVelocity` × `JumpPower`; nothing else scales movement |
| Combat, outgoing | `AttackPower`, `AttackSpeed` | the funnel captures `AttackPower`; the swing divides its timings by `AttackSpeed` |
| Combat, incoming | `Armour`, `MagicResist`, `KnockbackResist`, `Immune.Damage.*` | the funnel and the impulse |
| Crafting | `WorkSpeed`, `WorkQuality` | the activity runtime: baseline duration ÷ `WorkSpeed`; quality floor + `WorkQuality`. This is where "class bonuses, never locks" lives: a class is a `BaseStats` with better work numbers and nothing else |
| Interaction | `CarryCapacity` | `CarryMath::SpeedFactor` |
| The HUD | active `Status.*` tags, remaining durations | a row of status icons from a tag-to-icon table on the theme |
| Enemies | everything, the same way | an enemy body's component is the same class with the same block |
## Networking
| State | Authority | Mechanism |
| --- | --- | --- |
| Attributes | Server | replicated attribute set, `REPNOTIFY_Always` so the HUD sees every change |
| Effects from own abilities | Client predicts, server confirms | the engine's effect prediction |
| Effects from areas, surfaces, other players, the world | Server | applied on the server; the attribute replicates |
| Immunity and resistance | Server | the helper runs where the effect is applied |
| Cues | Everywhere | unreliable multicast; presentation only |
The one cost worth naming: a server-applied speed change lands on the owning client one round trip after the
server saw it, and the client's predicted moves in between are corrected. At 100 ms that is a few centimetres at
the moment you step into mud, smoothed by the engine. Accepted; it is the tier-two rule from
[Networking.md](Networking.md) applied to a number instead of an object. If it ever reads as a snag, the owning
client may predict *surface* effects (it knows the floor too); nothing else.
## Telemetry
| Event | When | Payload |
| --- | --- | --- |
| `status_applied` | The helper applies or strengthens a status | `status`, `magnitude`, `duration`, `source_kind` (ability, area, surface, item, world), `source`, `target_kind` |
| `status_blocked` | Immunity blocks one | `status`, `immunity`, `source_kind` |
| `status_removed` | Expiry, cleanse or leaving the source | `status`, `cause`, `time_active_s` |
`status_applied` by `source_kind` against `target_kind` is the first thing to look at when a fight feels unfair:
it says whether players or enemies are the ones spending the fight slowed.
## Tests
- Automation: `CarryMath::SpeedFactor` (capacity and above is one; heavier is slower; never below the floor);
`StatMath::Resisted` (a 0.6 slow at 0.5 resist is 0.8; a flat -10 at 0.5 is -5; 1.0 resist is neutral); the
clamp ranges in the table.
- Functional: a body in `AEffectVolume(Slow, 0.6)` moves at 60 %; a second stronger volume raises nothing when
weaker and replaces when stronger; leaving restores 100 %; a body with `GE_Immune_Slow` in the same volume
moves at 100 % and the blocked cue fired; walking onto the gym's mud slows a player and a goblin alike; a haste
applied while encumbered multiplies rather than replacing.
## Open questions
- **Q1. Should the owning client predict surface effects?** Not until the correction on entering mud is felt.
The trace runs on both; the effect application is the only thing that would move client-side.
- **Q2. Resistances per damage type.** `Armour` and `MagicResist` cover physical and magic. Fire, frost and
poison as damage types with their own resist would each be a row in the table; add them when a second
elemental type exists, not before.
- **Q3. A block on things that are not bodies.** A wooden crate that burns, a door that freezes shut. The rule
says give it a component and a block; the cost is a component per affectable prop. Decide with the first one.
- **Q4. Stamina as a cost.** Reserved in the block; whether sprint or abilities draw from it is Movement Q3 and
Combat Q2, and the answer is a cost effect either way.