* 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
308 lines
20 KiB
Markdown
308 lines
20 KiB
Markdown
# 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.
|