diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41d7941 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# Unreal prototype + +An Unreal Engine 5 project, C++ first, that builds and proves three things in order: a movement controller, +fighting, and crafting. The engine version and the project name are open decisions (OD-01, OD-02 in +[`Docs/Decisions.md`](Docs/Decisions.md)); pin both here in step 1. Until the `.uproject` exists, `` +in the docs stands for its name. + +## Before you start + +Read [`Docs/Spec/README.md`](Docs/Spec/README.md) (the notation and the twelve cross-cutting rules) and +[`Docs/Spec/Architecture.md`](Docs/Spec/Architecture.md) before writing or changing any code. Nearly every wiring +decision follows from them. + +For *what* to build next, read [`Docs/Steps.md`](Docs/Steps.md). It is a ladder, not a roadmap: each rung says +what exists afterwards, links the spec sections that define it, and states the proof that closes it. The spec +docs under `Docs/Spec/` are the specification; each is C++-shaped pseudocode plus the reasoning, and the +pseudocode is meant to be implemented rather than admired. If a step and its spec disagree, the spec wins and the +step is corrected. + +The human summary is [`Docs/Design.md`](Docs/Design.md). Ideas from the two earlier projects that are not built +are in [`Docs/Ideas.md`](Docs/Ideas.md); do not build one inside a step about something else. + +## Where the project actually is + +Nothing is built. The repository holds the documentation set and a stock `.gitignore`. Step 1 (the project, two +modules, tests, a dedicated server in the editor) is next and waits on OD-01 and OD-02. Update this section when a +step closes: what exists, what the next step is. + +## Layout + +Planned; created by step 1. The `.uproject` sits at the repository root, which is what the `.gitignore` assumes. + +| Path | What it is | +| --- | --- | +| `.uproject`, `Source/.Target.cs`, `Editor.Target.cs`, `Server.Target.cs` | The project and its three build targets | +| `Source/Core/` | Rules, data types, tags, the telemetry contract. Knows no `AActor`. Tests in `Tests/` | +| `Source//` | Gameplay: `Core/`, `Stats/`, `Movement/`, `Interaction/`, `Combat/`, `Crafting/`, `UI/`, one folder per feature | +| `Content//` | Assets per feature: `Definitions/` for data assets, Blueprints, meshes, montages | +| `Content/Maps/L_Gym` | The movement test level. Never leaves the project | +| `Content/Tests/` | Functional test maps | +| `Config/Tags/.ini` | Gameplay tag source of truth, one file per top-level namespace | +| `Scripts/` | `run-tests.sh`, `build.sh`, and `Authoring/` for editor Python scripts. Run by hand; there is no CI | +| `Docs/` | The documentation set. Update it when you change how something works | +| `Docs/Worklog.md` | One terse line per step closed or wall hit | + +Module dependency is one way: `` depends on `Core`, never the reverse. If the core module seems +to need an actor, the thing it needs is data. + +## Conventions + +- **The server decides.** Every mutation is a `Server*` path that validates its instigator and re-checks its + preconditions, even in standalone play. The client predicts its own movement and its own animation, nothing + else. Never write a listen-server assumption or an "is this the host" branch. +- **Rules are pure C++ in the core module; actors and components adapt them.** A preview and the real thing call + the same function. Every pure rule has automation tests, one per rejection reason. +- **One funnel per consequence.** All damage through `UDamageExecution`; all interactions through + `UInteractionSubsystem`; all activity quality through `FActivityResult`. Do not add a second path. +- **One stat block; everything is an effect on it.** Every body has `UStatBlockAttributeSet`; every buff, + debuff, surface, weight, gear and base value is a gameplay effect applied through `ApplyStatus` or + `ApplyDefaults`. Never add a multiplier, a modifier list or a status timer to a component. Counters are + `Immune.*` tags and resist attributes on the receiver. See `Docs/Spec/Stats.md`. +- **Content is data.** `UPrimaryDataAsset` subclasses, addressed by gameplay tag or primary asset id, art through + soft references. No string ids. If a variant needs code, the model is wrong. +- **Gameplay Tags are the vocabulary.** Defined in `Config/Tags/`; tags code references are declared natively + with `UE_DECLARE_GAMEPLAY_TAG_EXTERN` / `UE_DEFINE_GAMEPLAY_TAG`. No string literal tag at a call site. +- **C++ owns rules, replicated properties and RPCs. Blueprints own composition, tuning and cosmetics.** A + Blueprint is a child of a C++ class that sets assets and numbers and wires `*_Cosmetic` hooks. +- **Lifetimes are the engine's.** Game-instance subsystems for the application, world subsystems for a map, + the player state for a player across bodies, the pawn for a body. Nothing about a player that would matter + tomorrow lives on the character actor. +- **No world searches in gameplay code.** No `GetAllActorsOfClass`, no static gameplay singletons. Subsystems are + the registries; actors register in `BeginPlay` and unregister in `EndPlay`. +- **Every rejection carries a reason tag** a player can read. **Nothing is destroyed silently.** +- **Emit telemetry through `UTelemetrySubsystem` with names from `TelemetryEvents`**, never a literal, never a + static helper. Every feature emits; the catalogue in `Docs/Spec/Telemetry.md` and the feature spec agree. +- **Naming is Unreal's:** `A`/`U`/`F`/`E`/`I` prefixes, no project prefix on classes, `PascalCase` members, asset + prefixes `BP_ DA_ DT_ IA_ IMC_ GA_ GE_ GC_ ABP_ AM_ SK_ SM_ M_ MI_ WBP_ T_ L_`. Placeholders carry `_Proto`. +- **Units are centimetres and seconds.** `FText` for anything a player reads. `TObjectPtr` for object properties. +- **The design word "material" is "substance"** in code and docs. `UMaterial` is the engine's. +- Do not add an interface, a module or an abstraction for one implementation with no test double. The two source + projects were each trimmed of speculative abstraction once already; the interfaces that exist here are the ones + with several implementations named in the specs. + +## Commands + +Created in step 1; the shapes are fixed now so the docs can refer to them. + +```bash +Scripts/run-tests.sh # headless: UnrealEditor-Cmd .uproject -ExecCmds="Automation RunTests ; Quit" -unattended -nopause -NullRHI +Scripts/run-tests.sh Core # a filter: .Core.* +Scripts/build.sh Win64 Development # UAT BuildCookRun for the game target; add -server for the server target once the engine is a source build +``` + +There is no CI, deliberately, and there will not be until there is a reason. Run the tests yourself before saying +a step is done. Every replicated step is also played in the editor with two clients, "Run Dedicated Server" on, and +network emulation at 100 ms and 5 % loss with `p.NetShowCorrections 1`. + +## Working with the editor + +- Prefer editing C++ and `.ini` files directly. Blueprints and maps are binary; keep them thin and keep logic out + of them so a diff can be reviewed. +- Batch authoring (a folder of definition assets, a greybox level from a table) goes through the editor's Python + API in `Scripts/Authoring/`, idempotent, kept so it can be rerun. Do not hand-edit `.uasset` files. +- Blueprint-side wiring an authoring script cannot express is done once in the editor and recorded in the step's + worklog line so it can be redone. +- Watch for the compile trap: a C++ error that stops the editor launching is fixed in the files, with the build + output, not by guessing. The editor's Live Coding is fine for iteration and unreliable for header changes; a + header change means a full rebuild. +- Every `.uasset` and `.umap` goes through Git LFS; `.gitattributes` is created in step 1. Run `git lfs install` + once per machine. Never commit `Saved/`, `Intermediate/`, `DerivedDataCache/` or `Binaries/`; the `.gitignore` + covers them. + +## Documentation upkeep + +- A step that lands updates: its status in `Docs/Steps.md`; a **What was built, and where it differs** section at + the end of the spec it built from; a line in `Docs/Worklog.md`; a line in `Docs/Decisions.md` if a decision was + taken; and the "Where the project actually is" section above. +- A design change that contradicts a decision names the decision it supersedes in the log. Never delete a log + line. +- Do not turn `Docs/Steps.md` into a roadmap. Detail the next step when the current one closes, not before. diff --git a/Docs/Decisions.md b/Docs/Decisions.md new file mode 100644 index 0000000..b63399a --- /dev/null +++ b/Docs/Decisions.md @@ -0,0 +1,98 @@ +# Decisions + +One line per decision, newest first, with a pointer to where it lives in full. A decision belongs here the moment +it is made, even if the doc it affects has not caught up. Never delete an entry; supersede it with a newer one and +say so. + +Status: `Decided` · `Supersedes` (contradicts an earlier decision, names it) · `Deferred` (deliberately not now, +with a trigger) + +## 2026-09-15 — The stat block + +Taken on the same day, after the first read of the doc set. + +| # | Decision | Where | +| --- | --- | --- | +| D-38 | **Counters live on the receiver as tags and attributes**: immunity blocks by tag, resistance scales by attribute, cleanse removes by tag, and every status and every block is visible. The same status from several sources: strongest wins, longest remaining duration; different statuses multiply. | [Stats](Spec/Stats.md) | +| D-37 | **One stat block on everything with a body, and every outside influence is a gameplay effect on it.** Base values, buffs, debuffs, ground, carried weight, gear and being downed all go through the same effects; no system keeps its own multiplier. `Supersedes` the tagged speed-multiplier map the first draft of Movement gave the movement component. | [Stats](Spec/Stats.md) | + +## 2026-09-15 — The move to Unreal and the reframing + +Taken while rewriting the two earlier projects' documentation into this one. Everything below is `Decided` +unless marked. + +| # | Decision | Where | +| --- | --- | --- | +| D-01 | **Unreal Engine 5, C++ first.** Blueprints compose, tune and decorate; they never hold a rule, a replicated property or a server RPC. | [Architecture](Spec/Architecture.md) | +| D-02 | **Two runtime modules**, `Core` (rules, data, tags, telemetry contract; no actors) and `` (gameplay). Dependency one way. A third arrives when a feature is stable, never before. | [Architecture](Spec/Architecture.md) | +| D-03 | **Server-authoritative from the first line; the dedicated server is the real target.** No listen-server design, no "are we multiplayer" branch. Tested with the editor's dedicated server option from step 1. | [Networking](Spec/Networking.md) | +| D-04 | **The client predicts only its own movement and its own animation.** Three responsiveness tiers assigned by one test: does a rollback have a visible victim? No rollback of world state a player can already see. | [Networking](Spec/Networking.md) | +| D-05 | **Ask, then show.** Commit-shaped moments wait for the server inside their own feedback beat. Never show a number as final before it is committed. | [Networking](Spec/Networking.md) | +| D-06 | **A player is an identity, not a body.** Class, attributes, cooldowns, discoveries and identity live on the player state; the character actor is lent. | [Networking](Spec/Networking.md), [Architecture](Spec/Architecture.md) | +| D-07 | **Persistence behind one provider interface with a local-file implementation.** Nothing is persisted yet; the seam exists first. | [Networking](Spec/Networking.md) | +| D-08 | **Gameplay Tags are the vocabulary.** `Config/Tags/.ini` is the source; code-referenced tags are declared natively. No string ids anywhere. | [Architecture](Spec/Architecture.md) | +| D-09 | **Content is `UPrimaryDataAsset`s** addressed by primary asset id or tag, with soft references for art. | [Architecture](Spec/Architecture.md) | +| D-10 | **Rules are pure functions in the core module**, tested without a world, called by both the client preview and the server verdict. | [Architecture](Spec/Architecture.md) | +| D-11 | **Telemetry from step 2**, one subsystem, one sink interface, JSON Lines to a local file, no personal data, events not aggregates. | [Telemetry](Spec/Telemetry.md) | +| D-12 | **`UCharacterMovementComponent`, extended**, not a custom controller and not the Mover plugin. Sprint is a saved-move flag; dodge and blink are root-motion abilities. | [Movement](Spec/Movement.md) | +| D-13 | **One rig, two camera modes.** First and third person on the same mannequin; the camera never enters the authority path; both modes exist from step 3; the default is decided by playing (OD-03). | [Movement](Spec/Movement.md) | +| D-14 | **The deadzone look model**, salvaged: the head turns freely inside a yaw deadzone, then the body follows; the deadzone widens while carrying. | [Movement](Spec/Movement.md) | +| D-15 | **Sprint takes Shift.** The earlier four-slot layout (Q, E, Shift, R) becomes Q, E, R, C. Interact F, drop G tap, throw G hold. `Supersedes` the earlier project's Controls decision. | [Movement](Spec/Movement.md) | +| D-16 | **The controller is built first and proved against a written checklist in a gym level** that never leaves the project. | [Movement](Spec/Movement.md), [Steps](Steps.md) | +| D-17 | **One interaction system.** An interface, a world subsystem as the funnel, the player's component as the only RPC path; reach re-checked server-side with tolerance; prompt and permission from one function. | [Interaction](Spec/Interaction.md) | +| D-18 | **Interaction is a discrete act on a fixed prop; carrying is continuous possession.** Pick-up is routed by the interaction key but is a carry verb. | [Interaction](Spec/Interaction.md) | +| D-19 | **Two hands; two-handed objects block interaction and, in first person, the view.** Held objects attach and stop simulating; dropped and thrown ones are server physics. Throwing is in and scrappy; nothing is destroyed by it. | [Interaction](Spec/Interaction.md) | +| D-20 | **The Gameplay Ability System** for attributes, abilities, effects, cooldowns and cues. The component is on the player state for players and on the pawn for enemies. | [Combat](Spec/Combat.md) | +| D-21 | **One damage funnel**: one execution calculation every damaging effect uses. **No friendly fire**: the funnel discards player-on-player damage and keeps the impulse. | [Combat](Spec/Combat.md) | +| D-22 | **Down before death.** Bleed-out, revive by anyone, a solo down is an instant wipe. A downed player is a prop. | [Combat](Spec/Combat.md) | +| D-23 | **Kits define ability access; gear defines stats and crosses classes; mods never grant a signature.** The `bSignature` flag exists from the first ability; gear and mods do not. | [Combat](Spec/Combat.md) | +| D-24 | **The recoverability contract** applies to any verb that inconveniences a teammate: recovery takes less time than the verb, and none may down, kill or deprive. All emit `grief_action`. | [Combat](Spec/Combat.md) | +| D-25 | **Sublinear party scaling and role relaxation** are reserved rules applied to a count. | [Combat](Spec/Combat.md) | +| D-26 | **Part, piece, trait.** A family declares parts; a piece fills one and carries three trait layers. No recipes anywhere. | [Crafting](Spec/Crafting.md) | +| D-27 | **"Material" is "substance"** in code and docs, because `UMaterial` is the engine's. | [Crafting](Spec/Crafting.md) | +| D-28 | **Substances are objects, not stacks**, with identity, quality and provenance. Affordable only because storage is physical; an abstract bank is refused on that ground. | [Crafting](Spec/Crafting.md) | +| D-29 | **Discovery unlocks, choice applies.** Characteristics are discovered through play and chosen at the station. Known set is per player. | [Crafting](Spec/Crafting.md) | +| D-30 | **One activity runtime, three guarantees**: completion never gated on skill; skill modulates a quality band only; a second operator is additive, never required. The anvil is the growing zone; the forge is a 5×5 bed with two levels of detail bound by an equivalence rule. | [Crafting](Spec/Crafting.md) | +| D-31 | **The assembly bench is domain-neutral; domains gate the work, never the worker.** Class changes speed and quality only. | [Crafting](Spec/Crafting.md) | +| D-32 | **Durability is per part, summed; the item is lost at zero; nothing wears from crafting.** | [Crafting](Spec/Crafting.md) | +| D-33 | **A crafted weapon-family item is a weapon**: both authored and crafted weapons produce one `FWeaponProfile` that the swing, the funnel and the bar read. `[PROPOSED]` until step 13 closes. | [Crafting](Spec/Crafting.md), [Combat](Spec/Combat.md) | +| D-34 | **UMG with CommonUI; one theme asset by role; prop text is world-space with no canvas; every readable string is `FText` from a string table.** The menu never pauses the world. | [UI](Spec/UI.md) | +| D-35 | **Steps, not a roadmap.** A ladder closed by proofs; the next few steps concrete, the rest sketched and detailed only when reached. | [Steps](Steps.md), [Design](Design.md) | +| D-36 | **The two earlier loops are not carried.** Their ideas are catalogued as features with needs and constraints, none scheduled. | [Ideas](Ideas.md) | + +## Open decisions + +Questions that block steps. Each names what it blocks and the recommendation on record, so closing one is a +confirmation, not a fresh discussion. When one is taken it moves up as a D-line and the row is marked closed. + +| id | Question | Blocks | Recommendation | Status | +| --- | --- | --- | --- | --- | +| OD-01 | **Engine version.** | 1 | The newest release with a hotfix out (5.6.x at the time of writing). Pin it in `CLAUDE.md` and the `.uproject` in step 1; upgrade deliberately, never mid-step. | Open | +| OD-02 | **Project and module name.** | 1 | Short, one word, no spaces. It becomes the `.uproject`, the module prefix and the test filter root. The docs write `` until it exists. | Open | +| OD-03 | **Default camera mode.** | none until 8 | Build both in step 3, play the gym and the first fight in each, let `camera_mode_changed` and `movement_sample` settle it by step 8. | Recommended | +| OD-04 | **Engine from source.** | the first packaged server | The launcher build until then; the editor's dedicated-server option covers every step in the ladder. | Recommended | +| OD-05 | **Placeholder character and animations.** | 3 | The engine's Third Person template mannequin and its animation blueprint; a retargeted pack only when the swing needs a clip the template lacks. | Recommended | +| OD-06 | **Physics replication mode for thrown objects.** | 6 | The engine's predictive interpolation mode; resimulation is not needed for objects nobody predicts. | Recommended | +| OD-07 | **Which two kits ship first.** | 9 | Warrior and Cleric: a taunt and a heal are what make the goblin's threat table and the downed state legible. | Recommended | +| OD-08 | **Crafting professions against combat kits.** | after 15 | A design session, not a step. See Ideas. | Open | + +## Deferred, with triggers + +Not gates. Listed so nobody reopens them as if they were undecided. + +| Deferred | Until | +| --- | --- | +| The Mover plugin | It leaves experimental and a movement feature needs what it has (Movement Q2) | +| Stamina as a cost | A kit wants one (Combat Q2); the attribute exists so it is a cost effect, not a refactor | +| A blade sweep instead of the box hit check | Real animation makes the box read wrong (Combat Q4) | +| Storage beyond hands and station buffers | A step needs more than hands can hold (Crafting Q4, Ideas) | +| Alloys and the kiln | Post-fifteen; meanwhile nobody hard-codes one-to-one processing (Crafting Q5) | +| Generated glyph discovery | Its own session; the composition path never changes, any seed is global | +| Reconnect into a running session | Written down before the first playtest with strangers (Networking Q1) | +| Seamless travel, a second map | The second map | +| Replication Graph or Iris | A world with many actors and many players; dormancy and relevancy keep it cheap | +| Steam, lobbies, invitations, voice | A session layer in front of the server; never inside gameplay | +| Continuous integration | A reason for it; tests run by hand until then | +| Resistances per damage type beyond physical and magic | A second elemental damage type (Stats Q2) | +| Predicting surface effects on the owning client | The correction on entering mud is felt (Stats Q1) | +| A stat block on props that are not bodies | The first prop that should burn or freeze (Stats Q3) | diff --git a/Docs/Design.md b/Docs/Design.md new file mode 100644 index 0000000..fac03dd --- /dev/null +++ b/Docs/Design.md @@ -0,0 +1,109 @@ +# Design + +A fantasy world you move through, fight in and craft for. This project builds those three things, in that order, +and proves each one feels right before anything is built around it. There is no game loop yet, no economy, no +hall, no dungeon, and no roadmap that says when there will be. The world this is headed for is large, persistent +and shared, so nothing built now may assume otherwise; and nothing built now needs to be that yet. + +This is the human summary. The specifications an implementer works from are in [`Spec/`](Spec/README.md), the +order of work is in [`Steps.md`](Steps.md), and the ideas carried over from the two earlier projects but not yet +built are in [`Ideas.md`](Ideas.md). + +## The three things + +Each has a rejection clause, because a pillar that never rules anything out is decoration. + +| | What it means | What we say no to | +| --- | --- | --- | +| **Move** | A body that feels good to walk, run, jump and look with, in first or third person, on a server that has the final say. Proved in a level made of nothing but movement problems, against a written checklist, with a person at the keyboard. | Movement the client owns. Movement tuned once against one enemy and never written down. A camera the server has to know about. A speed number that lives anywhere but the stat block. | +| **Fight** | Melee that lands where the animation says, one funnel every point of damage goes through, enemies with a readable tell, classes with a signature nobody else can have, and going down before dying so a friend can pick you up. | Friendly fire. A second damage path. A class that is a stat block. An ability that writes health directly. | +| **Craft** | Items assembled from pieces, each with a shape, a substance and an ornament; a name and a quality derived from what you put in, never picked from a list; hands-on station work where skill raises the ceiling and never gates the floor; and a crafted sword that is a sword when you swing it. | Recipes. Stacks. A minigame you can fail. A crafting system that does not know combat exists. | + +The order is the dependency: fighting needs a body, crafting needs something to make weapons for. It is also the +risk order. The controller is the cheapest thing to get wrong and the most expensive to fix late. + +## Fixed decisions + +Settled, with the reason, so nobody relitigates them in month four. The full log with ids is +[`Decisions.md`](Decisions.md). + +| Decision | Why | +| --- | --- | +| **Unreal Engine 5, C++ first** | The engine's native answers (subsystems, Gameplay Ability System, Gameplay Tags, character movement, replication) are the systems the earlier projects hand-built in Unity. Blueprints compose and tune; they never hold a rule. | +| **The server decides, always** | Every mutation is validated on the authority; the client predicts only its own body and its own animation. The dedicated server is the real target; standalone and listen-server are dev conveniences. This is the one posture that stays right as the world grows. | +| **One funnel per consequence** | All damage through one execution, all interactions through one service, all activity quality through one result. A second path is where rules quietly diverge. | +| **One stat block, and everything is an effect on it** | Every body, player or not, carries the same set of numbers. Mud, a debuff, a buff, a heavy crate, gear and a class's base values are all effects on that set, so anything that can affect one body affects every body, and a counter (immunity, resistance, cleanse) works against every source at once. No system keeps a number of its own. | +| **Rules are pure and shared** | A preview and the real thing call the same function. Preview and reality cannot disagree, and the same rules run headless on a server and in a test. | +| **Content is data, addressed by tag** | A new sword, enemy or substance is an asset. Strings never identify anything. | +| **One rig, two cameras** | First and third person on the same mannequin; the camera is presentation and never touches the simulation. Which is the default is decided by playing, not arguing. | +| **Bonuses, never locks** | Class, level and gear change speed, quality and numbers. They never change what a station accepts or what an assembly permits, and never grant another class's signature. | +| **Solo is a gate, not a mode** | Every rule is checked against one player. Where it costs a solo player, the doc names the knob. | +| **Telemetry from the first line** | Events into a no-op sink from step 2. The questions this prototype exists to answer are distributions over sessions, and sessions that never emitted are gone. | +| **A player is an identity, not a body** | Class, progression and discoveries live on the player state and behind a persistence seam, never on the character actor. A body is a thing the world lends you. | +| **Steps, not a roadmap** | Work is a ladder of small proofs. The next few steps are concrete; the rest are sketched and get detailed when reached. | + +## How the work runs + +[`Steps.md`](Steps.md) is a numbered ladder. Each step says what exists afterwards that did not before, links the +spec section that defines it, and states what proves it done: a test passes, a checklist is ticked, a person +performs an action. "Implemented" never closes a step. The first steps are written in full; later ones are +sketches that are detailed only when the step before them closes, because a plan written fifteen steps ahead is +a roadmap with a different name. + +A step that turns out wrong in the building corrects its spec in the same change, under a "what was built, and +where it differs" heading, so the next reader is not misled by the sketch. Decisions go in the log the moment they +are made. The worklog gets one terse line per step closed or wall hit. + +## What this is not, yet + +Named so that scope drift has to be a decision rather than an accident. + +- **Not a vertical slice.** There is no loop to prove. There are three feels to prove. +- **Not an economy.** No gold, no customers, no orders, no shop. Substances are spawned by a cheat. +- **Not a place.** One test level. No hall, no town, no generated dungeon. +- **Not a progression.** No upgrades, no reputation, no skill trees. Classes exist as kits with two abilities. +- **Not social infrastructure.** No lobbies, no invitations, no voice. Two clients and a server in the editor. +- **Not player versus player.** The damage funnel discards it. Players can inconvenience each other; they cannot + hurt each other. + +Every one of these has a home in [`Ideas.md`](Ideas.md) with what it would need and what it must not break. + +## Tone and look + +Stylised, chunky, readable at a glance. The placeholder is the engine's mannequin in a greybox gym with a plain +white HUD; the two earlier projects each had a finished palette and neither is this game's, so an art direction +decides when there is one. Until then, saturated colour in the world means one of two things: a substance's +identity, or the thing you are meant to reach. + +Comedy, when it happens, comes from physics and other players, never from writing. A thrown ingot skidding under +the anvil is the game working. + +## Glossary + +Every document uses these words with exactly these meanings. + +| Term | Meaning | +| --- | --- | +| **Body** | The character actor a player or enemy currently occupies. Not the player. | +| **Player state** | The replicated per-player object that outlives bodies: identity, class, attributes, cooldowns, discoveries. | +| **Kit** | A class's abilities, in slot order. Fixed by class. | +| **Signature** | The one ability in a kit no gear or mod may grant to another class. | +| **Funnel** | The single path a kind of consequence takes: the damage execution, the interaction service, the activity result. | +| **Prop** | A thing in the world a player can act on through the interaction system. | +| **Carryable** | A thing a player can hold. Carrying is not an interaction. | +| **Downed** | At zero health, immobile, revivable, bleeding out. Not dead. | +| **Stat block** | The one set of numbers every body carries: health, speed, attack, armour, resistances, work speed. | +| **Effect** | The only way a number on a stat block changes. Timed, infinite or instant; from an ability, an area, a surface, an item or the world. | +| **Status** | A named kind of effect: slow, haste, poisoned. The same name from any source, so one counter stops all of them. | +| **Counter** | Immunity (blocks a status by tag), resistance (scales it), cleanse (removes it). Lives on the receiver. | +| **Family** | A kind of item and the tree of parts it has: sword, axe, bow. | +| **Part** | A position in an item: blade, guard, handle, pommel. | +| **Piece** | A crafted object filling one part. Has identity. | +| **Trait** | One of a piece's three layers: characteristic (shape), substance (what it is made of), enchantment (ornament). | +| **Substance** | The design word "material", renamed because the engine owns that word. Iron, oak, mithril. A substance object is one physical ingot or log. | +| **Coherence** | An item's quality: piece values plus a bonus for pieces that agree with each other. | +| **Station** | A prop where work happens: bench, anvil, forge. | +| **Activity** | Hands-on work at a station, on the one shared runtime. Skill raises the ceiling, never gates the floor. | +| **Domain** | Which station family works a thing: forge, wood, enchant. Gates the work, never the worker. | +| **Step** | One rung of the ladder, closed by a proof. | +| **The gym** | The test level made of movement problems. It never leaves the project. | diff --git a/Docs/Ideas.md b/Docs/Ideas.md new file mode 100644 index 0000000..324cc25 --- /dev/null +++ b/Docs/Ideas.md @@ -0,0 +1,172 @@ +# Ideas + +The two earlier projects, Adventurer Guild and Project Malleable, each designed a whole game around a core loop. +This project keeps neither loop. It keeps the ideas, reshaped as features a world could hold rather than as the +thing the game is about, and it keeps them here rather than in the specs so that none of them can be smuggled into +a step as if it were already decided. + +Each entry says what the idea is, which mechanism is worth keeping, what it would need, and what it must not +break. "Adopted" means the idea already lives in a spec. Nothing here is scheduled. + +## From Adventurer Guild + +### Contracts and the board + +Take a parchment off a wall, go and do it, come back and have it stamped. The mechanism worth keeping is the +**stamp**: a ritual at the end of a job where the value is counted out in front of everyone, which is a better +stopping point than a results screen because it gives a group a shared moment. Contracts as data (a target, a +count, a reward, a standing requirement) and progress reported through one funnel from combat kills and item +deliveries. Needs: a place to put the board, a reason to want the reward. Must not: replace playing with +fetching; a contract is a reason to go somewhere, not the game. + +### The cart + +A physical, pushable container that carries the haul out. Tips, spills a fraction, gets righted. Pushed from the +handles by taking them (an interaction), which routes movement input into the cart. The mechanism worth keeping +is **hauling as a physical problem**: what you bring back is what fits and what you managed not to lose. Needs: +somewhere to haul from and to; the physics of a pushed body on a server. Must not: be the only way to carry +things; hands and (later) containers come first. + +### Grief verbs and the recoverability contract + +Shove, take from the cart, heal-launch, tip the cart. **Adopted as a rule** in [Combat.md](Spec/Combat.md): any +verb that lets one player inconvenience another must have a recovery that takes less time than the verb did, and +none may down, kill or permanently deprive. Every such verb emits `grief_action`. The verbs themselves arrive with +the systems they act on. + +### Down, revive, bleed-out, wipe + +**Adopted whole** in [Combat.md](Spec/Combat.md). + +### Seeded generation + +A dungeon generated from a seed on every peer, byte-identical, so the server replicates eight bytes instead of +megabytes. The determinism rules (own random stream, integer grid, ordered collections, no physics, no time), a +critical path the cart can traverse, side branches that split the party, a shortcut door that only opens from the +deep side, rooms as one-cell tiles in folders, typed spawn anchors with always/chance/budget rules, weighted +pools, and an append-only pass that guarantees a contract's target exists. The mechanism worth keeping is all of +it; it is the most worked-through part of the earlier project. Needs: a reason to have instanced places. +Must not: assume one instance per server or that generation runs on the client only; both hold in the earlier +design and both survive the move. + +### Party-size scaling + +Sublinear enemy budget (1.0, 1.75, 2.4, 3.0) and role relaxation below three players. **Adopted as a reserved +rule** in [Combat.md](Spec/Combat.md), applied to a count for now. + +### Reputation, tiers, zones, upgrades + +Standing that gates content, tiers that pay more, an upgrade bench where gold buys modifiers that combat pulls +by name (`GetModifier("cart.capacity")`), and the cross-class gear rule. The pull direction is the mechanism: +combat asks for its numbers, progression never pushes into combat. The signature guardrail is **adopted** as the +`bSignature` flag. Needs: a currency, a reason to spend. Must not: become per-body state; it lives on the player. + +### The hall as hub and lobby + +You launch into a place, not a menu. The lobby is a board by the door, readiness is a plinth you stand on, class +is a locker with racks, and everyone is physically together before the run. The mechanism is **the lobby is a +room**. Needs: a second map and session travel. Must not: assume a listen server; the room is on the same server +the world is. + +### Steam lobbies and the friends-list join + +Every launch hosts a lobby of one; opening it to friends is a physical act; a friend's "join game" carries them +in. Needs: a session layer in front of the server. Must not: put a session type inside gameplay code. + +### Content authoring tools + +Weighted pools, typed anchors, a tile template with red doorway volumes and a footprint validator, a project +content catalogue feeding dropdowns, and one browser window over every content asset. The engine's data +validation and asset manager do half of this natively. Needs: enough content to hunt through. Must not: arrive +before the third tileset. + +### The design system + +A palette by role, one label component, one theme, meaning never on hue alone. **Adopted** in +[UI.md](Spec/UI.md); the parchment-and-ink look itself is not. + +## From Project Malleable + +### Orders as tickets on a rail + +A customer hands over a parchment; it hangs on a rail from the desk to the workshop; taking it off is the claim; +clipping it to a station shows what that station is working; handing it back with the item is the delivery. The +spec on the ticket is explicit (family, required tags, minimum quality) and matched by one pure function that +also explains why an item does not match. Five customer tiers as data, archetypes with patience and reactions. +The mechanism is **demand as a physical object you can pick up, hand over and drop**. Needs: a shop, an +economy, customers. Must not: be the only source of demand once players are the adventurers. + +### The economy + +Gold only. A payout split 20/40/40 between base, quality and speed against a tier price, clamped so the ceiling +belongs to the order and profit is margin. A shared wallet with per-player attribution. A front shop with bid and +ask prices, NPC valuation computed independently so player prices cannot mint money, and haggling as a turn-based +exchange with a **truthful** likelihood meter. Needs: everything above it. Must not: introduce a lying surface; +every gauge in this project tells the truth and a haggling meter that dramatised would poison the others. + +### Storage as a place + +No shop-wide inventory, no stack counters, no station pulling from a container across the room. Containers with +slots, designated floor areas typed by what they hold, reservations per object so two players racing for the last +ingot find out at claim time, nothing destroyed by a full container, and a satchel bought later that adds pocket +slots (2, 3, 4, 6, 8, 10). The mechanism is **hauling is the game**, and the substance object model already +assumes it. Needs: more things than hands can hold. Must not: add an abstract bank; the object model's cost is +only affordable because storage caps the count. + +### Crafting domains as professions + +Five domains mapped one to one to five crafting classes (Forge Worker, Woodworker, Enchanter, Shopkeep, +Quartermaster) with speed and quality bonuses that never lock anything. Three domains are **adopted** as the +station gate in [Crafting.md](Spec/Crafting.md). The open question worth a real session: this project has combat +kits; are crafting professions a second axis on the same player, a choice against a kit, or folded into kits? +Needs: that decision. Must not: gate a station on a class; the rule is bonuses, never locks. + +### Stations you place, upgrade and pack + +Movable stations on a grid, per-station upgrades with a mesh per tier so level reads across the room, fittings as +visible sub-upgrades in named sockets, and stations repackaged into a carryable crate rather than sold, so nothing +is lost and no gold comes back. Needs: a place that is yours. Must not: put station state on the actor's +Blueprint; it is a replicated record. + +### World events as a modifier service + +A standalone service any system asks for its own modifier (`GetModifier(PriceBias, tag)`), events as assets with +scopes and channels, seeded per world, pushable from outside. The mechanism is **consumers ask, they never test +for an event**. Needs: a second system that wants tuning from outside. Must not: be consulted from inside an +activity tick. + +### Travel and the town + +The cart as the travel system: sit in it, pick a destination, cut to the scene. A town with a purchase surface +and a small bounded warehouse, staged so that a local town cannot smuggle in shared population or a marketplace +before the service that hosts them exists. Needs: a second place. Must not: make the warehouse a bank. + +### Persistence and portable characters + +Worlds keep the relationship, characters belong to the player and travel between worlds; ironman days with no +rewinds and a mid-day autosave; records as idempotent snapshots with schema versions and recomputed derived +fields; a provider interface with a local implementation forever. **Adopted as the identity and persistence +seams** in [Networking.md](Spec/Networking.md); the day, the world record and the character record are not built. + +### The service posture + +Dedicated servers, a validation layer behind the simulation, three responsiveness tiers, ask-then-show, no +rollback of visible world state, lag compensation only where timing is scored, and an incident doctrine (detect, +contain with a modifier push, trace by object id, remediate surgically, restore last). **Adopted as posture** in +[Networking.md](Spec/Networking.md) and [Telemetry.md](Spec/Telemetry.md). The layers themselves are not built and +nothing in gameplay code would change when they are. + +### Adventure contracts, turned inside out + +Malleable sent NPC parties out with your gear to bring rare substances back. Here the players are the party. +The loop that closes when the three feels exist: **craft a weapon, fight with it, bring back what only fighting +finds, craft something better.** Rare substances as drops and finds with provenance already in the data model +(`Provenance.Found`, `SourceObjectIds`). This is the most likely next step after fifteen and it is deliberately +not written as one. + +### Discovery at scale, alloys, deciphering + +Generated glyphs discovered like skill unlocks (with the constraint that the composition path never changes and +any seed is global), a kiln that alloys two raw types (with the constraint that nobody hard-codes one-to-one +processing), and customer requests as rough descriptions to interpret rather than specs to read. Each is its own +session. Must not: happen by accident in a step about something else. diff --git a/Docs/README.md b/Docs/README.md new file mode 100644 index 0000000..64bc871 --- /dev/null +++ b/Docs/README.md @@ -0,0 +1,44 @@ +# Documentation + +Two layers, kept apart on purpose. + +**For people.** Short, and meant to be read end to end. + +| Doc | Covers | +| --- | --- | +| [Design.md](Design.md) | What this is, the three things it proves, the fixed decisions, what it is not yet, the glossary. Start here. | +| [Steps.md](Steps.md) | The ladder of work: fifteen rungs, each closed by a proof. The next few in full, the rest sketched. | +| [Ideas.md](Ideas.md) | The ideas carried over from the two earlier projects as features a world could hold, with what each needs. None scheduled. | +| [Decisions.md](Decisions.md) | One line per decision with a pointer, the open decisions that block steps, and what is deliberately deferred. | +| [Worklog.md](Worklog.md) | One terse line per step closed or wall hit. The memory. | + +**For an implementer that lifts code**, which today means Claude. Long, C++-shaped, and meant to be built from. + +| Doc | Covers | +| --- | --- | +| [Spec/README.md](Spec/README.md) | Notation, status markers, the twelve cross-cutting rules every spec obeys. Read before any spec. | +| [Spec/Architecture.md](Spec/Architecture.md) | Modules, lifetimes, authority, content, tags, the C++/Blueprint line, testing, conventions, gotchas. | +| [Spec/Stats.md](Spec/Stats.md) | The one stat block every body carries, effects and where they come from, stacking, counters. | +| [Spec/Movement.md](Spec/Movement.md) | The character, the movement component, input, the look model, camera modes, the gym, the feel checklist. | +| [Spec/Interaction.md](Spec/Interaction.md) | The one interaction system, carrying, throwing, highlighting. | +| [Spec/Combat.md](Spec/Combat.md) | Attributes, the damage funnel, the swing, enemies, downed and revive, abilities and kits, projectiles. | +| [Spec/Crafting.md](Spec/Crafting.md) | Families, parts, pieces, traits, substances, the pure rules, stations, activities, enchanting, the weapon seam. | +| [Spec/Networking.md](Spec/Networking.md) | The authority table, the responsiveness tiers, the posture, sessions, identity, persistence. | +| [Spec/Telemetry.md](Spec/Telemetry.md) | The sink, the envelope, the catalogue. | +| [Spec/UI.md](Spec/UI.md) | The HUD, the prompt, the theme, world-space text, localisation, the menu. | + +## Reading order + +New here: `Design.md`, then `Steps.md`, then `Spec/README.md` and `Spec/Architecture.md`. About to build a step: +the step's entry in `Steps.md`, then the spec sections it links. About to decide something: `Decisions.md` first, +to see whether it was already decided or deliberately deferred. + +## Conventions + +- Version and status live in a document's header where it has one, never in its filename. +- Links are relative so the folder survives being moved. +- A spec that turns out wrong in the building is corrected in the same change as the code, under a **What was + built, and where it differs** heading at its end. +- A decision goes in `Decisions.md` the moment it is made. Newest first; never delete, supersede. +- The two earlier projects are referred to by name (Adventurer Guild, Project Malleable) when an idea's origin + matters, and not otherwise. Their docs are not copied here; they are in their own repositories. diff --git a/Docs/Spec/Architecture.md b/Docs/Spec/Architecture.md new file mode 100644 index 0000000..7082b16 --- /dev/null +++ b/Docs/Spec/Architecture.md @@ -0,0 +1,248 @@ +# Architecture + +How the project is put together in Unreal Engine 5: modules, lifetimes, authority, content, the C++ and Blueprint +boundary, testing and the conventions that follow from all of it. Read this before writing gameplay code; nearly +every wiring decision in the other specs follows from something here. + +The two projects this grew out of solved the same problems in Unity with a dependency-injection container, assembly +definitions and a hand-rolled tag type. Unreal has native answers to each: subsystems for lifetimes, modules for +dependency direction, Gameplay Tags for vocabulary, the Gameplay Ability System for attributes and abilities, and +server-authoritative replication built into the actor model. This document maps the old discipline onto those rather +than reintroducing a container the engine does not need. + +## Modules + +Two runtime modules from step 1. Modules are Unreal's real dependency boundary, the equivalent of the old assembly +definitions, and the direction between them is the only architectural rule that is enforced by the build. + +``` +Source/ +├── Core/ rules, data types, tags, the telemetry contract. Knows no AActor, no UWorld. +│ ├── Crafting/ pure crafting rules and the definition asset classes +│ ├── Combat/ damage maths, threat table, weapon profile +│ ├── Movement/ the look model, tuning asset validation +│ ├── Stats/ CarryMath and the resistance formula: the pure parts of the stat block +│ ├── Tags/ native gameplay tag declarations +│ ├── Telemetry/ ITelemetrySink, the event struct, the event name constants +│ └── Tests/ automation tests for everything above +└── / gameplay. Depends on Core. Actors, components, subsystems, abilities, UI. + ├── Core/ GameMode, GameState, PlayerState, PlayerController, GameInstance, subsystems + ├── Stats/ the stat block attribute set, the effect helper, effect volumes, surface materials + ├── Movement/ + ├── Interaction/ + ├── Combat/ + ├── Crafting/ + ├── UI/ + └── Tests/ functional and integration tests that need a world +``` + +`Core` depends on `Core`, `CoreUObject`, `Engine` (for `UPrimaryDataAsset` and `FGameplayTag`), +`GameplayTags` and `GameplayAbilities` (for `FGameplayAttribute` in the damage maths). It does **not** depend on the +gameplay module, and the gameplay module never has a type the core module needs. If the core module seems to need an +actor, the thing it needs is data and belongs in a struct the actor fills in. + +An editor module (`Editor`) arrives when the first editor tool does, never before. A third runtime module +appears when a feature is stable enough to be built on its own; splitting is cheap in Unreal (a folder and a +`Build.cs`), so the default is one gameplay module with a folder per feature. + +**The rule between feature folders:** a feature may use another feature's data types, tags, pure rules and +subsystem API. It may never reach into another feature's actors or components by class. Combat asks the crafting +subsystem for a weapon profile; it does not `Cast<>` a bench. + +## Lifetimes + +Unreal already has the three lifetimes the old projects modelled with container scopes. + +| Lives for | Unreal type | Examples here | +| --- | --- | --- | +| The whole application | `UGameInstanceSubsystem` | Telemetry, the persistence provider, user settings, the content catalogue | +| One world (a map, on the server or a client) | `UWorldSubsystem` | Interaction registry, crafting service, activity runtime, encounter spawner | +| One player, across pawn deaths and map travel | `APlayerState` (replicated) | Ability system component, attributes, class, the player's persistent identity | +| One body | `APawn` / `ACharacter` | Movement, camera, mesh, the interaction and carry components | +| A single local player's machine | `ULocalPlayerSubsystem` | Input mode, prompt glyphs, HUD state | + +Two consequences worth stating: + +- **The ability system component lives on the player state for players and on the pawn for enemies.** A player's + attributes and cooldowns must survive their body dying and respawning; an enemy's die with it. This is the standard + arrangement and every ability in [Combat.md](Combat.md) assumes it. +- **Everything about a player that would matter outside this session is on the player state or behind the + persistence provider, never on the character actor.** Class, progression, identity. A body is a thing the world + lends the player for a while. + +A world subsystem exists on the server and on every client. Authority-only work checks `GetWorld()->GetNetMode()` +or lives in the `AGameModeBase` subclass, which only exists on the server. There is no other "is this the host" +branch anywhere in gameplay code. + +## Authority + +**Server-authoritative, always.** The client sends intent (an input, a request), the server validates and mutates, +replication carries the result back. The client predicts exactly two things: its own movement through the character +movement component and the animation of its own abilities through the ability system. Nothing else is predicted, +because everything else has a visible victim when the prediction is wrong. + +This is not a choice made for a four-player game and it is not a choice that scales down. It is the one posture +that stays correct as the world grows, and every actor, subsystem and RPC in this project is written as if the +server were a separate process on another machine, because in the intended build it is. + +[Networking.md](Networking.md) has the full authority table and the posture on tick rate, prediction and +rollback. The rule that keeps it all playable: **no rollback of world state a player can already see.** If a +design would need one, move the decision to the server and cover the round trip with animation. + +## Content + +All authored gameplay content is a `UPrimaryDataAsset` subclass, registered with the asset manager under a primary +asset type per definition class, and addressed by primary asset id or gameplay tag. String ids do not exist. + +```cpp +// A definition asset. Designers fill these in; code reads them. Never the other way round. +UCLASS(BlueprintType) +class UWeaponDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly, Category = "Identity") + FGameplayTag WeaponTag; // Item.Weapon.Sword, the stable identity + + UPROPERTY(EditDefaultsOnly, Category = "Identity") + FText DisplayName; // FText, never FString: localisable from day one + + UPROPERTY(EditDefaultsOnly, Category = "Visual") + TSoftObjectPtr Mesh; // soft, so a definition never hard-loads its art + + // GetPrimaryAssetId() uses the class name as the type; the asset manager scans Content//Definitions. +}; +``` + +Rules that follow: + +- **Soft references for art and other assets** (`TSoftObjectPtr`, `TSoftClassPtr`). A definition is small data and + must be loadable on a headless server without dragging meshes in. +- **Polymorphic inline content** (a list of effects, a spawn rule) uses `Instanced` `UObject` properties with + `EditInlineNew` classes, or `FInstancedStruct` for value types. Both serialise the concrete type; both survive a + rename only with a core redirect, so name such classes carefully and early. +- **Tables for tuning numbers**, data assets for things with identity. A `UDataTable` of `FMovementTuningRow` is + fine; an enemy is a data asset. +- **Every visual defaults to an engine primitive or the engine mannequin**, so the first art pass is a content edit. + +## Gameplay tags + +Gameplay Tags are the project's vocabulary: hierarchical, parent-matching, cheap to compare, replicated as indices, +native to the ability system. They replace every enum that would otherwise leak across features and every string +that would otherwise be typed twice. + +**Source of truth:** `Config/Tags/.ini`, one file per top-level namespace, reviewed in pull requests like +code. Tags that code references are additionally declared natively so a typo fails at compile time: + +```cpp +// Core/Tags/NativeTags.h +UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_State_Downed); +UE_DECLARE_GAMEPLAY_TAG_EXTERN(TAG_Damage_Type_Physical); +// NativeTags.cpp +UE_DEFINE_GAMEPLAY_TAG_COMMENT(TAG_State_Downed, "State.Downed", "At zero health, immobile, revivable. Not dead."); +``` + +The taxonomy, top level only. Each spec owns the children of its namespaces. + +``` +Input.* Input.Move, Input.Jump, Input.Sprint, Input.Interact, Input.Drop, Input.Attack, Input.Ability.1..4 +Ability.* asset tags on abilities: Ability.Melee, Ability.Dodge, Ability.Taunt, Ability.Signature +Cooldown.* granted by cooldown effects, one per ability +Data.* SetByCaller keys: Data.Damage, Data.Heal, Data.Impulse +Damage.Type.* Physical, Magic, True Damage.Source.* Melee, Projectile, Ability, Fall, Hazard, Shove +State.* what a body IS: Downed, Dead, Invulnerable, Sprinting, Dodging, Carrying, Carrying.TwoHanded +Status.* an outside influence on a body: Status.Buff.Haste, Status.Debuff.Slow, .Stunned ... (Stats.md) +Immune.* Immune.Status., Immune.Damage.: the counters a body carries +Surface.* Mud, Ice, Hot: on physical materials, applied by the ground trace +Event.* gameplay events between animation and abilities: Event.Montage.Hit, Event.Montage.WindupEnd +Class.* Warrior, Ranger, Mage, Cleric, Rogue +Item.* Item.Family.*, Item.Part.*, Item.PieceType.*, Item.Substance.*, Item.Enchant.*, Item.Weapon.* +Piece.Layer.* Characteristic, Substance, Enchantment +Substance.* Substance.Class.*, Substance.Rarity.*, Substance.State.* +Theme.* synergy vocabulary shared by characteristics and enchantments +Domain.* Forge, Wood, Enchant +Station.* Station.State.*, Station.Type.* Activity.* Heat, Strike, Assemble Fault.* Mark.* +Interact.* Interact.Reason.* (why a verb is unavailable), Interact.Verb.* +GameplayCue.* hit impacts, hit stop, outlines: presentation only +UI.* theme roles, see UI.md +``` + +## The C++ and Blueprint boundary + +C++ owns every rule, every state machine and every replicated property. Blueprint owns composition, tuning and +cosmetics: which mesh, which montage, which numbers, what particle plays. The contract each C++ type declares: + +| Surface | Meaning | +| --- | --- | +| `UFUNCTION(BlueprintCallable)` | a command a designer or UI may invoke; verb-noun names | +| `UFUNCTION(BlueprintPure)` `Get*` / `Preview*` | side-effect-free query, safe every frame | +| `UFUNCTION(BlueprintNativeEvent)` | a working C++ default a subclass may replace, for cosmetic reactions only | +| `UPROPERTY(BlueprintAssignable)` delegate, `*_Cosmetic` | an Inspector-wired reaction; never load-bearing | +| `UPROPERTY(EditDefaultsOnly)` | tuning data, authored not called | +| `protected` / `private` C++ | authoritative internals; no Blueprint access | + +A Blueprint may subclass a C++ actor to set its assets and numbers and to wire cosmetic events. A Blueprint never +implements a rule, never writes a replicated property and never contains a `Server` RPC. Blueprints are binary and +unreviewable in a diff, which is the practical reason for the line; the principled reason is that a rule in +Blueprint cannot run in a headless test. + +## Testing + +Two kinds, and the split mirrors the module split. + +- **Automation tests in `Core/Tests/`** for every pure rule, using `IMPLEMENT_SIMPLE_AUTOMATION_TEST` with + the `ProductFilter` flag. They run with no world, in the editor's Session Frontend or headless: + + ``` + UnrealEditor-Cmd .uproject -ExecCmds="Automation RunTests .Core; Quit" -unattended -nopause -NullRHI -log + ``` + + Every rejection reason has a named test, not just the happy path. A test name is + `.Core...` so a filter can run one feature. +- **Functional tests in maps** (`AFunctionalTest` actors in `Content/Tests/`) for anything that needs a world: the + gym's step-up heights, a swing landing on a dummy, two clients seeing the same assembled item. Run through the + same command with the `Project.Functional` filter. + +There is no continuous integration and there will not be until there is a reason for it. `Scripts/run-tests.sh` +wraps the command above and is run by hand before a step is called done. + +## Conventions + +- **Naming follows Unreal:** `A` actors, `U` objects, `F` structs, `E` enums, `I` interfaces, no project prefix on + classes. Assets: `BP_`, `DA_` (data asset), `DT_`, `IA_`, `IMC_`, `GA_`, `GE_`, `GC_` (gameplay cue), `ABP_`, + `AM_` (montage), `SK_`, `SM_`, `M_`, `MI_`, `WBP_`, `T_`, `L_` (map). Placeholder assets carry `_Proto` before the + descriptor so they can be purged in one search. +- **Identity is `FGuid`** for anything that persists or is referenced across peers: an item, a piece, a substance + object, an activity session. Never an index, never a name. +- **No world searches in gameplay code.** `GetAllActorsOfClass`, `FindComponentByClass` on arbitrary actors, and + static singletons holding gameplay state are all banned. A subsystem is the registry; actors register with it in + `BeginPlay` and unregister in `EndPlay`. +- **`TObjectPtr` for `UPROPERTY` object references**, raw pointers only for function-local use. +- **`FText` for anything a player reads**, from a string table. `FString` for identifiers and logs. +- **Private members `Name`, not `_name`**: Unreal's own style, and the engine code the project reads uses it. +- **One header, one class.** A struct shared by several may have its own header. +- **Comments say why.** What the code does is visible; why it does it that way is the part that gets lost. + +## Gotchas + +Real ones, from the engine, that cost a day each if met in the wild rather than here. + +- **Replication needs a registered property.** A `UPROPERTY(Replicated)` does nothing until it is listed in + `GetLifetimeReplicatedProps`. There is no warning. +- **Server RPCs need an owning connection.** `UFUNCTION(Server)` on an actor the client does not own silently + drops. Components on the player's pawn or player state are owned; a bench is not, so interaction RPCs go through + the player's own interaction component, never through the prop. +- **The ability system component must be initialised on both sides.** `InitAbilityActorInfo` in `PossessedBy` on + the server and in `OnRep_PlayerState` on the client, and `UAbilitySystemGlobals::Get().InitGlobalData()` once at + startup or target data will not serialise. +- **`BeginPlay` order between actors is not defined.** A component that needs another actor reads it on first use, + not in `BeginPlay`. +- **Blueprint child of a C++ class: `Super::` calls are on the Blueprint author.** A cosmetic override that forgets + to call the parent event silently drops the C++ default. +- **`CustomTimeDilation` on the server changes the simulation.** Hit stop is a client-side gameplay cue, never a + server-side dilation. +- **`FGameplayTag` matching is hierarchical by default.** `HasTag(State)` is true for `State.Downed`. Use `HasTagExact` + when the parent must not match. +- **Dedicated server packages need the engine from source.** The launcher build compiles no server target. Play In + Editor's "Run Dedicated Server" works on the launcher build and is the everyday test; the source build arrives when + the first packaged server does. diff --git a/Docs/Spec/Combat.md b/Docs/Spec/Combat.md new file mode 100644 index 0000000..f4e3ed5 --- /dev/null +++ b/Docs/Spec/Combat.md @@ -0,0 +1,459 @@ +# Combat + +Owns attributes, the one damage funnel, health and the downed state, melee hit detection and feel, enemies, the +ability system, class kits, and the rules that keep gear and mods from dissolving the classes. It does **not** own +how a body moves ([Movement.md](Movement.md), whose root-motion hooks the movement abilities use), how a player +touches a downed teammate ([Interaction.md](Interaction.md)), or where a weapon's numbers come from when the weapon +was crafted ([Crafting.md](Crafting.md), which produces the `FWeaponProfile` defined here). + +Read [Architecture.md](Architecture.md) first. Steps 7 to 11 in [`../Steps.md`](../Steps.md). + +## Decisions + +``` +[DECIDED] The Gameplay Ability System. Attributes, abilities, effects, cooldowns, tags, cues. + + The earlier project hand-built a damage resolver, a health service, a cooldown tracker, an ability runner and a + client-to-host relay, then spent a step making them replicate. GAS is those five things, server-authoritative + with client prediction, already replicated, and the engine's own combat samples are written on it. The cost is + its learning curve, which is paid once, and its opinions, which happen to be this project's: one attribute set, + effects as data, abilities as classes, everything addressed by tag. +``` + +``` +[DECIDED] One damage funnel. Every point of damage in the game passes through one execution calculation. + + Melee, projectiles, abilities, falling, hazards, a shove into a pit. One place, for three reasons: modifiers from + gear and upgrades apply in one place, telemetry is emitted in one place, and the no-friendly-fire rule is enforced + in one place rather than remembered at twenty call sites. No ability, weapon or hazard writes Health directly. +``` + +``` +[DECIDED] Down before death. Zero health is downed: immobile, revivable, bleeding out. Death is what happens when +nobody comes. Any player can revive; some kits do it faster. When no player is standing, everyone downed dies at +once, because nobody can revive anybody. +``` + +``` +[DECIDED] The ability system component is on the player state for players and on the pawn for enemies. +A player's cooldowns, attributes and class survive their body; an enemy's die with it. +``` + +``` +[SALVAGED] Kits define ability access. Gear defines stat and utility access, and crosses class lines. Mods bend +numbers and change how an ability you have behaves, and never grant another class's signature ability. Without that +line, free-form gear dissolves five classes into five slightly different damage dealers within one content patch. +Gear and mods themselves are not built in these steps; the flag that guards the line is, because it is free now. +``` + +## Layout + +``` +Source/Core/Combat/ +├── WeaponProfile.h // FWeaponProfile: the one shape both authored and crafted weapons produce +├── DamageMath.h / .cpp // pure: ComputeDamage(context) -> FDamageResult +└── ThreatTable.h / .cpp // pure: who an enemy wants to hit + +Source//Combat/ // the stat block and the ability system component live in Stats/, see Stats.md +├── DamageExecution.h // UGameplayEffectExecutionCalculation: THE funnel +├── CombatantComponent.h // per body: team, downed/dead state machine, revive target, hit reaction +├── WeaponDefinition.h // UPrimaryDataAsset for authored weapons +├── WeaponActor.h // the held weapon: mesh on the hand socket, its profile, its trace sockets +├── Abilities/ +│ ├── KitAbility.h // UGameplayAbility base: InputTag, bSignature, activation policy +│ ├── GA_MeleeAttack.h // montage, hit window, server sweep, apply damage +│ ├── GA_Dodge.h GA_Blink.h GA_ShoulderCharge.h GA_Taunt.h GA_Mark.h GA_Heal.h GA_Downed.h GA_Revive.h +│ └── GA_Fireball.h GA_Volley.h // step 11, projectiles +├── ClassKitDefinition.h // UPrimaryDataAsset: abilities in slot order, starting weapon, base attributes +├── Enemies/ +│ ├── EnemyDefinition.h // UPrimaryDataAsset: stats, montages, drops later +│ ├── EnemyCharacter.h // ABaseCharacter with its own ASC +│ ├── EnemyController.h // AAIController + StateTree + perception + the threat table +│ └── EncounterSubsystem.h // UWorldSubsystem, server: spawns and counts, party-size scaling +├── ProjectileActor.h // step 11 +└── Cues/ // GameplayCue notifies: impact, hit stop, camera kick, outline flash + +Content/Combat/ +├── Definitions/DA_Weapon_Sword, DA_Weapon_Mace, DA_Weapon_Dagger, DA_Weapon_Staff, DA_Weapon_Bow +├── Definitions/DA_Kit_Warrior, DA_Kit_Ranger, DA_Kit_Mage, DA_Kit_Cleric, DA_Kit_Rogue +├── Definitions/DA_Enemy_Goblin +├── Effects/GE_Damage, GE_Heal, GE_Cooldown_*, GE_BleedOut, GE_Invulnerable, GE_FallDamage +├── Abilities/GA_* // Blueprint children setting montages and cues only +├── Animations/AM_Melee_*, AM_Hit_* // montages with the hit-window notify states +└── Cues/GC_* +``` + +## Types at a glance + +| Type | Module | Lifetime | Notes | +| --- | --- | --- | --- | +| `FWeaponProfile` | Core | value | Authored or crafted, same struct | +| `DamageMath::ComputeDamage` | Core | pure | The maths the execution calls | +| `FThreatTable` | Core | value | Owned by an enemy controller | +| `UStatBlockAttributeSet` | Gameplay (Stats) | per ASC | The stat block, see [Stats.md](Stats.md) | +| `UDamageExecution` | Gameplay | asset-referenced | Referenced by `GE_Damage` | +| `UCombatantComponent` | Gameplay | per body | Team, downed state, revive target, hit reaction | +| `UWeaponDefinition`, `UClassKitDefinition`, `UEnemyDefinition` | Gameplay | asset | Content | +| `AWeaponActor` | Gameplay | per equipped weapon | Attached to the hand socket | +| `UKitAbility` and children | Gameplay | granted per ASC | Abilities | +| `AEnemyCharacter`, `AEnemyController` | Gameplay | per enemy | Server-driven | +| `UEncounterSubsystem` | Gameplay | world, server | Spawning and the wipe | + +## Attributes + +The numbers combat reads and writes are on the one stat block every body carries, `UStatBlockAttributeSet`, +specified in [Stats.md](Stats.md): `Health` and `MaxHealth`, `AttackPower` and `AttackSpeed`, `Armour`, +`MagicResist` and `KnockbackResist`, and the two meta attributes the funnel writes. Combat owns what they mean; +it does not own the set, and it adds nothing to it without a row in that document's table. + +```cpp +// UStatBlockAttributeSet, the parts combat uses +ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingDamage) // META: written by the execution, consumed below, never replicated +ATTRIBUTE_ACCESSORS(UStatBlockAttributeSet, IncomingHeal) // META, same + +virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override; +// IncomingDamage: Health = clamp(Health - Incoming, 0, Max); if it reached zero and was not zero, raise +// OnOutOfHealth(instigator, causer, context). Emit player_damaged / enemy_damaged. Clear the meta attribute. +// IncomingHeal: overheal past MaxHealth is returned to the caller as a value, because a heal that overflows +// is a launch (the salvaged joke, see Abilities). Health clamps; the surplus goes out through OnOverheal. +``` + +Base values are a `FStatBlockDefaults` on the kit or the enemy definition, applied through the one init effect +on grant or spawn; nothing sets an attribute directly. A body's speed does not live in combat at all: it is +`MoveSpeed` on the block, and a slow from a frost bolt changes it the same way mud does. + +## The damage funnel + +```cpp +// Source/Core/Combat/DamageMath.h // pure +struct FDamageContext +{ + float BaseAmount; // from the weapon profile or the ability's SetByCaller Data.Damage + FGameplayTag DamageType; // Damage.Type.Physical | Magic | True + FGameplayTag Source; // Damage.Source.Melee | Projectile | Ability | Fall | Hazard | Shove + float SourceAttackPower = 1.f; // captured attribute + float TargetArmour = 0.f; // captured attribute, physical + float TargetMagicResist = 0.f; // captured attribute, magic + bool bSourceIsPlayer = false, bTargetIsPlayer = false; + bool bTargetMarked = false; // Status.Debuff.Marked: everyone hits harder + bool bTargetInvulnerable = false; // dodge i-frames, a shield + FGameplayTagContainer TargetImmunities; // Immune.Damage. tags on the target: matching damage is zero + FGameplayTagContainer GrantedTags; // from an enchanted weapon: Item.Enchant.Fire.Penetration ... +}; +struct FDamageResult { float FinalAmount = 0.f; bool bDiscardedFriendlyFire = false; bool bCritical = false; }; + +namespace DamageMath +{ + // pure. Tested for: the friendly-fire gate; True damage ignores armour and resist; a mark multiplies; + // invulnerable is zero; an Immune.Damage. tag zeroes that type; fall damage is never friendly fire + // even when a player caused the fall. + FDamageResult ComputeDamage(const FDamageContext& Ctx, const struct FDamageTuning& Tuning); +} +``` + +```cpp +/** + * THE funnel. Referenced by GE_Damage and by nothing else; every damaging effect in the project is GE_Damage with + * SetByCaller magnitudes, or a subclass of it. Runs on the server only, as executions do. + */ +UCLASS() +class UDamageExecution : public UGameplayEffectExecutionCalculation +{ + GENERATED_BODY() +public: + UDamageExecution(); // captures AttackPower from the source; Armour, MagicResist and the State, Status and Immune tags from the target + virtual void Execute_Implementation(const FGameplayEffectCustomExecutionParameters& Params, + FGameplayEffectCustomExecutionOutput& Out) const override; + // 1. Build FDamageContext from the spec: Data.Damage, the type and source tags on the spec, the captured + // attributes, team membership of instigator and target (IGenericTeamAgentInterface), target state tags. + // 2. THE FRIENDLY FIRE GATE. Both players: FinalAmount is zero. The effect still applies, so the impulse + // and the cue survive. Player-on-player is impulse and ragdoll, never health loss. This is the + // consequence-free griefing rule from the earlier project, enforced here so no ability can violate it. + // 3. DamageMath::ComputeDamage. Modifiers from gear read the source's granted tags and attributes; nothing + // is pushed into this class by another system, it reads. + // 4. AddOutputModifier(IncomingDamage, Additive, FinalAmount). + // 5. The attribute set's PostGameplayEffectExecute emits telemetry and raises OnOutOfHealth. This class + // emits nothing itself: one emitting site per event. +}; +``` + +Fall damage is a `GE_FallDamage` applied by the character's `Landed` through the same funnel with +`Damage.Source.Fall` and no instigator; hazards are the same with `Hazard`. A shove is `GE_Damage` with zero base +amount and an `Impulse` magnitude: the gate discards nothing because there is nothing, and the impulse cue lands. + +## Health, down, revive, wipe + +```cpp +/** Per body. Turns attribute events into the downed/dead state and exposes the revive prop. */ +UCLASS() +class UCombatantComponent : public UActorComponent, public IInteractable +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGenericTeamId Team; // players 1, enemies 2; the attitude solver says 1 vs 2 is hostile + FGuid CombatantId; // stable runtime identity for the threat table and telemetry + + // Server, on OnOutOfHealth: + // Enemies: grant State.Dead, play the death montage through a cue, disable collision, the encounter counts it. + // Players: ASC->TryActivateAbilityByClass(GA_Downed). GA_Downed grants State.Downed, applies GE_BleedOut + // (30 s duration, on expiry -> Die), applies GE_Downed (MoveSpeed overridden to zero), forces a drop through the carry + // component, drops the camera, and enables this component's revive collider on the Interactable channel. + // Then: if no player in the world is standing, every downed player dies now (a solo down is an instant wipe; + // bleed-out only matters with a party) and the encounter subsystem's wipe fires. + // IInteractable, the revive prop: + // GetPrompt: Interact.Verb.Revive, enabled when the target has State.Downed and the asker does not. + // Interact (server): activate GA_Revive on the reviver, which holds for ReviveSeconds (faster for a Cleric), + // then removes GE_BleedOut and restores a fraction of MaxHealth. Emit player_revived with down_duration_s. + // Hit reaction: OnDamaged raises HitReaction_Cosmetic(direction); the animation blueprint bends the spine with + // a damped spring in the direction of the hit, on top of whatever plays, and never moves the hands. +}; +``` + +A downed player is the first prop in the game, and reviving gets the server-side range and permission re-check +for free from [Interaction.md](Interaction.md). It is the reason interaction is built before combat. + +## Weapons and the melee swing + +```cpp +// Source/Core/Combat/WeaponProfile.h +/** + * The one shape a weapon has at the moment of the swing. An authored UWeaponDefinition produces it directly; a + * crafted item produces it through CraftingRules::MakeWeaponProfile. The melee ability, the action bar and the + * damage funnel read this and nothing else, which is the seam that lets a crafted sword be a sword. + */ +struct FWeaponProfile +{ + FGameplayTag WeaponTag; // Item.Weapon.Sword; a crafted item carries its family tag here + float Damage = 12.f; + float ReachCm = 180.f; + float ArcHalfWidthCm = 60.f, ArcHalfHeightCm = 50.f; + float WindupSeconds = 0.12f; // press to hit check: the tell + float SwingSeconds = 0.55f; // press to next press + float HitStopSeconds = 0.06f; // cosmetic, client side + FGameplayTag DamageType; // Damage.Type.Physical by default + FGameplayTagContainer GrantedTags; // an enchanted blade grants Item.Enchant.* here + bool bTwoHanded = false; // a greatsword occupies both hands, see Interaction.md + FSoftObjectPath Montage; // AM_Melee_*; a crafted weapon uses its family's montage +}; +``` + +```cpp +UCLASS() +class UGA_MeleeAttack : public UKitAbility +{ + GENERATED_BODY() + // Net execution policy: LocalPredicted. The montage plays immediately on the owner; the server plays it too and + // does the hit check itself. Activation: on Input.Attack, blocked by State.Downed, State.Carrying.TwoHanded, + // State.Dodging, and by its own cooldown (SwingSeconds, a GE_Cooldown_Melee with SetByCaller duration). + // + // ActivateAbility: + // Profile = Owner->GetWeaponActor()->GetProfile(); timings divided by the body's AttackSpeed attribute + // PlayMontageAndWait(Profile.Montage, rate = AttackSpeed) // predicted + // WaitGameplayEvent(Event.Montage.Hit) // fired by ANS_HitWindow on the montage, at WindupSeconds + // on event, SERVER ONLY (HasAuthority): + // sweep a box (ArcHalfWidth x ArcHalfHeight x Reach) from the eye along the body's aim on the Enemy channel + // for each hit, once per swing: Linecast eye -> hit point on the world channel; a wall blocks it + // for each surviving hit: MakeOutgoingSpec(GE_Damage), SetByCaller(Data.Damage, Profile.Damage), + // add DamageType and Damage.Source.Melee to the spec, ApplyToTarget + // execute cue GameplayCue.Melee.Impact at the hit point on every peer + // EndAbility on montage end. + // + // Why a single box after the wind-up rather than a per-frame sweep along the blade: it is what the earlier + // project shipped, it hits the whole pack, and it reads. A blade-socket sweep is a change to this one ability + // if the feel pass wants it; nothing else would move. +}; +``` + +Feel, all cosmetic and all client-side through gameplay cues: **hit stop** (a brief `CustomTimeDilation` dip on the +local player and the struck enemy, never on the server), a **camera kick** through a camera shake on the owner's +controller, and the **procedural hit reaction** on the struck body. Every duration passes through the motion +accessibility scale, so reduced motion turns them down. + +The wind-up is what makes the animation and the rules agree: the player's hit check is delayed past the press by +`WindupSeconds`, and the goblin's damage lands after its own wind-up, missing if the target has stepped out of +reach. The tell is real; reading it and stepping back works. + +**Combat feel is the honest unknown of these steps.** No document settles whether a swing lands well. Step 7 is not +done until one training dummy and then one goblin are satisfying to hit, and the numbers that made it so are +committed. + +## Enemies + +```cpp +UCLASS(BlueprintType) +class UEnemyDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag EnemyTag; // Enemy.Goblin + UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // 30 health, MoveSpeed 1: the same block a player has (Stats.md) + UPROPERTY(EditDefaultsOnly) TObjectPtr Tuning; // the goblin's 320 cm/s walk; the block multiplies it like anyone's + UPROPERTY(EditDefaultsOnly) FWeaponProfile Attack; // 8 damage, 1.2 s, 0.35 s wind-up: the goblin's claws + UPROPERTY(EditDefaultsOnly) float AggroRangeCm = 1000.f; + UPROPERTY(EditDefaultsOnly) float LeashRangeCm = 2500.f; + UPROPERTY(EditDefaultsOnly) float FleeSecondsOnAllyDeath = 2.5f; // reads as cowardice; funnier than fighting to the death + UPROPERTY(EditDefaultsOnly) int32 ThreatCost = 1; // what it costs an encounter budget + UPROPERTY(EditDefaultsOnly) bool bRequiresRole = false; // ineligible below three players, see scaling + UPROPERTY(EditDefaultsOnly) TSoftClassPtr Body; + UPROPERTY(EditDefaultsOnly) TSoftObjectPtr Brain; +}; +``` + +```cpp +// Source/Core/Combat/ThreatTable.h // pure +struct FThreatTable +{ + void AddThreat(FGuid Combatant, float Amount); // damage dealt to me, healing done near me + void ForceTarget(FGuid Combatant, float Seconds); // a taunt + void Decay(float DeltaSeconds); + TOptional GetTarget() const; // forced target while it lasts, else highest threat + void Remove(FGuid Combatant); +}; +``` + +The controller is an `AAIController` running a StateTree from the definition, with a perception component for +sight and the threat table for choice. States: Idle, Chase, Attack (wind-up, hit through the same melee ability +class the player uses, on the enemy's own ASC), Flee (on an ally's death, for `FleeSecondsOnAllyDeath`), Leash +(back to the spawn point past `LeashRangeCm`). Threat accrues from damage dealt and from healing done, which is why +a healer needs a tank. The threat table is what makes a taunt mean something. + +**Party-size scaling is a reserved rule, not a built one.** The encounter subsystem reads the party size from the +game state and scales the enemy budget sublinearly: 1 player 1.0, 2 players 1.75, 3 players 2.4, 4 players 3.0. +Linear scaling is the obvious choice and it is wrong: four coordinated players are worth far more than four times +one. Enemies with `bRequiresRole` are ineligible below three players, which is what "playable alone" means +concretely: a constraint on which enemies may appear, not a damage multiplier. Only the goblin exists, so the +subsystem multiplies a count and filters a list; the table is written down so the first second enemy inherits it. + +## Abilities and kits + +```cpp +UCLASS(Abstract) +class UKitAbility : public UGameplayAbility +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly, Category = "Kit") FGameplayTag InputTag; // Input.Ability.1..4, or Input.Attack, Input.Dodge + UPROPERTY(EditDefaultsOnly, Category = "Kit") bool bSignature = false; // THE flag a mod may never cross + UPROPERTY(EditDefaultsOnly, Category = "Kit") FText DisplayName; // the action bar's label + // Cooldown and cost are the engine's: a GE_Cooldown_ with a Cooldown. granted tag, + // and an optional stamina cost effect. CommitAbility applies both. The HUD reads the cooldown tag's remaining time. +}; + +USTRUCT() +struct FKitAbilitySlot +{ + GENERATED_BODY() + UPROPERTY(EditDefaultsOnly) TSubclassOf Ability; + UPROPERTY(EditDefaultsOnly) FGameplayTag InputTag; // which slot; the ability's own InputTag is the default +}; + +UCLASS(BlueprintType) +class UClassKitDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Class.Warrior + UPROPERTY(EditDefaultsOnly) FText DisplayName; + UPROPERTY(EditDefaultsOnly) TArray Abilities; // slot order; at most four on the bar + UPROPERTY(EditDefaultsOnly) FStatBlockDefaults BaseStats; // applied through the one init effect (Stats.md) + UPROPERTY(EditDefaultsOnly) TObjectPtr StartingWeapon; + UPROPERTY(EditDefaultsOnly) float ReviveSeconds = 4.f; // the Cleric's is 2 + // IsDataValid: at most one bSignature ability, at most four slots, distinct input tags. +}; +``` + +`UExtendedAbilitySystemComponent` routes input by tag: on `Input.Ability.2` pressed it activates every granted +ability whose asset tags contain that input tag. The player character forwards its `IA_Ability*` and `IA_Attack` +presses there and decides nothing. Granting a kit is `GrantKit(const UClassKitDefinition&)` on the player state's +component: `ApplyDefaults(BaseStats)`, give each ability with its input tag, equip the starting weapon. Changing +class regrants. + +The kits, sketched. Each is a role, a signature no mod may grant elsewhere, and a silly escalation that later +content pushes on. Two abilities each for the steps; slots three and four are content. + +| Kit | Role | Weapon | Signature | Second ability | Escalation later | +| --- | --- | --- | --- | --- | --- | +| Warrior | Hold attention, absorb, open a path | Sword, or shield and mace | **Taunt**: force nearby enemies onto you | Shoulder charge: damage to enemies in a cone, impulse to teammates | Charge launches teammates across rooms | +| Ranger | Sustained ranged damage, mobility | Bow (step 11); dagger until then | **Mark**: a marked target takes more from everyone | Volley | Arrows that carry things | +| Mage | Burst and area, crowd control | Staff | **Blink**: short teleport along the aim | Fireball (step 11) | Blink takes whoever is touching you | +| Cleric | Sustain, revives, buffs | Mace | **Mass heal** | Mend: targeted heal; **overheal launches the target** | The heal launches people over walls | +| Rogue | Burst, mobility | Dagger | **Backstab**: aimed, high damage, short cooldown | Shadowstep: a self blink along the aim | | + +Effects an ability may apply, each through the engine's own mechanism and never by writing a value: **Damage** +(`GE_Damage` through the funnel), **Heal** (`GE_Heal` into `IncomingHeal`), **Impulse** (`LaunchCharacter` on the +target through a client RPC to its owner, since movement is owner-predicted, scaled by the target's +`KnockbackResist`), **Taunt** (`Status.Debuff.Taunted` through `ApplyStatus`, which the enemy controller turns +into `ForceTarget`; a boss's `Immune.Status.Taunted` blocks it like any status), **Mark** (`Status.Debuff.Marked` +for a duration, read by the funnel), **Slow**, **Haste** and every other buff or debuff (`ApplyStatus`, see +[Stats.md](Stats.md)), **Blink** and **Dodge** (root-motion sources through the movement component, with +`State.Invulnerable` for the dodge's i-frames). + +**Parity target:** every kit completes the same gym fight solo in roughly the same time. Not equal damage, equal +viability. `enemy_killed.killer_class` against `time_to_kill_s` is the measurement. + +**The recoverability contract**, salvaged whole: any verb that lets one player inconvenience another (a charge +into a carrier, a heal off a ledge, a blink with a passenger) must have a recovery available to the victim that +takes **less time than the verb took to perform**, and none may down, kill or permanently deprive. Damage is gated +in the funnel; things are never destroyed. Every such verb emits `grief_action`, because a rule nobody can measure +is a rule nobody can defend. + +## Projectiles (step 11) + +`AProjectileActor` with a `UProjectileMovementComponent`, spawned on the server by `GA_Volley` and `GA_Fireball` +and replicated; the firing client spawns a local cosmetic copy on activation so the shot leaves the hand without a +round trip (predict the animation, not the ownership), and the cosmetic copy hides itself when the replicated one +arrives. On hit the server applies `GE_Damage` with `Damage.Source.Projectile`. Travel time and drop are real, so +aim matters. The bow's `FWeaponProfile` has reach zero and a montage that fires `Event.Montage.Release` instead of +`Hit`. + +## Networking + +| State | Authority | Mechanism | +| --- | --- | --- | +| Attributes | Server | Replicated attribute set on the ASC (Mixed mode for players, Minimal for enemies) | +| Ability activation | Client requests, server decides | GAS prediction keys; LocalPredicted for the player's abilities, ServerOnly for GA_Downed | +| Damage | Server | Executions run on the server only | +| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim, which is fine in cooperative play | +| Cooldowns | Server, client predicts | Cooldown effects with prediction; the bar shows the prediction and takes the correction | +| Enemy brains | Server | StateTree ticks on the server; bodies replicate movement and montages | +| Impulses on players | Server decides, owner applies | Client RPC to the owner's body, because movement is owner-predicted | +| Statuses on bodies | Server, own-ability ones predicted | `ApplyStatus`, see [Stats.md](Stats.md) | +| Cues | Everywhere | Gameplay cues, unreliable multicast; presentation only | + +## Telemetry + +| Event | When | Payload | +| --- | --- | --- | +| `enemy_spawned` | The encounter spawns | `enemy`, `spawn_point`, `party_size` | +| `enemy_killed` | Health reaches zero | `enemy`, `killer_class`, `weapon`, `ability`, `time_to_kill_s` | +| `ability_used` | Activation commits | `ability`, `class`, `target_count` | +| `player_damaged` | Damage lands on a player | `source`, `attacker`, `amount`, `health_after` | +| `player_downed` | Health reaches zero | `source`, `party_alive`, `party_size` | +| `player_revived` | Revive completes | `by_class`, `down_duration_s` | +| `player_died` | Bleed-out or wipe | `down_duration_s`, `cause` | +| `party_wiped` | Nobody standing | `enemies_alive`, `duration_s` | +| `grief_action` | A recoverable verb on a teammate | `verb`, `actor`, `target` | + +## Tests + +- Automation: `DamageMath` for every branch above; `FThreatTable` for forced target expiry, decay and removal; + `UClassKitDefinition::IsDataValid` for two signatures and five slots. +- Functional: `FT_Combat_Funnel` applies `GE_Damage` from a player to a player and asserts health is unchanged and + the cue fired; `FT_Combat_Down` drives a player to zero, asserts `State.Downed`, revives through the interaction + path and asserts health; `FT_Combat_Swing` swings at a dummy behind a wall and asserts no damage. + +## Open questions + +- **Q1. Does a shove into a pit count as friendly fire?** The gate discards player-to-player damage, but a shove + that causes a fall produces fall damage from the world. Recoverable if the victim is downed rather than killed + and can be revived. Decide when the first pit exists; the funnel already distinguishes the cases. +- **Q2. Resource model.** Cooldowns only for these steps. Stamina is an attribute in the set so that adding a cost + is a cost effect, not a refactor. +- **Q3. Hit registration latency.** Server-authoritative with the owner's aim is the plan. At what latency does a + swing feel wrong? Test at 100 ms and 150 ms in step 7 and write the number down. +- **Q4. A blade sweep instead of a box.** If the box reads as imprecise once real animations arrive, sweep the + weapon actor's tip and base sockets per frame during the hit window. Contained to `GA_MeleeAttack`. +- **Q5. Which kits exist at all.** Five are named because two source projects between them named these five and + their signatures survive contact with the guardrail. Steps 9 and 10 build two; the rest are content. +- **Q6. Cross-class weapon animation.** A Warrior carrying a bow uses the bow's montage, which is authored per + weapon family, not per class. That is the cheap answer and it is the plan; revisit when animation gets real. diff --git a/Docs/Spec/Crafting.md b/Docs/Spec/Crafting.md new file mode 100644 index 0000000..9ce18e7 --- /dev/null +++ b/Docs/Spec/Crafting.md @@ -0,0 +1,717 @@ +# Crafting + +Owns what an item is made of and how it comes to exist: families, parts, pieces and their three trait layers; the +substances pieces are shaped from and the pipeline that refines them; the pure rules for legality, naming and +quality; stations and the one activity runtime their hands-on work runs on; enchanting; and the seam through which +a crafted weapon becomes a weapon you fight with. It does **not** own how a piece is picked up or inserted +([Interaction.md](Interaction.md)), what happens when the weapon lands ([Combat.md](Combat.md)), or any economy, +customer or shop: none of those exist here, and the ideas are parked in [`../Ideas.md`](../Ideas.md). + +Read [Architecture.md](Architecture.md) first. Steps 12 to 15 in [`../Steps.md`](../Steps.md). This is the +largest spec because the earlier smithing project had already worked its crafting model through several review +passes; what follows is that model, restated for Unreal and cut to what a fighting prototype needs. + +## Decisions + +``` +[DECIDED] Three words, three levels. A FAMILY declares PARTS. A PIECE is a crafted object filling one part. A +piece carries three TRAIT layers: a characteristic (its shape), a substance (what it is made of) and zero or more +enchantments (its ornament). An item is a family's parts, filled. + + Blade: mithril, blunt · Guard: mithril, pointy · Handle: dragonhide, of embers · Pommel: adamantine + -> "Blunt Mithril Sword of Embers". The player made that; they did not pick it from a list. +``` + +``` +[DECIDED] The design word "material" is "substance" in this project's code and docs. UMaterial is the engine's +render material and the collision would be permanent. Substance is the word the earlier design used in its own +subtitle, so nothing of meaning is lost. +``` + +``` +[DECIDED] No recipes, anywhere. A family lists slots and filters. Any slot-complete, filter-satisfying set of +pieces is a legal item. If a combination needs a table to be legal, the filters are wrong. +``` + +``` +[DECIDED] Substances are objects, not stacks. Twelve iron ingots are twelve actors, each with its own identity +and quality. Quality variance is then free, provenance is free, and "the sword made from that dragon's bone" is a +queryable fact. Affordable only because storage is physical and caps the count: any future feature that lets +substance accumulate without occupying space breaks the assumption and is refused on that ground. +``` + +``` +[DECIDED] Discovery unlocks, choice applies. You discover characteristics through play; you choose them at the +station. Strike quality decides how well a shape came out, never which shape you got, so a crafted item is always a +deliberate act and a request for "a serrated blade" is always fillable. +``` + +``` +[DECIDED] One activity runtime, three guarantees. Every hands-on station task runs on one runtime that owns +participation, authority, progress, pause, quality accumulation and telemetry; a minigame implements only its own +rules. The guarantees, binding forever: completion is never gated on skill; skill modulates a quality band and +nothing else; a second pair of hands is always additive and never required. +``` + +``` +[DECIDED] The assembly bench is domain-neutral. Every pipeline converges there; gating it would make one player a +bottleneck. Domains gate the WORK (a station refuses a piece of the wrong domain) and never the WORKER (anyone may +use any station). Class changes speed and quality only. +``` + +``` +[DECIDED] Durability is a part property summed into an item total, and the item is lost at zero. Nothing in the +shop wears out from crafting; wear is something the world does to gear later. +``` + +## Vocabulary and tags + +| Term | Meaning | Tag namespace | +| --- | --- | --- | +| Family | A kind of item and the schema of its parts, as a tree | `Item.Family.Sword`, `.Axe`, `.Bow`, `.Shield` | +| Part | A position in an item | `Item.Part.Blade`, `.Guard`, `.Handle`, `.Pommel` | +| Piece type | What a piece is, before its traits | `Item.PieceType.Blade`, `.Guard`, `.Handle`, `.Pommel` | +| Piece | A crafted object filling a part; has an `FGuid` | runtime, `FPieceInstance` | +| Trait layer | Characteristic, substance, enchantment | `Piece.Layer.*` | +| Characteristic | Shape: serrated, blunt, curved, heavy | `Item.Characteristic.*` | +| Substance | What a piece is made of | `Item.Substance.Iron`, `.Oak`, `.Mithril` ... | +| Enchantment | Ornament: of embers, of frost | `Item.Enchant.Fire.Penetration` ... | +| Substance object | One physical ingot, log, hide; has an `FGuid` | runtime, `FSubstanceInstance` | +| Domain | Which station family works a thing | `Domain.Forge`, `.Wood`, `.Enchant` | +| Theme | Synergy vocabulary shared by characteristics and enchantments | `Theme.Aggressive`, `.Defensive`, `.Fire` ... | +| Coherence | An item's quality score: piece values plus synergy | runtime | + +Other namespaces this spec owns: `Substance.Class.[Metal|Wood|Hide|Bone|Arcane|Fuel]`, +`Substance.Rarity.[Common|Uncommon|Rare|Unique]`, `Substance.State.[Raw|Processed]`, +`Station.Type.*`, `Station.State.[Idle|Occupied|Working|Blocked|Paused]`, `Activity.[Heat|Strike|Assemble]`, +`Activity.State.*`, `Fault.*`, `Mark.*`, and `Craft.Reason.*` for every rejection. + +## Layout + +``` +Source/Core/Crafting/ +├── CraftingDefinitions.h // the definition asset classes (data only, no world) +├── CraftingTypes.h // FPieceInstance, FAssembledItem, FSubstanceInstance, FProvenance, verdict structs +├── CraftingRules.h / .cpp // pure: CanFillSlot, ValidateAssembly, ResolveAttachChains, ComposeName, ScoreCoherence, +│ // PieceValue, PartDurability, MakeWeaponProfile +└── ActivityTypes.h // FActivityResult, FActivitySession, the strike and heat rule structs (pure parts) + +Source//Crafting/ +├── CraftingSubsystem.h // UWorldSubsystem, server: the Server* flows, the item registry, discovery +├── SubstanceActor.h // a carryable physical substance object +├── PieceActor.h // a carryable piece +├── AssembledItemActor.h // a carryable assembled item; builds its visual from sockets; equippable if a weapon +├── Stations/ +│ ├── StationDefinition.h // UPrimaryDataAsset +│ ├── StationActor.h // IInteractable receptacle, buffers, operators, state machine +│ ├── AssemblyBench.h // staging, preview, commit +│ ├── AnvilStation.h // hosts the strike activity +│ └── ForgeStation.h // hosts the heat activity, the bed +└── Activities/ + ├── ActivityRuntime.h // UWorldSubsystem: sessions, participants, pause, the one result funnel + ├── ActivityDefinition.h // UPrimaryDataAsset per activity + ├── StrikeActivity.h // the growing zone + └── HeatActivity.h // the coal bed grid, two LODs + +Content/Crafting/ +├── Definitions/Families/DA_Family_Sword +├── Definitions/PieceTypes/DA_PieceType_Blade, _Guard, _Handle, _Pommel +├── Definitions/Characteristics/DA_Char_Straight, _Serrated, _Blunt, _Curved +├── Definitions/Substances/DA_Sub_IronOre, _IronIngot, _OakLog, _OakPlank, _Coal +├── Definitions/Enchantments/DA_Ench_Embers, _Frost +├── Definitions/Stations/DA_Station_AssemblyBench, _Anvil, _Forge +├── Definitions/Activities/DA_Activity_Strike, _Heat +├── DA_CraftingConfig // the weights and thresholds every pure rule reads +└── Meshes/SM_Proto_Blade ... // primitives with named sockets, tinted by substance +``` + +## Data + +```cpp +// Source/Core/Crafting/CraftingDefinitions.h + +UCLASS(BlueprintType) +class UPieceTypeDefinition : public UPrimaryDataAsset // "Blade", "Guard", "Handle", "Pommel" +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag PieceTypeTag; // Item.PieceType.Blade + UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Blade: which part it can fill + UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge | Domain.Wood: who shapes it + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer FitsFamilies; // Item.Family.Sword, .Dagger ... + UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Sword": root candidate when this part dominates + UPROPERTY(EditDefaultsOnly) int32 PartWeight = 1; // dominance for naming and scoring: blade 4, guard 2, handle 2, pommel 1 + UPROPERTY(EditDefaultsOnly) int32 BaseValue = 10; + UPROPERTY(EditDefaultsOnly) int32 SubstanceObjects = 1; // objects consumed to shape one: blade 3, guard 1, handle 2, pommel 1 + UPROPERTY(EditDefaultsOnly) int32 BaseDurability = 100; + UPROPERTY(EditDefaultsOnly) int32 AddonSockets = 1; // enchantment capacity + UPROPERTY(EditDefaultsOnly) int32 HotSpots = 4; // for the strike activity, 3..6 + UPROPERTY(EditDefaultsOnly) FVector2D ScaleRange = FVector2D(0.7f, 1.6f); // absurd proportions are legal, bounded + UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // what this part adds to a weapon profile, see the seam + UPROPERTY(EditDefaultsOnly) TSoftObjectPtr Mesh; // carries the named sockets +}; + +UCLASS(BlueprintType) +class UCharacteristicDefinition : public UPrimaryDataAsset // "Serrated", "Blunt", "Curved", "Heavy" +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag CharacteristicTag; + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToParts; // a blade can be serrated; a pommel cannot + UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // synergy input + UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Serrated", the prefix + UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0; + UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // serrated: +damage, -durability, say + UPROPERTY(EditDefaultsOnly) bool bKnownFromStart = false; // else discovered +}; + +UCLASS(BlueprintType) +class USubstanceDefinition : public UPrimaryDataAsset // the TYPE: "Iron Ingot", "Oak Log" +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag SubstanceTag; // Item.Substance.Iron + UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Substance.Class.Metal + UPROPERTY(EditDefaultsOnly) FGameplayTag RarityTag; // Substance.Rarity.Common + UPROPERTY(EditDefaultsOnly) FGameplayTag StateTag; // Substance.State.Raw | .Processed + UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer WorkableBy; // Domain.Forge ... + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ArcaneAffinity; // which enchantment themes it accepts + UPROPERTY(EditDefaultsOnly) int32 BaseValue = 5; + UPROPERTY(EditDefaultsOnly) float DurabilityFactor = 1.f; // dragonbone 1.5, oak 0.1 + UPROPERTY(EditDefaultsOnly) FLinearColor PaletteColour; // the substance IS the palette in flat-shaded low poly + // heat, for the forge: working band and burn point in arbitrary heat units, thermal mass + UPROPERTY(EditDefaultsOnly) float WorkingTempMin = 0.f, WorkingTempMax = 0.f, BurnPoint = 0.f, ThermalMass = 1.f; + UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false; // a log takes both hands + // processing: Raw -> Processed + UPROPERTY(EditDefaultsOnly) TObjectPtr ProcessesInto; + UPROPERTY(EditDefaultsOnly) int32 ProcessInputCount = 1, ProcessOutputCount = 1; + UPROPERTY(EditDefaultsOnly) FGameplayTag ProcessDomainTag; + UPROPERTY(EditDefaultsOnly) TSoftObjectPtr Mesh; +}; + +UCLASS(BlueprintType) +class UEnchantmentDefinition : public UPrimaryDataAsset // "of Embers" +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag EnchantmentTag; + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToSubstances; // affinity gate, both sides must agree + UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // Theme.Fire + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer GrantsTags; // Item.Enchant.Fire.Penetration: reaches the weapon profile + UPROPERTY(EditDefaultsOnly) TSubclassOf HolderEffect; // optional: granted to whoever equips the item, e.g. GE_Immune_Burning (Stats.md) + UPROPERTY(EditDefaultsOnly) FGameplayTag DamageType; // Damage.Type.Magic, optional + UPROPERTY(EditDefaultsOnly) FText NameFragment; // "of Embers", the suffix + UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0, SocketCost = 1; +}; + +USTRUCT() +struct FPartSlot +{ + GENERATED_BODY() + UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Guard + UPROPERTY(EditDefaultsOnly) bool bRequired = false; // blade and handle yes; guard and pommel no + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer Accepts; // piece-type tags this slot takes + UPROPERTY(EditDefaultsOnly) TArray AttachesTo; // ordered parent preference: [Guard, Handle] + UPROPERTY(EditDefaultsOnly) FName ParentSocket; // "Socket_Attach_Blade" on the parent's mesh + UPROPERTY(EditDefaultsOnly) FIntPoint GridPos; // the assembly panel's layout, traces the silhouette +}; + +UCLASS(BlueprintType) +class UItemFamilyDefinition : public UPrimaryDataAsset // "Sword" +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag FamilyTag; // Item.Family.Sword + UPROPERTY(EditDefaultsOnly) TArray Parts; // the schema; exactly one slot has an empty AttachesTo (the root) + UPROPERTY(EditDefaultsOnly) FText RootFragment; // "Sword", if no part supplies a root + UPROPERTY(EditDefaultsOnly) bool bIsWeapon = true; + UPROPERTY(EditDefaultsOnly) FSoftObjectPath Montage; // the swing animation every sword shares + UPROPERTY(EditDefaultsOnly) FGameplayTag HandSocket; // where an equipped one attaches + // IsDataValid: one root, no duplicate part tags, every AttachesTo names a part in this family, sockets non-empty. +}; + +UCLASS(BlueprintType) +class UCraftingConfig : public UPrimaryDataAsset // the weights every pure rule reads; one asset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) float SubstanceSynergyWeight = 3.f, ThemeSynergyWeight = 4.f, CompletenessWeight = 5.f; + UPROPERTY(EditDefaultsOnly) float SubstanceMajorityThreshold = 0.5f; // share of PartWeight one substance needs to be named + UPROPERTY(EditDefaultsOnly) int32 MaxNameWords = 5; + UPROPERTY(EditDefaultsOnly) FVector2D QualityFactorRange = FVector2D(0.85f, 1.15f); // what MaterialQuality and CraftQuality scale between + UPROPERTY(EditDefaultsOnly) float DamagePerQualityPoint = 0.2f; // the seam's exchange rate + UPROPERTY(EditDefaultsOnly) float ScrapReturnFraction = 0.2f; +}; +``` + +The sword family, as data: + +``` +DA_Family_Sword (Item.Family.Sword) + Handle required root (AttachesTo empty) + ├── Guard optional AttachesTo [Handle] ParentSocket "Socket_Attach_Guard" + │ └── Blade required AttachesTo [Guard, Handle] ParentSocket "Socket_Attach_Blade" + └── Pommel optional AttachesTo [Handle] ParentSocket "Socket_Attach_Pommel" +``` + +`Blade.AttachesTo = [Guard, Handle]` is what makes optional parts work without special cases: resolution walks +the list and attaches to the first ancestor present, so a guardless sword's blade lands on the handle's own +`Socket_Attach_Blade`, which every handle mesh therefore carries. Other families (axe: haft root with head and grip; +bow: riser root with limbs and string; shield: face root with rim, boss and straps) are the same shape and are +content, not code. A part tag is unique within a family; a second instance of the same kind of part is its own tag +(`Item.Part.Pauldron.Left`), which the part-keyed map enforces structurally. + +Sockets are the engine's static mesh sockets, authored on the mesh asset and resolved by name, never by index, so +an artist can move `Socket_Attach_Guard` without touching gameplay data. A missing socket logs a content error and +degrades to the parent's origin rather than failing: a greybox item must always assemble. + +## Runtime state + +```cpp +USTRUCT() +struct FProvenance +{ + GENERATED_BODY() + UPROPERTY() FGameplayTag Kind; // Provenance.Starting | .Crafted | .Found | .Reward ...; extensible, never an enum + UPROPERTY() FGuid Ref; // the crafter's player id, or whatever the kind refers to + UPROPERTY() int32 Day = 0; // reserved +}; + +USTRUCT() +struct FSubstanceInstance // THE physical object: one per ingot, log, hide +{ + GENERATED_BODY() + UPROPERTY() FGuid ObjectId; + UPROPERTY() TObjectPtr Type; + UPROPERTY() float Quality = 0.5f; // 0..1, set by the activity that made it + UPROPERTY() FProvenance Origin; + UPROPERTY() FGameplayTagContainer Marks; // Mark.Overheated, Mark.Salvaged, Mark.Dented: flavour and valuation + // Temperature is NOT here. It lives on the forge bed while the object is in it (HeatActivity), and it is not saved. +}; + +USTRUCT() +struct FPieceInstance // a crafted object filling one part +{ + GENERATED_BODY() + UPROPERTY() FGuid PieceId; + UPROPERTY() TObjectPtr Type; + UPROPERTY() TObjectPtr Characteristic; + UPROPERTY() TObjectPtr Substance; + UPROPERTY() float SubstanceQuality = 0.5f; // average of the objects consumed + UPROPERTY() TArray SourceObjectIds; // WHICH objects: provenance and unique-substance binding + UPROPERTY() float CraftQuality = 0.5f; // the shaping activity's contribution + UPROPERTY() TArray> Enchantments; // bounded by Type->AddonSockets + // DERIVED, recomputed on load, never persisted: Value, Durability +}; + +USTRUCT() +struct FAssembledItem +{ + GENERATED_BODY() + UPROPERTY() FGuid ItemId; + UPROPERTY() TObjectPtr Family; + UPROPERTY() TMap PartsFilled; // Item.Part.* -> piece. A map, so "what is in the Guard slot" is the question + UPROPERTY() int32 Durability = 0; // CURRENT: persisted, it is history + // DERIVED, recomputed on load: DerivedName, QualityScore, MaxDurability, the weapon profile +}; +``` + +`SourceObjectIds` looks like overhead on the first day and is load-bearing later: it is what lets a finished sword +answer "is this the bone from that particular kill" years on, and it is the field an audit trail needs. + +## The pure rules + +All in `CraftingRules`, in the core module, no world, every one tested. They are the single authority consulted +by the bench preview, the server commit, and later anything that asks whether an item satisfies a request. +Preview and reality call the same function and cannot disagree. + +```cpp +namespace CraftingRules +{ + struct FSlotVerdict { bool bAccept; FGameplayTag Reason; }; // Craft.Reason.* on rejection + struct FAssemblyVerdict + { + bool bValid; FGameplayTag Reason; FGameplayTag OffendingPart; + FText PreviewName; int32 PreviewQuality; int32 PreviewSynergy; int32 PreviewSubstanceValue; + }; + struct FAttachment { FGameplayTag Child, Parent; FName Socket; }; + + // pure. Both sides must agree: the slot accepts the type, the type fits the family, the shape fits the part. + // No scale gating: a greatsword blade on a dagger handle is legal because it is funny; ScaleRange bounds it. + FSlotVerdict CanFillSlot(const FPieceInstance& Piece, const FPartSlot& Slot, const UItemFamilyDefinition& Family); + // Type->PartTag != Slot.PartTag -> WrongPart + // !Slot.Accepts.HasTag(Type->PieceTypeTag) -> SlotRejectsType + // !Type->FitsFamilies.HasTag(Family.FamilyTag) -> FamilyMismatch + // !Characteristic->ApplicableToParts.HasTag(part) -> IllegalShape + + // pure. Every required slot filled, every filled slot legal, no foreign part, every attach chain resolves. + FAssemblyVerdict ValidateAssembly(const UItemFamilyDefinition& Family, const TMap& Parts, + const UCraftingConfig& Config); + // MissingPart, ForeignPart (a pommel on a bow), UnreachablePart (an authoring error), else Valid with previews + + // pure. Walks each slot's AttachesTo and emits child -> first present parent -> socket, parent before child. + TArray ResolveAttachChains(const UItemFamilyDefinition& Family, const TMap& Parts); + + // pure. Prefix + [substance] + root + suffix, at most one fragment per role. Word salad is structurally impossible. + FText ComposeName(const UItemFamilyDefinition& Family, const TMap& Parts, const UCraftingConfig& Config); + // dominant = the piece with the highest PartWeight + // prefix = dominant.Characteristic->NameFragment + // core = the substance holding >= SubstanceMajorityThreshold of total PartWeight, else nothing + // (this is what stops "Iron Dragonhide Mithril Sword" from ever being generated) + // root = dominant.Type->NameFragment, else Family.RootFragment + // suffix = the highest-value enchantment's NameFragment across all pieces + // over MaxNameWords: drop suffix, then core, then prefix. Prefix sharing a stem with root drops the prefix. + + // pure. A coherent item beats an expensive one. + int32 ScoreCoherence(const UItemFamilyDefinition& Family, const TMap& Parts, + const UCraftingConfig& Config, int32* OutSynergy = nullptr); + // base = Σ PieceValue(piece) * PartWeight + // synergy = SubstanceSynergy (n parts of one substance score n(n-1)/2 pairs: commitment, not accident) + // + ThemeSynergy (matching pairs among all characteristic and enchantment themes) + // + CompletenessBonus (optional parts filled; without it "required parts only" is strictly optimal) + + int32 PieceValue(const FPieceInstance& Piece, const UCraftingConfig& Config); + // (Type->BaseValue + Substance->BaseValue + Characteristic->ValueDelta + Σ enchant ValueDelta) + // * lerp(QualityFactorRange, SubstanceQuality) * lerp(QualityFactorRange, CraftQuality) + + int32 PartDurability(const FPieceInstance& Piece, const UCraftingConfig& Config); + // Type->BaseDurability * Substance->DurabilityFactor * the two quality factors + int32 MaxDurability(const FAssembledItem& Item, const UCraftingConfig& Config); // Σ PartDurability + + // pure. The seam to Combat.md. See "From item to weapon". + FWeaponProfile MakeWeaponProfile(const FAssembledItem& Item, const UCraftingConfig& Config); +} +``` + +The reference example, run through: dominant is the blade (weight 4), prefix "Blunt"; mithril holds blade plus +guard, six of nine weight, so core "Mithril"; root "Sword"; the handle's fire enchantment supplies "of Embers". +*Blunt Mithril Sword of Embers*. That is the first automation test. + +## Substances + +**The pipeline, enforced by one tag.** A raw object is never valid input to a shaping station and a processed +object is never valid input to a processing station; `StateTag` alone enforces it, with no per-station recipe list. + +``` +RAW PROCESSED PIECE +Iron Ore -> Iron Ingot -> Iron Blade +Oak Log -> Oak Plank -> Oak Handle +Coal -> (fuel: never processed, burned by the forge bed) +``` + +Cost is authored on the piece type, not per type and substance, so substance choice is a free axis: any workable +substance makes any piece of that type at the same object cost, with different value, colour and heat behaviour. +Combinatorial variety, no combinatorial authoring. + +**Object semantics.** Identity always. No merging, no splitting: objects are created and consumed whole. +Aggregated for display only ("Iron Ingot ×12, avg quality 0.62" is a HUD affordance over twelve distinct actors). +Consumption picks **worst-fit-first** by default: a station needing three ingots from a rack of nine takes the three +lowest-quality qualifying ones, shows which before starting, and the player may override. Good stock is saved for +good work by default. + +`ASubstanceActor` is a carryable ([Interaction.md](Interaction.md)) with a `UStaticMeshComponent` tinted by the +substance's palette colour through custom primitive data (no dynamic material instance per object), net dormancy on +while at rest, and its `FSubstanceInstance` replicated to whoever is in range. Rare and unique objects on the floor +are never swept away by any future tidy-up rule; they glow faintly. + +**Quality variance is the natural case.** The smelt activity's result lands in `Quality` and scales the object's +value; a careless smelt is worth less, a careful one more, both are usable. **Class bonus applies to time and +quality, never to yield**: a better smith's smelt is faster and purer, never more numerous, because a yield bonus is +an efficiency lock. Concretely, a bonus is the `WorkSpeed` and `WorkQuality` attributes on the body's stat block +([Stats.md](Stats.md)), which the activity runtime reads; a class is base numbers there and nothing else. + +Supply, for these steps, is a spawner in the gym: `bs.SpawnSubstance ` and a crate that refills. Where +substances come from in a world (purchase, contracts, the world itself) is [`../Ideas.md`](../Ideas.md). + +## Stations + +```cpp +UCLASS(BlueprintType) +class UStationDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag StationTag; // Station.Type.Anvil + UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge; empty on the assembly bench + UPROPERTY(EditDefaultsOnly) TObjectPtr Activity; // null for a plain station + UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1, always, everywhere + UPROPERTY(EditDefaultsOnly) int32 InputCapacity = 4, OutputCapacity = 4; + UPROPERTY(EditDefaultsOnly) float BaseSpeedMultiplier = 1.f; + UPROPERTY(EditDefaultsOnly) FGameplayTagContainer AcceptsInsert; // Substance.State.Processed for an anvil, Item.PieceType for a bench +}; + +/** + * Every station. An interactable (join, leave, take output) and an insert receptacle. Operators are a LIST because + * the anvil takes two smiths; a second operator raises the ceiling and never the floor. Work at a station PAUSES + * when everyone leaves and never fails: losing progress would punish exactly the behaviour a co-op game wants + * (dropping what you are doing to help someone). The clock that matters runs elsewhere. + */ +UCLASS() +class AStationActor : public AActor, public IInteractable +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) TObjectPtr Definition; + UPROPERTY(Replicated) FGameplayTag StateTag; // Station.State.* + UPROPERTY(Replicated) TArray> Operators; + UPROPERTY(Replicated) FGuid ActivitySession; // 0 when idle + UPROPERTY(Replicated) TArray InputBuffer, OutputBuffer; // object ids; the forge's "buffer" is its bed + + // IInteractable: Interact.Verb.Operate joins (or leaves when already an operator); the prompt names who is on it + // when full, in their colour, and nothing else happens: occupied stations do not queue you. TakeOutput is a + // second verb when the output buffer holds something and your hands are free. + // Insert (the receptacle path from Interaction.md): validate AcceptsInsert and DomainTag against the carried + // object's tags; reject WrongDomain or NotAccepted with the reason; move the object into InputBuffer. + // ServerStartWork: an operator, inputs present -> ActivityRuntime->ServerBeginActivity(...). On completion, + // if the output buffer has no room the station is Blocked, never destroying anything, until someone empties it. + // No queueing. Batching is physical: the forge bed holds many items at once because it is a bed. +}; +``` + +**The assembly bench** is a station with no activity and no domain. Inserting a piece stages it into the part its +type fills (the map makes a second piece in one part structurally impossible); every insertion re-runs +`ValidateAssembly` and shows the preview name, quality, synergy segment and the failing reason on the bench's +world-space panel; the `Assemble` verb commits through `ServerAssembleItem`, which re-runs the verdict server-side +and never trusts the preview. The session's initiator commits; anyone may insert. The assembled item appears in +the output buffer as an `AAssembledItemActor` built from the resolved attach chains, each piece's mesh on its +parent's named socket, tinted by its substance. + +## Activities + +``` +[SALVAGED] One runtime, so that the forge's interactivity is not lost because the anvil learned to take two +players, and so that five copied minigames cannot drift into five bug surfaces. A minigame overrides OnBegin, +ApplyInput, Tick, EvaluateProgress and CollectFaults. It cannot invent its own participation model, save shape or +way of producing quality; those are closed. +``` + +```cpp +UCLASS(BlueprintType) +class UActivityDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag ActivityTag; // Activity.Strike + UPROPERTY(EditDefaultsOnly) TSubclassOf Rules; // UStrikeActivity, UHeatActivity + UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1: serialised as a constant so it is visible + UPROPERTY(EditDefaultsOnly) FVector2D QualityBand = FVector2D(0.85f, 1.15f); // guarantee 2: skill modulates this and nothing else + UPROPERTY(EditDefaultsOnly) float BaselineSeconds = 6.f; // "steady work" duration at one operator + UPROPERTY(EditDefaultsOnly) float BaselineScore = 0.4f; // what steady work scores: guarantee 1 + UPROPERTY(EditDefaultsOnly) bool bAllowAutoPlay = true; // hold-to-work is a first-class setting + UPROPERTY(EditDefaultsOnly) float AssistWindowScale = 1.f; // accessibility: widens timing tolerances, never gated +}; + +USTRUCT() +struct FActivityResult // the ONE funnel; nothing else may produce quality +{ + GENERATED_BODY() + float QualityContribution = 0.f; // 0..1, mapped through QualityBand by the consumer + float TimeSpent = 0.f; + TMap ContributionByPlayer; // attribution: never clawed back, feeds telemetry and later XP + FGameplayTagContainer Faults; // Fault.Overheated, Fault.Misstruck +}; + +/** UWorldSubsystem, server. Owns every session; the station owns none of this. */ +UCLASS() +class UActivityRuntime : public UTickableWorldSubsystem +{ + GENERATED_BODY() +public: + FGuid ServerBeginActivity(APlayerState* Instigator, AStationActor* Host, const UActivityDefinition& Def, const FActivityPayload& Payload); + bool ServerJoinActivity(APlayerState* Who, FGuid Session); // additive, always optional; ActivityFull when at max + void ServerLeaveActivity(APlayerState* Who, FGuid Session); // keeps their contribution; last one out pauses + void ServerActivityInput(APlayerState* Who, FGuid Session, const FActivityInput& Input); // THE per-frame path + // validates participant and reach (with a short leash and a visible warning before dropping out), + // delta = Rules->ApplyInput(session, who, input); accumulates and attributes + // Tick: Rules->Tick per running session; steady-work accrual when auto-play is engaged; progress advances at + // the operators' summed WorkSpeed and the quality floor is raised by their best WorkQuality (both attributes on + // the stat block, Stats.md); complete at progress 1: + // result -> Host->DeliverResult(result, payload) -> the substance or piece is made; emit activity_completed. +}; +``` + +**The anvil: the growing zone.** Not a rhythm game; a reaction-and-placement game scored as a percentage. The +heated piece exposes 3 to 6 hot spots (authored per piece type, in the piece's own space so the data survives an +art swap). Each round one becomes the active zone, a circle that starts small and grows; the player moves the hammer +over it and strikes. Inside the zone, score is `InverseLerp(MaxRadius, MinRadius, radiusAtHit)`: tiny is excellent, +huge is poor. Outside is `Fault.Misstruck` and the zone keeps growing. Never struck, the round expires at zero. No +input at all, each round auto-resolves at `BaselineScore`: completion is never gated on reaction. After the authored +round count, `QualityContribution` is the mean. **Temperature gates the whole thing:** below the substance's working +band the metal resists, growth speeds up and scores are capped. Go back to the forge. That one rule is the loop +between the two stations. Two strikers alternate the active zone; each hit is attributed. + +Because the zone's radius is a deterministic function of time, a strike is lag-compensated by arithmetic, not +history: the client stamps its input, the server evaluates `radius(clientTime)` directly, clamped to a 250 ms +window and never scoring better than the best achievable at the earliest legitimate arrival. Two strikers resolve +in timestamp order. See [Networking.md](Networking.md), tier three; it is the only timing-scored input in the game. + +**The forge: heat in a bed.** A coal bed is a fixed 5×5 grid of cells with temperature and fuel. On the server at +10 Hz: fuel burns into temperature, temperature conducts to four neighbours (never all pairs), items in the bed +sample the cells they overlap and move toward that temperature at their own conductivity over thermal mass, and an +item over its substance's burn point takes `Fault.Overheated`. Inputs: place and move items, rake coals, add fuel, +work the bellows (an airflow spike, the cleanest second role in the game: pure help, zero risk). An item is done +when it has held its working band for the hold time; quality is time inside the band against time outside. The +bed holds many items at once, which is the only batching there is. + +Heat runs at **two levels of detail**: the full grid while a player is within the sim radius, one lumped +temperature and fuel pool ticked at 1 Hz when nobody is, with the spatial pattern stored across the collapse so +the bed you walk back to is the bed you left. The binding rule: far-mode outcomes must match near-mode outcomes +on average over the same wall-clock time, or walking away becomes an exploit. The comparison harness is thirty +lines and is written with the first heat prototype, not after. Heat is scoped to a bed; an object outside any +bed cools on a curve and stops simulating at ambient. Replication: 25 quantised bytes at 5 Hz to near clients, one +byte to far ones, coal glow and metal colour driven locally from the values. + +**Discovery.** Working metal turns up shapes nobody told you about: a run of excellent strikes on an unusual +hot-spot pattern, an odd substance at an odd temperature, a fault that turned out well. `ServerDiscoverCharacteristic` +adds the definition to the player's known set (per player, on the player state, so it travels with them), fires +`OnCharacteristicDiscovered` as a moment (the piece named and glowing on the anvil, a line in the log, never a +screen-centre banner), and from then on the characteristic is an ordinary choice at the station. The glyph +discovery the earlier project planned for enchanting is the same shape and should generalise this rather than +grow beside it; for these steps enchantments are a fixed authored set. + +**Scrapping** runs no activity: a plain interaction returning `ScrapReturnFraction` of the constituent objects, +rounded down, quality scaled, marked `Mark.Salvaged`. A frictionless sink for failed work. + +## Enchanting + +Applied to a piece, before or after assembly, into addon sockets bounded by the piece type. Both filters must +agree: the enchantment's `ApplicableToSubstances` and the substance's `ArcaneAffinity` meet in the middle, so a new +substance declares what it accepts rather than every enchantment being edited. Enchanting an assembled item routes +to the same call per target piece; there is no second code path. + +```cpp +// UCraftingSubsystem +bool ServerApplyEnchantment(APlayerState* Instigator, FGuid PieceId, const UEnchantmentDefinition& Ench); +// Validate: at a Domain.Enchant station; UsedSockets + SocketCost <= Type->AddonSockets else NoFreeSocket; +// affinity both ways else SubstanceRejectsEnchant. Add; recompute derived; OnPieceEnchanted; emit piece_enchanted. +``` + +## Server flows + +```cpp +// UCraftingSubsystem (world, server). Every entry validates the instigator, re-runs the pure verdict, mutates +// through the registry, then notifies. In solo exactly as in co-op. + +bool ServerProcessSubstance(APlayerState* Instigator, AStationActor* Station, TArray Inputs); +// all inputs one type and Raw else AlreadyProcessed; station domain matches ProcessDomainTag else WrongDomain; +// count >= ProcessInputCount else NotEnough. Begin the heat activity with the inputs as payload. +// On result: consume inputs; emit ProcessOutputCount objects of ProcessesInto with Quality from the result, +// Origin Crafted(instigator), Mark.Overheated if the fault is present. OnSubstanceProcessed. + +bool ServerShapePiece(APlayerState* Instigator, AStationActor* Station, const UPieceTypeDefinition& Type, + const UCharacteristicDefinition& Char, TArray Objects); +// station domain == Type->DomainTag; Char applicable to Type->PartTag; Char known to instigator; +// Objects.Num() >= Type->SubstanceObjects, one substance, all at working temperature (the anvil reads the bed). +// Begin the strike activity. On result: new FPieceInstance with SubstanceQuality = avg, SourceObjectIds, +// CraftQuality = result.QualityContribution; consume objects; output buffer; OnPieceShaped. + +bool ServerAssembleItem(APlayerState* Instigator, AAssemblyBench* Bench, const UItemFamilyDefinition& Family); +// verdict = ValidateAssembly(Family, Bench->Staged, Config); if invalid reject(verdict.Reason) even if the +// client's preview said otherwise. New FAssembledItem; Durability = MaxDurability; consume pieces (they cease +// to exist as objects); spawn the item actor into the output buffer; OnItemAssembled; emit item_assembled. +``` + +## From item to weapon + +``` +[PROPOSED] A crafted item of a weapon family IS a weapon. Nothing about the melee ability, the damage funnel or the +action bar knows whether the sword in hand was authored or crafted; both are an FWeaponProfile. +``` + +```cpp +USTRUCT() +struct FWeaponContribution // on a piece type and on a characteristic: what this part adds +{ + GENERATED_BODY() + UPROPERTY(EditDefaultsOnly) float Damage = 0.f, ReachCm = 0.f, WindupSeconds = 0.f, SwingSeconds = 0.f; + UPROPERTY(EditDefaultsOnly) float DamageMultiplier = 1.f; // characteristics multiply; piece types add +}; + +// CraftingRules::MakeWeaponProfile, pure: +// Damage = (Σ piece Type->Weapon.Damage) * Π characteristic DamageMultiplier +// + QualityScore * Config.DamagePerQualityPoint // coherence is what makes it hit harder +// ReachCm = Σ Type->Weapon.ReachCm (the blade supplies most of it), scaled by the blade piece's authored scale +// Windup, Swing = Σ contributions, clamped to sane bounds +// DamageType = the highest-value enchantment's DamageType if set, else Damage.Type.Physical +// GrantedTags = ∪ enchantment GrantsTags // the funnel and future rules read these +// bTwoHanded = the family says so, or the blade piece's scale is past its range's midpoint +// Montage = Family.Montage; WeaponTag = Family.FamilyTag +``` + +Equipping: an `AAssembledItemActor` whose family is a weapon offers `Interact.Verb.Equip` when it is on the +ground or in your hand; the server builds an `AWeaponActor` from the profile, attaches it to the family's hand +socket, applies every piece's enchantment `HolderEffect` to the wielder for as long as it is held, and drops the +previously equipped weapon where you stand. One weapon equipped, whatever you carry. The combat step that proves +this is step 13: a crafted sword's coherence score visibly changes the damage number on a dummy. + +## Persistence shape + +Nothing persists in these steps; the shapes are written down now because the data-contract rules are cheap on the +first day and expensive later. + +``` +FSubstanceRecord { FGuid Id; FPrimaryAssetId Type; float Quality; FProvenance Origin; TArray Marks; } +FPieceRecord { FGuid Id; FPrimaryAssetId Type, Characteristic, Substance; float SubstanceQuality, CraftQuality; + TArray SourceObjectIds; TArray Enchantments; } // no Value, no Durability +FItemRecord { FGuid Id; FPrimaryAssetId Family; int32 Durability; TMap Parts; } // current durability only +- Content by primary asset id, tags by name. Derived fields recomputed on load, so a rebalanced config re-scores old items. +- Pieces recorded once and referenced by id from items, so a partial write can never duplicate one. +- Each record carries a schema_version and an adjacent migration function. +``` + +## Networking + +| State | Authority | Mechanism | +| --- | --- | --- | +| Substance and piece objects | Server | Replicated actors, dormant at rest, relevant by distance | +| Station state, operators, buffers | Server | Replicated properties on the station | +| Bench staging and the preview | Client previews, server verdicts | Same pure function on both; the client's preview is a question, never a claim | +| Activity sessions and inputs | Server | Inputs are server RPCs; the strike is timestamped | +| Heat grid | Server | 25 bytes at 5 Hz near, 1 byte far | +| Assembled items | Server | Replicated actor; the item struct replicated to the owner for the panel | +| Known characteristics | Server | On the player state, replicated to the owner | + +## Telemetry + +| Event | When | Payload | +| --- | --- | --- | +| `substance_processed` | Smelt completes | `type`, `count`, `quality_avg`, `faults` | +| `piece_shaped` | Strike completes | `type`, `characteristic`, `substance`, `craft_quality`, `rounds`, `round_scores[]` | +| `piece_enchanted` | Enchant applied | `piece_type`, `enchantment` | +| `item_assembled` | Commit succeeds | `family`, `piece_ids[]`, `name`, `quality`, `synergy`, `substance_value`, `time_at_bench_s` | +| `assembly_rejected` | Commit or insert refused | `family`, `reason`, `part` | +| `activity_completed` | Any activity | `activity`, `operators`, `quality`, `auto_play`, `faults`, `contribution_by_player` | +| `characteristic_discovered` | The moment | `characteristic`, `cause` | +| `weapon_equipped` | Equip | `family`, `crafted` (bool), `damage`, `quality` | + +`assembly_rejected` by reason is the friction signal on the crafting UX before anyone says "I didn't get it"; +`round_scores[]` against `auto_play` says whether the anvil is fun or a chore. + +## Tests + +- Automation, every rejection reason named: `CanFillSlot` for the four reasons; `ValidateAssembly` for missing, + foreign and unreachable parts and for the guardless sword validating; `ComposeName` for the reference example, + no prefix, no suffix, competing substances suppressing the core, the word limit and the stem collision; + `ScoreCoherence` for synergy rising with matching themes and for completeness making optional parts worth it; + `PartDurability` and `MaxDurability` for the dragonbone-hilt-oak-blade case; `MakeWeaponProfile` for a crafted + sword landing inside the authored sword's numbers at mid quality; the heat LOD equivalence harness. +- Functional: `FT_Crafting_Bench` spawns three pieces, inserts, commits and asserts the name and score; two clients + see the same assembled mesh with the blade on the handle's socket; `FT_Crafting_Anvil` with no input completes + at `BaselineScore`. + +## Open questions + +- **Q1. Is the known-characteristic set per player or per world?** Per player, on the player state: it travels + with them and gives a visiting veteran something real to bring. Cheap to move if wrong. +- **Q2. Two strikers: alternating or simultaneous?** Alternating first; it is the one that still works when the two + players have very different reaction speeds. +- **Q3. Hot-spot count and growth curve.** 3 to 6 spots and the radius numbers are authored per piece type and the + feel lives entirely there. A greybox pass, not a decision. +- **Q4. Where do pieces and items live beyond hands and station buffers?** Nowhere yet. Storage as a place + (containers with slots, no auto-pull, no infinite chest) is the earlier design and is parked in Ideas until a + step needs it. The two-hand rule and the object model already assume it. +- **Q5. Alloys.** A kiln combining two raw types into one processed type wants `ProcessesInto` to become a small + recipe struct. Not now, and **nobody hard-codes 1:1 processing** in the meantime. +- **Q6. Does substance quality want a visible tell?** A dull versus bright ingot at a glance would make the smith's + care legible across a room. Cheap (a tint lerp), competes with substance identity colour. An art call. diff --git a/Docs/Spec/Interaction.md b/Docs/Spec/Interaction.md new file mode 100644 index 0000000..3e0d743 --- /dev/null +++ b/Docs/Spec/Interaction.md @@ -0,0 +1,320 @@ +# Interaction, carrying and throwing + +Owns the one system through which a player acts on the world: what can be touched, how it is targeted, what the +prompt says, how the request reaches the server, and what happens to things a player picks up. It does **not** own +any prop's behaviour: a bench belongs to [Crafting.md](Crafting.md), a downed teammate to [Combat.md](Combat.md). +This document says how a player acts on a thing; the feature docs say what the thing does. + +Read [Architecture.md](Architecture.md) first. Built in step 6, before combat, because reviving a teammate and +inserting a piece into a bench are the same act on different props and neither should invent its own mechanism. + +## One system + +Before the earlier projects had this, their design docs described four implicit interaction models: a raycast +"use", a proximity push, a trigger volume you stood on, and an undefined "confirm". In a game where the world is +the interface that becomes a dozen mechanisms that each feel slightly different. One system instead, and one +interface, which is justified by the count: the two source projects between them named a bench, a station, a +container, a ticket, a contract card, a counter, a locker, a plinth, a lever, a cart's handles, a cart being righted, +and a downed player. Twelve implementations is not speculative abstraction. + +**The boundary that keeps it one system:** an interaction is a **discrete act on a fixed prop**. Carrying is +**continuous possession of a moving object**. Pushing is continuous force and is neither. Picking something up +therefore goes through the carry component, not the interaction service, even though both are on the same key; +the interaction component routes the press. + +## Layout + +``` +Source//Interaction/ +├── Interactable.h // IInteractable, the UInterface every prop implements (on an actor or a component) +├── InteractionTypes.h // FInteractionPrompt, FInteractionQuery, the reason tags +├── InteractionSubsystem.h // UWorldSubsystem: registry, server-side validation, the one dispatch path +├── InteractionComponent.h // on the player: targeting, prompt, the request RPC, hold-to-confirm +├── InteractionHighlightComponent.h // on a prop: custom depth outline, in-range and targeted states +├── Carryable.h // ICarryable and UCarryableComponent: what can be held, and by whom +└── CarryComponent.h // on the player: hands, pick up, drop, throw, hand over + +Content/Interaction/ +├── M_Outline_PP // the post-process outline material reading custom stencil +└── WBP_InteractionPrompt // one prompt widget, see UI.md +``` + +Trace channel `Interactable` is `ECC_GameTraceChannel1`, declared in `DefaultEngine.ini`; every interactable's +collider responds to it and nothing else does. Carryables additionally sit on `Carryable` (`ECC_GameTraceChannel2`). + +## Types at a glance + +| Type | Lifetime | Notes | +| --- | --- | --- | +| `IInteractable` | interface | Implemented by a prop actor, or by a component when one actor holds several props | +| `FInteractionPrompt`, `FInteractionQuery` | value | The prompt is the output of one function, on both peers | +| `UInteractionSubsystem` | world | Registry and the server-side funnel | +| `UInteractionComponent` | per player body | Owner only; the only thing that sends the request | +| `UInteractionHighlightComponent` | per prop | Presentation | +| `ICarryable`, `UCarryableComponent` | per carryable actor | Replicated `CarriedBy` | +| `UCarryComponent` | per player body | Two hands | + +## The interactable contract + +```cpp +USTRUCT(BlueprintType) +struct FInteractionQuery +{ + GENERATED_BODY() + TObjectPtr Instigator; // who is asking + TObjectPtr Body; // their body, for range and carried state + FGameplayTagContainer BodyTags; // State.Carrying, State.Downed, ... from the ability system +}; + +USTRUCT(BlueprintType) +struct FInteractionPrompt +{ + GENERATED_BODY() + FGameplayTag Verb; // Interact.Verb.Open, .Take, .Insert, .Revive, .Pull ...; localised by the widget + FText TargetName; // "Assembly bench", "Rainer" (a downed player's name is a name, not a key) + bool bEnabled = true; + FGameplayTag Reason; // Interact.Reason.HandsFull, .Occupied, .NotYours, .OutOfReach ... shown when disabled + float HoldSeconds = 0.f; // > 0 for irreversible verbs: scrap, cancel, leave +}; + +UINTERFACE(BlueprintType) +class UInteractable : public UInterface { GENERATED_BODY() }; + +/** + * Anything a player can touch. The prompt and the permission come from ONE function so what the prompt says is + * possible and what the server allows can never disagree. Interact() runs on the authority only. + */ +class IInteractable +{ + GENERATED_BODY() +public: + virtual FInteractionPrompt GetPrompt(const FInteractionQuery& Query) const = 0; // pure, called every frame locally + virtual void Interact(const FInteractionQuery& Query) = 0; // SERVER ONLY, after re-validation + virtual FVector GetInteractionLocation() const = 0; // what the server measures reach against + virtual bool IsLocalOnly() const { return false; } // true: acts on this machine's own state, never forwarded + virtual float GetPriorityBias() const { return 1.f; } // a person outranks the furniture they stand at + virtual FGameplayTag GetPropTag() const = 0; // stable identity for telemetry: Prop.Bench, Prop.ReviveTarget +}; +``` + +`IsLocalOnly` exists for props like a personal locker or a settings surface: things that only change this +player's own local state. They run their `Interact` on the client without a round trip and are never sent to the +server. Everything touching shared state stays forwarded. Without the flag, an earlier project had a guest's locker +setting the host's class. + +## The subsystem: one funnel + +```cpp +UCLASS() +class UInteractionSubsystem : public UWorldSubsystem +{ + GENERATED_BODY() +public: + // Reach. Tokens, not literals, so they are tuned in one place and the prompt and the server agree. + static constexpr float ReachCm = 180.f; + static constexpr float HighlightCm = 350.f; + static constexpr float ReachToleranceCm = 50.f; // the client's frame and the server's are a tick apart + + bool IsInReach(const FVector& BodyLocation, const IInteractable& Target, float Tolerance = 0.f) const; + + /** + * SERVER ONLY. The only path an interaction takes. Never trusts the client's claim that it was close enough + * or that the prompt was enabled: re-checks reach with tolerance, re-runs GetPrompt for permission, then calls + * Interact. Emits prop_interacted. One distance comparison is the difference between a convenience and an + * exploit. + */ + bool ServerTryInteract(const FInteractionQuery& Query, TScriptInterface Target); + + // Registry, so the highlight sweep and later systems can ask "what is near" without a world search. + void Register(TScriptInterface Prop); void Unregister(TScriptInterface Prop); + void QueryNear(const FVector& Point, float Radius, TArray>& Out) const; +}; +``` + +Props register in `BeginPlay` on every peer and unregister in `EndPlay`. The RPC carries the prop as an object +reference: map-placed actors and replicated actors both resolve on the server through their network identity, which +is why there is no hand-rolled handle table. A prop that is neither placed nor replicated cannot be interacted with +over the network, by construction, which is the correct failure. + +## The player's component + +```cpp +/** + * The targeting, the prompt, the press. Owner only. Finds what you are looking at, offers it, and on the press + * either routes to the carry component (a carryable with free hands) or sends the request to the server. + */ +UCLASS() +class UInteractionComponent : public UActorComponent +{ + GENERATED_BODY() +public: + TScriptInterface GetCurrentTarget() const; + FInteractionPrompt GetCurrentPrompt() const; // the HUD reads this; it never computes its own + void OnInteractPressed(); void OnInteractReleased(); // released matters for hold-to-confirm + +protected: + // TickComponent, owner only: + // 1. Sweep a 15 cm sphere from the camera along its forward for ReachCm on the Interactable channel. + // What you LOOK at wins; the earlier overlap-and-angle-score model is used only for the highlight set. + // 2. If the hit resolves to an IInteractable (on the hit component first, then its owner) and it is in + // reach per the subsystem, it is the target; GetPrompt(query) is the prompt. Otherwise no target, no prompt. + // 3. Every 0.25 s, QueryNear(HighlightCm) drives the in-range outline set; the target gets the strong outline. + // Press: + // target is ICarryable and hands are free -> Carry->RequestPickUp(target) (not an interaction) + // carrying and target accepts an insert -> ServerRequestInteract(target) (Insert is an interaction) + // target.IsLocalOnly() -> target->Interact(query) locally + // prompt.HoldSeconds > 0 -> start the hold; send on completion; release cancels silently + // otherwise -> ServerRequestInteract(target) + + UFUNCTION(Server, Reliable) void ServerRequestInteract(UObject* Target); + // builds the query from the owning controller and calls the subsystem's ServerTryInteract. + // The client sends the target and nothing else; the server reads the body's position itself. +}; +``` + +Two players interacting with one prop in the same frame resolve host-side by arrival order: the second fails the +re-run `GetPrompt` if the first changed the prop's state. Contested props that need a soft lock ("someone is using +this", with a name) get it in their own `GetPrompt`; the system does not know what contention means. + +## Carrying + +``` +[SALVAGED] Two hands, and things you carry are real. Most objects take one hand; big ones take both and, in +first person, sit in the middle of your view. Carrying something two-handed blocks Interact, Insert and attacking +until you put it down. Drop is always available and instant. Nothing you carry is ever destroyed by dropping, +throwing or bumping into a wall with it. +``` + +```cpp +UINTERFACE() class UCarryable : public UInterface { GENERATED_BODY() }; +class ICarryable +{ + GENERATED_BODY() +public: + virtual UCarryableComponent* GetCarryable() = 0; +}; + +/** On any actor that can be held. The actor is the object; this is its carry state. */ +UCLASS(meta = (BlueprintSpawnableComponent)) +class UCarryableComponent : public UActorComponent +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false; + UPROPERTY(EditDefaultsOnly) float CarryWeight = 1.f; // against the carrier's CarryCapacity, see Stats.md + UPROPERTY(EditDefaultsOnly) bool bBlocksAttack = false; // one-handed loot does not; a crate does + UPROPERTY(EditDefaultsOnly) FName AttachSocket = TEXT("carry_r"); // or carry_both + + UPROPERTY(ReplicatedUsing = OnRep_CarriedBy) TObjectPtr CarriedBy; // null on the ground + bool IsCarried() const { return CarriedBy != nullptr; } + + // Server: attach to the carrier's mesh socket, physics off, collision to Ignore, dormancy off. + // Release: detach, physics on, collision back, an impulse if thrown, dormancy back on when at rest. + // OnRep_CarriedBy does the same attach/detach on clients so the carried object rides the body without + // per-frame movement replication. +}; + +/** On the player. Two hands, the verbs, and the encumbrance effect that is the only way weight slows a body. */ +UCLASS() +class UCarryComponent : public UActorComponent +{ + GENERATED_BODY() +public: + UPROPERTY(Replicated) TArray> Hands; // 0, 1 or 2 entries; two-handed fills both + bool HasFreeHandsFor(const UCarryableComponent& Object) const; + UCarryableComponent* GetHeld() const; // the one-handed object, or the two-handed one + + void RequestPickUp(UCarryableComponent* Object); // owner -> ServerPickUp + void RequestDrop(); // G tap + void RequestThrow(float ChargeSeconds); // G held then released; the interaction component tracks the hold + + UFUNCTION(Server, Reliable) void ServerPickUp(UCarryableComponent* Object); + // Validate: in reach (subsystem, with tolerance), not carried by anyone, not State.Downed, hands free. + // Object->AttachTo(this). ApplyStatus(GE_Encumbered, CarryMath::SpeedFactor(weight, CarryCapacity), infinite): + // weight is an effect on MoveSpeed like mud or a slow (Stats.md). Grant State.Carrying(.TwoHanded). + // Emit object_picked_up. + UFUNCTION(Server, Reliable) void ServerDrop(); + // Always legal while carrying. Release at the hands with no velocity. Remove GE_Encumbered and the tags. Emit object_dropped. + UFUNCTION(Server, Reliable) void ServerThrow(FVector_NetQuantizeNormal Aim, float ChargeSeconds); + // Two-handed objects cannot be thrown (reason: TooHeavy). power = clamp(charge / MaxCharge) * ThrowForce(mass). + // Release with impulse along Aim. It lands where it lands. Fragile things take a Mark (Crafting.md), never damage. + // Emit object_thrown. + UFUNCTION(Server, Reliable) void ServerHandOver(UCarryComponent* Other); + // Both in reach, other has free hands. The co-op verb. Emit object_handed_over. + + // A down drops what you hold (Combat.md calls RequestDrop on the server when State.Downed is granted). +}; +``` + +The attach-not-simulate approach is the whole reason carrying feels instant: while held, the object is a child of +the carrier's mesh and rides its predicted movement; there is no physics to predict and nothing to reconcile. The +moment it leaves the hands it is a server-simulated physics body again, replicated with the engine's predictive +interpolation mode, and a thrown object arriving a tick late on a remote screen reads as weight, not lag. + +**Throwing is in, and it is allowed to be scrappy.** It is the cheapest physical comedy available and it serves +coordination: tossing an ingot across a room beats walking it, at the cost of accuracy. Rules that keep it fun: +objects never break from a throw; a thrown object that comes to rest is an ordinary floor object; thrown objects +collide with the world but never knock something out of another player's hands; catching is not a mechanic, aim at +the feet. + +**Blocking is deliberate.** A two-handed object commits your hands and, in first person, your eyes. The deadzone +widening while carrying ([Movement.md](Movement.md)) is the safety valve: you can peek round your load without +turning your body. If playtests read the block as friction rather than weight, the valve is per-object +(`bTwoHanded` is data) and never a global rule change. + +## Highlighting + +Custom depth with stencil. `UInteractionHighlightComponent` sets `bRenderCustomDepth` and a stencil value on the +prop's primitives: 1 for in-range, 2 for targeted, 0 otherwise. One post-process material in the player camera's +volume draws the outline from the stencil, reading its two colours from the theme ([UI.md](UI.md)). The prompt says +*what*; the outline says *which*, so two ingots lying on each other show which one the press takes. No prop draws +its own outline and no prop chooses a colour. + +## Networking + +| State | Authority | Mechanism | +| --- | --- | --- | +| Targeting and the prompt | Client, owner | Local every frame; cosmetic; never replicated | +| The request | Client asks, server decides | `ServerRequestInteract` on the player's own component | +| Reach and permission | Server | Re-checked with tolerance; the client's claim is never trusted | +| The prop's effect | Server | Whatever the owning feature does; replicated by that feature | +| Local-only props | Client | Never sent | +| Held objects | Server | `CarriedBy` replicated; attachment mirrored in `OnRep` | +| Thrown and dropped objects | Server | Physics simulated on the server, replicated movement | + +## Telemetry + +| Event | When | Payload | +| --- | --- | --- | +| `prop_interacted` | A successful interaction | `prop`, `verb`, `held_seconds`, `player_class` | +| `interaction_refused` | The server refused a request | `prop`, `reason` | +| `object_picked_up` | Carry begins | `object`, `two_handed`, `weight` | +| `object_dropped` | Drop or forced drop | `object`, `forced` | +| `object_thrown` | Throw | `object`, `charge`, `distance_landed` | +| `object_handed_over` | Hand over | `object` | + +`prop_interacted` earns its place because a world-as-interface game has a failure mode a menu game does not: a +player who cannot find the bench has nothing to fall back on. If a prop exists and nobody in a session ever +touches it, it is not discoverable, and this event is the only way to learn that before someone says so. +`interaction_refused` by reason is the cheapest possible measure of whether prompts explain themselves. + +## Tests + +- Automation: `IsInReach` with and without tolerance; the prompt of a test prop is disabled with `HandsFull` when + the query carries `State.Carrying.TwoHanded`. +- Functional: two clients and a server; client A picks up a crate, client B sees it on A's body; A throws it, both + see it land within tolerance of the server's resting place; a request from beyond reach plus tolerance is + refused and `interaction_refused` carries `OutOfReach`. + +## Open questions + +- **Q1. Contested props.** Arrival order is the rule. Whether a bench or a station wants a visible soft lock with a + name is each prop's call in its own `GetPrompt`; the first prop that needs one sets the pattern. +- **Q2. A greyed prompt or no prompt for an unusable prop.** Greyed, with the reason: more discoverable and more + honest. Revisit if prompts clutter a crowded room. +- **Q3. Hold-to-interact as an accessibility option.** The hold mechanism exists for irreversible verbs; making it + optional for every verb is a settings toggle over the same code. With the settings pass, not before. +- **Q4. Which objects are two-handed.** Data per object. The first crate, the first log and the first assembled + greatsword decide the initial list; the earlier projects chose crates, logs, ore chunks and packed stations. +- **Q5. A satchel.** The earlier smithing design added purchasable pocket slots later in progression. Not now; + parked in [`../Ideas.md`](../Ideas.md). Hands only until something needs more. diff --git a/Docs/Spec/Movement.md b/Docs/Spec/Movement.md new file mode 100644 index 0000000..92f2a0c --- /dev/null +++ b/Docs/Spec/Movement.md @@ -0,0 +1,445 @@ +# Movement + +Owns the character, the movement component, input, the look model, the two camera modes, the gym level and the +hooks other systems use to change how a body moves. It does **not** own abilities that move a body (dodge, blink, +charge: [Combat.md](Combat.md), built on the root-motion hooks here) or picking things up +([Interaction.md](Interaction.md), which supplies the carry-weight penalty this document consumes). + +Read [Architecture.md](Architecture.md) first. This is the first system built and the one everything else stands +on, which is why it gets three steps of its own in [`../Steps.md`](../Steps.md) before a single enemy exists. + +## Why the controller comes first + +A crafting game with bad movement is a menu with a walk between screens. A combat game with bad movement is unfair +before the first swing. Both earlier projects put movement in a corner of a combat step and tuned it by feel once, +against one enemy; neither ever wrote down what "good" meant. This time the controller is the first thing built, +it is built against a level made of nothing but movement problems, and it is not called done until a written +checklist passes with a person at the keyboard. + +## Decisions + +``` +[DECIDED] UCharacterMovementComponent, extended. Not a custom controller, not the Mover plugin. + + The engine's character movement is server-authoritative with client prediction and server correction built in, + which is exactly the posture Architecture.md demands and the thing the earlier Unity project never had (its + movement was client-authoritative, which does not survive contact with a real server). It integrates with root + motion sources, which is how abilities move a body without a second movement system. It is mature, documented and + what the engine's own animation tooling assumes. + + The Mover plugin is the engine's future answer and is still marked experimental. Q2 keeps it in view; the + extension points used here (a saved-move flag, a tuning asset, root motion sources) are the ones Mover also + exposes, so a later move is a port, not a rewrite. +``` + +``` +[DECIDED] One rig, two camera modes. The camera is presentation and never enters the authority path. + + First person is the engine's mannequin seen from a socket on its own head; third person is the same mannequin + seen over its shoulder. There is no first-person arms rig, no second animation set. Every activity, prop and + fight must be completable in both modes, and nothing about the camera is ever replicated, saved or used by the + server to decide anything. Which mode is the default is Q1; both exist from step 3 so the answer can be played + rather than argued. +``` + +``` +[DECIDED] Units are the engine's: centimetres, Z up, 1 uu = 1 cm. Every figure in this doc is in those. +``` + +## Layout + +``` +Source/Core/Movement/ +├── LookModel.h / .cpp // pure: the deadzone-then-body-follows look model +└── MovementTuning.h // the tuning asset class and its validation + +Source//Movement/ +├── BaseCharacter.h // ACharacter subclass shared by players and enemies: mesh, team, ASC access +├── PlayerCharacter.h // adds camera, input, interaction and carry components +├── ExtendedCharacterMovement.h // the CMC subclass: sprint flag, coyote time, jump buffer, speed multipliers +├── CameraModeDefinition.h // UPrimaryDataAsset per mode +└── CameraModeComponent.h // owns the camera and spring arm, blends between modes, applies the look model + +Content/Movement/ +├── Definitions/DA_Tuning_Player, DA_CameraMode_FirstPerson, DA_CameraMode_ThirdPerson +├── Input/IMC_Gameplay, IA_* // one mapping context for play; a second (IMC_Menu) arrives with the menu +├── BP_PlayerCharacter // sets the mesh, animation blueprint, tuning and camera assets. No logic. +├── Surfaces/PM_Mud, PM_Ice // UPhysicalMaterialWithTags for the gym's surfaces (Stats.md), step 5 +└── Maps/L_Gym // the movement test level +``` + +## Types at a glance + +| Type | Module | Lifetime | Notes | +| --- | --- | --- | --- | +| `FLookModelParams`, `FLookModelState`, `LookModel::Tick` | Core | value | pure, tested without a world | +| `UMovementTuning` | Core | asset | numbers, with `IsDataValid` checks | +| `ABaseCharacter` | Gameplay | per body | `IAbilitySystemInterface`, `IGenericTeamAgentInterface` | +| `APlayerCharacter` | Gameplay | per body | camera, input binding, interaction, carry | +| `UExtendedCharacterMovement` | Gameplay | per body | the CMC subclass, predicted | +| `UCameraModeDefinition` | Gameplay | asset | one per mode | +| `UCameraModeComponent` | Gameplay | per player body | local only, never replicated | + +## Input + +Enhanced Input. One mapping context, `IMC_Gameplay`, added at priority 0 in `APlayerCharacter::SetupPlayerInputComponent` +through the local player's `UEnhancedInputLocalPlayerSubsystem`. Actions are assets under `Content/Movement/Input/` +and are bound by the action asset, never by key, so a rebind is a settings change and not a code change. Rebinding +itself uses the engine's `UEnhancedInputUserSettings` (player-mappable key settings on each action), which persists +to the player's save folder; no custom rebinding code is written. + +The default layout. Keyboard bindings are the ones the earlier project settled on, with the one conflict resolved +in Q4's favour: sprint takes Shift, the fourth ability slot moves. + +| Action | Keyboard and mouse | Gamepad | Value | Notes | +| --- | --- | --- | --- | --- | +| `IA_Move` | W A S D | Left stick | Axis2D | Relative to the body in first person, to the camera in third | +| `IA_Look` | Mouse | Right stick | Axis2D | Mouse deltas are **not** scaled by delta time; stick rates are | +| `IA_Jump` | Space | A | Bool | Buffered and coyote-timed, see below | +| `IA_Sprint` | Left Shift (hold) | Left stick click | Bool | A predicted movement flag, not an ability | +| `IA_Crouch` | Left Ctrl (toggle) | B (hold) | Bool | Engine crouch, capsule shrinks | +| `IA_Dodge` | Left Alt | B (tap) | Bool | Activates the dodge ability once it exists, step 5 | +| `IA_Attack` | Left mouse | Right trigger | Bool | Owned by Combat; bound here so the map is in one place | +| `IA_Interact` | F | X | Bool | Owned by Interaction | +| `IA_Drop` | G (tap drops, hold throws) | Y | Bool | Owned by Interaction | +| `IA_Ability1..4` | Q, E, R, C | LB, RB, Y, LT | Bool | Owned by Combat, routed through input tags | +| `IA_CameraToggle` | V | D-pad down | Bool | Swaps camera mode | +| `IA_Menu` | Escape | Start | Bool | Owned by UI | + +Every action carries an `Input.*` gameplay tag in its player-mappable key settings so the ability system, the prompt +and the action bar can ask "which key is `Input.Interact` on the device this player touched last" and get the +live binding back. No view ever prints a literal key. + +## The character and the movement component + +```cpp +// Source/Core/Movement/MovementTuning.h +UCLASS(BlueprintType) +class UMovementTuning : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + // Speeds, cm/s. All guesses until the gym says otherwise. Walk is the old project's 4.5 m/s. + UPROPERTY(EditDefaultsOnly, Category = "Speed") float WalkSpeed = 450.f; + UPROPERTY(EditDefaultsOnly, Category = "Speed") float SprintSpeed = 650.f; + UPROPERTY(EditDefaultsOnly, Category = "Speed") float CrouchSpeed = 250.f; + UPROPERTY(EditDefaultsOnly, Category = "Speed") float MaxAcceleration = 2048.f; + UPROPERTY(EditDefaultsOnly, Category = "Speed") float BrakingDeceleration = 2048.f; + UPROPERTY(EditDefaultsOnly, Category = "Speed") float AirControl = 0.35f; + + // Jump. Apex ≈ JumpZ² / (2 · 980 · GravityScale): 560 at 1.5 gravity is about 106 cm, the old 1.1 m jump. + UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpZVelocity = 560.f; + UPROPERTY(EditDefaultsOnly, Category = "Jump") float GravityScale = 1.5f; + UPROPERTY(EditDefaultsOnly, Category = "Jump") float CoyoteTime = 0.10f; // seconds after leaving a ledge a jump still counts + UPROPERTY(EditDefaultsOnly, Category = "Jump") float JumpBufferTime = 0.12f; // seconds before landing a press is remembered + + // Ground. The engine defaults are right for stairs up to 45 cm and slopes to 45 degrees; listed so they are tuned here. + UPROPERTY(EditDefaultsOnly, Category = "Ground") float MaxStepHeight = 45.f; + UPROPERTY(EditDefaultsOnly, Category = "Ground") float WalkableFloorAngle = 45.f; + + // Landing. A drop taller than this costs a brief recovery; taller than the second, fall damage (Combat.md). + UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingHeight = 300.f; + UPROPERTY(EditDefaultsOnly, Category = "Landing") float HardLandingRecovery = 0.2f; + +#if WITH_EDITOR + virtual EDataValidationResult IsDataValid(FDataValidationContext& Context) const override; + // SprintSpeed > WalkSpeed > CrouchSpeed > 0; CoyoteTime and JumpBufferTime under 0.3 s; angles in (0, 90). +#endif +}; +``` + +```cpp +// Source//Movement/ExtendedCharacterMovement.h +/** + * The engine's character movement plus the four things every feel pass ends up adding: a sprint flag that + * predicts correctly, coyote time, a jump buffer, and the ground surface trace that turns mud into an effect. + * + * Sprint is a compressed flag in the saved move, which is the engine's mechanism for predicted input state. + * It is deliberately NOT a gameplay ability: an ability round-trips through the ability system for something the + * movement component already replicates for free. Dodge and blink ARE abilities, because they apply root motion. + */ +UCLASS() +class UExtendedCharacterMovement : public UCharacterMovementComponent +{ + GENERATED_BODY() +public: + void ApplyTuning(const UMovementTuning& Tuning); // called by the character on BeginPlay and on tuning change + + // Input state, set by the owning character, carried in the saved move + void SetWantsToSprint(bool bWants); + void PressJumpBuffered(); // remembers a press for JumpBufferTime + + // Speed is the state's tuning value times the body's MoveSpeed attribute (Stats.md), and nothing else. Carrying, + // being downed, mud, a haste and gear all change that one attribute through effects; this component never + // holds a multiplier of its own. Before the stat block exists (steps 3 and 4) the attribute reads as one. + float GetMoveSpeedAttribute() const; // 1.0 when the owner has no ability system component yet + void UpdateGroundSurface(); // server, ~5 Hz: the floor's UPhysicalMaterialWithTags -> its surface effect + + // UCharacterMovementComponent + virtual float GetMaxSpeed() const override; // walk/sprint/crouch by state, times MoveSpeed + virtual bool CanAttemptJump() const override; // grounded, OR within CoyoteTime of leaving the ground + virtual void UpdateFromCompressedFlags(uint8 Flags) override; + virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override; + virtual void OnMovementModeChanged(EMovementMode PrevMode, uint8 PrevCustomMode) override; // starts the coyote clock + +protected: + bool bWantsToSprint = false; + float TimeLeftGround = -1.f; + float JumpBufferedAt = -1.f; + TWeakObjectPtr CurrentSurface; + FActiveGameplayEffectHandle SurfaceEffect; + + // FSavedMove_Character subclass carrying bWantsToSprint in FLAG_Custom_0; FNetworkPredictionData_Client_Character + // subclass allocating it. Standard engine pattern; see the engine's own ACharacter crouch flag for the shape. +}; +``` + +```cpp +// Source//Movement/PlayerCharacter.h +/** + * The player's body. Owner-only concerns (camera, input, interaction, carrying) live here as components; + * everything shared with enemies is on ABaseCharacter. The character decides nothing: it reads input, hands it to + * the movement component and the ability system, and lets the camera component look. + */ +UCLASS() +class APlayerCharacter : public ABaseCharacter +{ + GENERATED_BODY() +public: + APlayerCharacter(const FObjectInitializer& OI); + +protected: + UPROPERTY(EditDefaultsOnly, Category = "Movement") TObjectPtr Tuning; + UPROPERTY(EditDefaultsOnly, Category = "Input") TObjectPtr GameplayContext; + UPROPERTY(VisibleAnywhere) TObjectPtr CameraMode; + UPROPERTY(VisibleAnywhere) TObjectPtr Interaction; // Interaction.md + UPROPERTY(VisibleAnywhere) TObjectPtr Carry; // Interaction.md + + // Replicated so remote players can see where this one is looking. RemoteViewPitch is the engine's; yaw is ours. + UPROPERTY(Replicated) uint8 RemoteHeadYaw; // HeadYaw compressed to a byte, written by the owner every tick + + virtual void SetupPlayerInputComponent(UInputComponent* Input) override; // binds IA_* to the handlers below + void OnMove(const FInputActionValue& V); // AddMovementInput relative to body (FP) or camera (TP) + void OnLook(const FInputActionValue& V); // feeds CameraMode->AddLookInput + void OnJumpPressed(); void OnJumpReleased(); // Jump() plus the buffer + void OnSprint(const FInputActionValue& V); // Movement->SetWantsToSprint + void OnCameraToggle(); // CameraMode->CycleMode() + // Attack, Interact, Drop and Ability1..4 forward to Combat and Interaction; they decide nothing here. + + virtual void Landed(const FHitResult& Hit) override; // hard-landing recovery, fall damage event, telemetry +}; +``` + +`ABaseCharacter` sets `UExtendedCharacterMovement` as the movement class (so an enemy is slowed by the same mud a +player is), sets the capsule to the mannequin's 42 cm radius and 96 cm half-height, implements +`IAbilitySystemInterface` (returning the player state's component for players, its own for enemies) and +`IGenericTeamAgentInterface`, and carries the `USkeletalMeshComponent` and animation blueprint reference. It has no +input. + +## The look model + +``` +[SALVAGED] The head turns freely inside a yaw deadzone; past it, the body eases around to follow. Inside the +deadzone the body does not move at all, and that "nothing" is the whole feel: you can glance at a teammate or the +thing on the bench beside you without stepping out of position. The deadzone widens while carrying something big, +so you can peek round your own load. +``` + +```cpp +// Source/Core/Movement/LookModel.h // pure, no UObject, tested +struct FLookModelParams +{ + float YawDeadzone = 45.f; // degrees either side before the body turns + float YawDeadzoneCarry = 70.f; // while carrying a two-handed object + float MaxTurnRate = 540.f; // degrees per second the body may turn to catch up + float MovingDeadzoneScale = 0.35f; // smaller while walking: you face where you go + float ReCenterRate = 90.f; // degrees per second the head drifts forward while moving + float PitchMin = -80.f, PitchMax = 70.f; + float SpinePitchShare = 0.5f; // how much of the pitch bends the spine, for the visible body +}; + +struct FLookModelState { float HeadYaw = 0.f; float BodyYaw = 0.f; float Pitch = 0.f; }; + +struct FLookModelInput { FVector2D LookDelta; bool bMoving; bool bStrafing; bool bCarryingTwoHanded; float DeltaSeconds; }; + +namespace LookModel +{ + // pure. Returns the new state; the caller writes BodyYaw to the actor and BodyYaw + HeadYaw to the camera. + FLookModelState Tick(const FLookModelState& State, const FLookModelParams& Params, const FLookModelInput& In); + // HeadYaw += LookDelta.X + // deadzone = (carrying ? YawDeadzoneCarry : YawDeadzone) * (moving ? MovingDeadzoneScale : 1) + // if |HeadYaw| > deadzone: turn = min(|HeadYaw| - deadzone, MaxTurnRate * dt) * sign; BodyYaw += turn; HeadYaw -= turn + // if moving && !strafing: HeadYaw = MoveTowards(HeadYaw, 0, ReCenterRate * dt) + // Pitch = clamp(Pitch + LookDelta.Y, PitchMin, PitchMax) +} +``` + +How it meets the engine: `bUseControllerRotationYaw` is off. In **first person** the character's actor yaw is +`BodyYaw`, the camera yaw is `BodyYaw + HeadYaw`, and movement input is relative to the body, so you walk where you +face and look elsewhere. In **third person** the camera is free (spring arm on control rotation), +`bOrientRotationToMovement` is on so the body faces where it walks, and the model drives only the head and spine aim +in the animation blueprint so teammates can see where you are looking. One implementation, both modes; `Pitch` +reaches the rig through the engine's replicated `RemoteViewPitch`, `HeadYaw` through `RemoteHeadYaw`. + +## Camera modes + +```cpp +UCLASS(BlueprintType) +class UCameraModeDefinition : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + UPROPERTY(EditDefaultsOnly) FGameplayTag ModeTag; // Camera.Mode.FirstPerson / .ThirdPerson + UPROPERTY(EditDefaultsOnly) float FieldOfView = 90.f; + UPROPERTY(EditDefaultsOnly) bool bFirstPerson = false; + // third person + UPROPERTY(EditDefaultsOnly) float ArmLength = 300.f; + UPROPERTY(EditDefaultsOnly) FVector SocketOffset = FVector(0, 60, 40); // over the right shoulder + // first person + UPROPERTY(EditDefaultsOnly) FName HeadSocket = TEXT("head"); + UPROPERTY(EditDefaultsOnly) FVector EyeOffset = FVector(10, 0, 0); + UPROPERTY(EditDefaultsOnly) float HeadStabilization = 20.f; // damping stiffness; 0 = rigidly on the bone (do not ship that) + UPROPERTY(EditDefaultsOnly) bool bHideLocalHead = true; + UPROPERTY(EditDefaultsOnly) FLookModelParams Look; + UPROPERTY(EditDefaultsOnly) float BlendSeconds = 0.25f; +}; + +/** Owns the camera and spring arm, blends between definitions, runs the look model. Local player only. */ +UCLASS() +class UCameraModeComponent : public UActorComponent +{ + GENERATED_BODY() +public: + void SetMode(FGameplayTag ModeTag); void CycleMode(); + void AddLookInput(FVector2D Delta); + FRotator GetCameraRotation() const; // BodyYaw + HeadYaw, Pitch + // TickComponent: run LookModel::Tick, write BodyYaw to the owner, position the camera: + // FP: camera location = damped follow of Mesh->GetSocketLocation(HeadSocket) + EyeOffset, in LateUpdate order + // (tick group PostUpdateWork) so it runs after animation; rotation from the look model, NEVER from the bone. + // Mesh->HideBoneByName(head) on the locally controlled pawn only; other clients see the whole body. + // TP: spring arm with collision test on, length and socket offset from the definition, camera on control rotation. +protected: + UPROPERTY(EditDefaultsOnly) TArray> Modes; + UPROPERTY(VisibleAnywhere) TObjectPtr SpringArm; + UPROPERTY(VisibleAnywhere) TObjectPtr Camera; + FLookModelState Look; +}; +``` + +The stabilised head socket is the make-or-break piece. Rigid parenting to the bone turns every walk-cycle bob into +camera shake, which the earlier project found out the hard way and fixed twice. The camera follows the socket's +position through a damped spring and takes its rotation from the look model only. An optional procedural bob (off +by default, scaled by the motion accessibility setting) is the only bob there is. + +Comfort settings are launch requirements for a first-person mode, not options: stabilisation strength, FOV per +mode, bob toggle, and the motion scale that also governs camera kicks in combat. + +## Networking + +| State | Authority | Mechanism | +| --- | --- | --- | +| Position, velocity, movement mode | Server, client predicts | Character movement's own prediction and correction | +| Sprint, crouch | Server, client predicts | Compressed flags in the saved move | +| Jump | Server, client predicts | Engine jump plus the buffered press, resolved in the predicted move | +| Look pitch | Owner writes | `RemoteViewPitch`, engine built-in, for the rig only | +| Head yaw | Owner writes | `RemoteHeadYaw` byte, for the rig only | +| Camera mode, FOV, stabilisation | Local only | Never replicated | +| `MoveSpeed`, `JumpPower` | Server | Attributes on the stat block, replicated; a server-applied change costs one small correction on the owner, see [Stats.md](Stats.md) | + +Remote bodies interpolate through the movement component's network smoothing (exponential). Test every step of this +document with the editor's network emulation profile set to 100 ms and 5 % loss, and `p.NetShowCorrections 1` on; +a correction you can see at those settings is a bug in the saved move. + +## The gym + +`L_Gym` is a level made only of movement problems, greyboxed from engine primitives with a material per problem +kind. It is the level every movement step is proved in, and it stays in the project forever as the regression +test for the controller. + +| Section | What it holds | +| --- | --- | +| Stairs | Risers of 15, 20, 30 and 45 cm, straight and spiral | +| Slopes | 15, 30, 45 and 60 degrees, up and down; the last must not be walkable | +| Gaps | 150, 200, 250 and 300 cm, flat; 250 makeable at sprint only | +| Ledges | Drops of 100, 200, 300 and 500 cm onto flat ground | +| Doorways | 110 by 220 cm, and a 90 by 200 cm one that a crouch fits | +| Beams | 30 cm wide walkways over a drop | +| Corridor | A 40 m straight for speed and stop-distance measurement, marked every 5 m | +| Surfaces | Patches of mud and ice on the corridor's second half, on tagged physical materials; inert until step 5 | +| Arena | An open 30 by 30 m circle for the combat steps later | + +## The feel checklist + +Step 4 closes when a person at the keyboard ticks every line, in both camera modes, and the numbers that made it +pass are committed in `DA_Tuning_Player`. + +- Stairs of every riser at walk and sprint: no camera stutter, no snag, no bounce at the top. +- Slopes: walkable to 45 degrees, slides off 60; speed on a 30 degree climb reads as effort, not a wall. +- Gaps: 150 and 200 at walk, 250 at sprint, 300 never. The 250 is the one that teaches sprint. +- Coyote time: stepping off a ledge and pressing jump within a tenth of a second still jumps. Buffer: pressing jump + just before landing jumps on landing. +- Landing from 100 and 200 cm: nothing. From 300: a visible knee-bend and a fifth of a second of no input. From 500: + fall damage (once Combat exists) and the same recovery. +- Stop distance from sprint under 150 cm; from walk under 60 cm. Turning at sprint has a radius, not a pivot. +- Air control is enough to correct a jump onto the beam, not enough to reverse mid-air. +- Doorways: the wide one at sprint without touching, the narrow one only crouched. +- First person: a five-minute walk of the whole gym without discomfort with stabilisation at its default. Looking + down shows your own feet. The deadzone lets you glance at a wall sign without turning. +- Third person: the spring arm never clips through a wall; the body faces where it walks; the head turns to follow + the look. +- Two clients and a dedicated server in editor, emulation at 100 ms and 5 %: the remote body is smooth on stairs, + slopes and jumps, and no correction snap is visible on the local one. + +## Hooks for other systems + +- **`MoveSpeed` and `JumpPower`** on the stat block ([Stats.md](Stats.md)) are the only way anything slows or + speeds a body: carrying, being downed, mud, gear and every buff or debuff are effects on those two attributes, + and the movement component multiplies. It never holds a multiplier of its own. +- **Root motion sources** are how abilities move a body. Dodge is a `FRootMotionSource_ConstantForce` over its + duration with i-frames granted as a `State.Invulnerable` tag; blink is a `MoveToForce` along the aim, flattened; + the shoulder charge is a constant force with a hit window. All three predict through the movement component's + existing root-motion prediction and are specified in [Combat.md](Combat.md). +- **Downed** sets the crouch capsule, overrides `MoveSpeed` to zero through `GE_Downed` and disables jump; the + camera drops to the downed eye height through the camera component, which reads the `State.Downed` tag. +- **Impulses** (a shove, a heal-launch) call `LaunchCharacter`, which the movement component already replicates. + +## Telemetry + +| Event | When | Payload | +| --- | --- | --- | +| `movement_sample` | Every 5 s while moving | `speed`, `mode`, `camera_mode`, `sprinting` | +| `jump` | A jump begins | `coyote` (bool), `buffered` (bool) | +| `land` | `Landed` | `fall_height`, `hard` (bool) | +| `camera_mode_changed` | Mode swap | `from`, `to` | +| `settings_changed` | A comfort or binding setting is committed | `setting_id`, `value` | + +`movement_sample` is what later says which camera mode people actually live in and how much of the gym's speed +range is used, which is what decides Q1 and the sprint tuning with data instead of taste. + +## Tests + +- `LookModel` automation: inside the deadzone the body yaw does not change; an overshoot turns the body by the + overshoot and never faster than `MaxTurnRate`; moving without strafing recenters the head; carrying widens the + deadzone; pitch clamps. Five tests, no world. +- `UMovementTuning::IsDataValid` refuses sprint slower than walk, negative times, and angles outside (0, 90). +- Functional: `FT_Gym_Stairs` drives an `APlayerCharacter` up each riser with `AddMovementInput` and asserts it + reaches the top; `FT_Gym_Slopes` asserts the 60 degree slope is not climbable; `FT_Gym_Gaps` asserts the 300 cm + gap is not crossable at sprint. + +## Open questions + +- **Q1. Which camera mode is the default?** Both earlier projects chose first person: it is where close manual + work reads best and it is cheapest to make feel good with one rig. The case for third person is seeing your own + crafted gear and a better read of a crowded fight. Build both in step 3, play the gym and the first fight in each, + and let `camera_mode_changed` and `movement_sample` settle it by step 8. +- **Q2. The Mover plugin.** Revisit when it leaves experimental. The port cost is bounded by keeping the custom + surface to the saved-move flag, the tuning asset and root-motion sources. +- **Q3. Does sprint cost stamina?** Not in step 3. If combat wants a stamina attribute, sprint may draw from it + through the ability system's attribute, but the flag itself stays in the movement component. +- **Q4. The Shift conflict.** The earlier layout put the third ability slot on Shift because that game had no sprint. + This one does. Decided for now: Shift sprints, the slots are Q, E, R, C. Revisit if the fourth slot is unreachable + in a fight. +- **Q5. Head bob.** Off by default, one amplitude setting, scaled by the motion setting. Whether it earns a place + in first person is a playtest question after step 4. +- **Q6. Mantling and vaulting.** Not now. The gym has no ledge you are meant to climb. If the world later wants it, + it is a movement ability on root motion, not a change to the component. diff --git a/Docs/Spec/Networking.md b/Docs/Spec/Networking.md new file mode 100644 index 0000000..a7f8cda --- /dev/null +++ b/Docs/Spec/Networking.md @@ -0,0 +1,205 @@ +# Networking + +Owns the authority model, the responsiveness tiers, the posture on prediction and tick rate, the session shape, +and the two seams (identity and persistence) that let the game outgrow one machine without a rewrite. It does +**not** own gameplay: what a shove does is in [Combat.md](Combat.md), what an insert does in +[Crafting.md](Crafting.md). Each feature spec states what is authoritative where; this document is the rule they +all follow and the reasoning behind it. + +Read [Architecture.md](Architecture.md) first. + +## The decision + +``` +[DECIDED] Server-authoritative from the first line, on the engine's own replication, with the dedicated server +as the real target. + + There is no listen-server mode in the design and no "are we multiplayer" branch anywhere in gameplay code. + Standalone and listen-server play exist because the editor offers them and they are convenient; every actor, + subsystem and RPC is written as if the server were a separate process on another machine, and it is tested that + way from step 1 with Play In Editor's dedicated server option. + + The engine's actor model already is this posture: AGameModeBase exists only on the server, properties replicate + one way, Server RPCs need an owning connection, movement is server-corrected. The work is to never undermine it, + not to build it. +``` + +Why this and not the earlier project's listen server with a relay: a host-authoritative peer is the one topology +that cannot grow. It ties the simulation to a player's machine, makes the host able to cheat freely, and turns +every later step toward a persistent shared world into a rewrite. Choosing the server posture now costs nothing +(the engine defaults to it) and closes no door. + +## The authority table + +The rollup. Each feature spec carries its own rows and the two must agree. + +| State | Authority | Mechanism | +| --- | --- | --- | +| Player movement, jump, sprint, crouch | Server, owner predicts | Character movement's prediction and correction | +| Look pitch and head yaw | Owner writes | Replicated bytes, for the rig only; the server never reads them to decide anything | +| Camera | Local only | Never replicated, saved or read by the server | +| Ability activation | Client requests, server decides | GAS prediction keys; the owner's animation starts immediately | +| Damage, health, downed, dead | Server | The one execution; replicated attributes and tags | +| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim; fine in cooperative play | +| Cooldowns | Server, client predicts | Cooldown effects; the HUD shows the prediction and takes the correction | +| Enemy brains and spawning | Server | StateTree ticks on the server; bodies replicate | +| Interaction | Client requests, server re-validates reach and permission | One RPC on the player's own component | +| Held objects | Server | Attached to the carrier, `CarriedBy` replicated; no physics while held | +| Dropped and thrown objects | Server | Server physics, replicated movement, predictive interpolation | +| Station state, buffers, operators | Server | Replicated properties | +| Crafting previews | Client computes | Hypotheticals; a wrong preview is a UI bug, never an exploit | +| Crafting verdicts and commits | Server | The same pure function, on the server, is the only one that counts | +| Activity inputs | Server | RPCs; the anvil strike is timestamped | +| Attributes and statuses on a body | Server; effects from a body's own abilities predicted | The stat block replicates; a server-applied speed change costs one small correction, see [Stats.md](Stats.md) | +| Player identity, class, known discoveries | Server | On the player state; replicated to the owner | +| Session membership | Server | Game mode and game state | + +## Three responsiveness tiers + +Several players in one room, all touching the same physical objects. The question is what each may do instantly +and what has to wait for the server, and one test assigns the tier: **does a rollback have a visible victim?** + +``` +TIER 1: PURELY LOCAL, never networked authority + camera and look, interaction targeting and prompts, every crafting preview, HUD, and your own movement + through the engine's prediction. Remote players interpolate; head and spine aim replicate so gaze survives. + +TIER 2: PREDICT THE ANIMATION, NOT THE OWNERSHIP + pick up, insert, take output, equip, hand over, fire a projectile. + The reach or throw ANIMATION starts instantly and locally. The object changes hands when the server confirms. + Predicting your own arm costs nothing when wrong. Predicting who got the last ingot costs a snatch-back the + whole room can see, and with substances as individual contested objects that case is frequent, not theoretical. + +TIER 3: TIMESTAMPED AND LAG-COMPENSATED + scored activity inputs: the anvil strike, and nothing else today. + The client stamps the input; the server evaluates it against what was true then. Because the zone's radius is + a deterministic function of time, that is arithmetic, not a rewind buffer: radius(clientTime), clamped to a + 250 ms window, never scoring better than the best achievable at the earliest legitimate arrival. +``` + +**Standing rule:** no rollback of world state a player can already see. If a design would need one, move the +decision to the server and cover the round trip with animation (tier 2), or make it a pure function of time +(tier 3). + +## Ask, then show + +Every commit-shaped moment in the game (an item assembled, a substance processed, a weapon equipped, later a +purchase or a payout) already has a beat of feedback: the assembled sword materialising on the bench, the ingot +coming out of the bed. That beat is the latency budget. A server round trip fits inside it with room to spare, so +the client asks, waits for the commit, and then shows the result. No prediction, no reconciliation path, no +possibility of a corrected number. **Never show a number as final before it is committed.** A UI that does not lie +needs no reconciliation. + +## Posture + +This is a game played with friends, not a tick-perfect competition. Nothing here is adversarial, ranked or decided +by milliseconds, so the bar is "feels good to a few people in a room", and anything argued for on competitive +integrity grounds is arguing from a genre this game is not in. + +| Not building | Why not | +| --- | --- | +| Rollback netcode or deterministic lockstep | Solves fighting-game and RTS problems; this is a cooperative sim | +| High tick rates | 20 to 30 Hz is ample when one input in the game is timing-scored | +| Client prediction beyond own movement and animation | Every further step buys responsiveness we do not need and costs rollbacks players can see | +| Lag compensation anywhere but the anvil | Nothing else scores timing | +| An anti-cheat arms race | Server authority removes the motive: no PvP, nothing to win by cheating but spoiling your own game | + +The tie-breaker when a choice is close: prefer the option that fails gracefully and legibly over the one that is +technically optimal. Rejections explain themselves, nothing is destroyed silently, a stalled station says why. + +## Prediction hygiene + +- Test every replicated feature with the editor's network emulation at 100 ms and 5 % loss before calling it + done, and `p.NetShowCorrections 1` on. A correction you can see is a bug in a saved move or an ability's + prediction, not a fact of life. +- Server RPCs go through components on actors the client owns: the player's pawn, player state and controller. + Never through a prop. An RPC on an unowned actor silently drops. +- Every `Server` RPC validates its inputs and its instigator. `WithValidation` is not enough; the body re-checks + reach, permission and state, because the client's claim is a claim. +- Replicated arrays with per-element churn (buffers, hands, a future inventory) use `FFastArraySerializer`, not a + plain `TArray`, so a change sends the element and not the array. +- Actors at rest are dormant (`DORM_DormantAll`, flushed on change). A room of two hundred ingots must cost nothing + while nobody touches them. +- Relevancy is by distance from day one (`NetCullDistanceSquared` on every replicated actor that is not a player). + A world larger than one room cannot afford everything being relevant to everyone, and a rule that exists from the + first actor is free. +- Never do per-client work on the server that scales with the square of the player count. If a system needs + every player to know about every player, that is a design flag. + +## Sessions + +There is one session shape: a server running a map, players joining it. The game mode handles login, spawns a +`APlayerCharacter` per controller at a spawn point and re-spawns on death. Party size is the game state's player +count and is read by anything that scales (the encounter budget) and stamped onto every telemetry envelope. + +Steam, lobbies, invitations and matchmaking are not built in these steps and are not designed here. The earlier +project's Steam lobby and friends-list join are in [`../Ideas.md`](../Ideas.md); when they arrive they sit in front +of the session and never inside gameplay. + +**Two instances on one machine:** Play In Editor with two clients and the dedicated server option is the everyday +test. A packaged server (`Server` target) needs the engine built from source and arrives when the first +packaged build does; nothing in the code changes for it. + +## Identity + +``` +[DECIDED] A player is an identity that outlives a body, a session and a server. Everything about a player that +would matter tomorrow lives on the player state or behind the persistence provider, never on the character actor. +``` + +Concretely: `APlayerState` carries a `FGuid PlayerId` minted by the server on first login (hashed for telemetry, +never a platform id or a name), the class kit, the ability system component with its attributes and cooldowns, +known characteristics, and later progression. The character is a body the world lends the player. A body dying, +a map changing or a player reconnecting never loses any of it. + +## Persistence + +``` +[DECIDED] Persistence is behind one provider interface with a local-file implementation. Gameplay writes records +to the provider and never to disk, so a service-backed provider is a second implementation and not a refactor. +``` + +```cpp +// Source/Core/Persistence/PersistenceProvider.h +class IPersistenceProvider +{ +public: + virtual ~IPersistenceProvider() = default; + virtual bool Load(const FString& Key, FString& OutJson) = 0; // records are JSON snapshots, idempotent, never deltas + virtual bool Save(const FString& Key, const FString& Json) = 0; // atomic: temp file then move + virtual void Remove(const FString& Key) = 0; +}; +// FLocalFilePersistenceProvider writes under FPaths::ProjectSavedDir()/Records/. Registered on the +// persistence game-instance subsystem at startup. Nothing is persisted in steps 1 to 15; the seam exists so the +// first thing that is (a player's class, a crafted sword) does not choose the file system by accident. +``` + +Record rules, from the first record: identity is `FGuid`; content is referenced by primary asset id; every record +carries a `schema_version` with an adjacent migration function and a monotonic `revision`; derived fields are +recomputed on load; save-facing records are separate types from runtime instances. + +## Telemetry over the wire + +Every peer emits its own events. There is no forwarding to the server, because the interesting questions (did this +client's frame rate drop, which player did the shoving, did this preview disagree with the verdict) are per-peer +by nature. `is_server` and `player_id` on the envelope let a run be reassembled later. A `session_id` is minted +by the server and adopted by every client on join, so one session's events are one session and not four. + +## Tests + +- Functional, run with emulation on: two clients see the same held object on a third's body; a request from beyond + reach is refused; an ability activated at 100 ms shows no visible correction; a thrown object lands within + tolerance on every peer; the anvil's strike scores the same for a client at 0 ms and one at 150 ms who pressed + at the same moment. + +## Open questions + +- **Q1. Reconnect.** A dropped player should reconnect into the same session with the world exactly as it + continued without them. The identity decision makes it possible; the session shape does not yet do it. Write the + rule down before it is discovered in a playtest. +- **Q2. Voice.** Proximity voice is a large part of what makes a room of friends a room. Not in these steps; + parked in Ideas. +- **Q3. Seamless travel.** One map today. Travelling between maps with the party and the player state intact is + the engine's seamless travel and arrives with the second map. +- **Q4. Replication at scale.** The Replication Graph and the newer Iris system exist for worlds with many actors + and many players. Not now; the dormancy and relevancy rules above are what keep the option cheap. diff --git a/Docs/Spec/README.md b/Docs/Spec/README.md new file mode 100644 index 0000000..b42077d --- /dev/null +++ b/Docs/Spec/README.md @@ -0,0 +1,91 @@ +# Specifications + +These documents are written for an implementer that reads carefully and lifts code, which today means Claude and +whoever reviews its pull requests. They are not the human summary; that is [`../Design.md`](../Design.md). Read the +summary first, then [`Architecture.md`](Architecture.md), then the document for the system you are touching. + +Each spec is pseudocode plus the reasoning behind it. The pseudocode is C++-shaped Unreal code and it is meant to be +implemented, not admired: a class skeleton here is the class we expect to find in `Source/`, with the same name, the +same members and the same comments about why. Where a spec says `// pure`, that is a promise about testability the +real code is expected to keep. + +## Documents + +| Doc | Owns | Read when | +| --- | --- | --- | +| [Architecture.md](Architecture.md) | Modules, lifetimes, authority, data assets, gameplay tags, the C++/Blueprint boundary, testing, conventions | Before writing any code | +| [Stats.md](Stats.md) | The one stat block every body carries, effects and their sources (abilities, areas, surfaces, items, the world), stacking, counters | Step 5, and anything that changes a number on a body | +| [Movement.md](Movement.md) | The character, the movement component, the look model, camera modes, input, the gym level | Steps 3 to 5 | +| [Interaction.md](Interaction.md) | The one interaction system every prop uses, carrying, throwing | Step 6, and any prop | +| [Combat.md](Combat.md) | Attributes, the one damage funnel, melee hit detection, enemies, abilities, kits, downed and revive | Steps 7 to 11 | +| [Crafting.md](Crafting.md) | Families, parts, pieces, traits, substances, the pure rules, stations, activities, the link back to combat | Steps 12 to 15 | +| [Networking.md](Networking.md) | The authority model, the responsiveness tiers, prediction posture, persistence and identity seams | Any replicated feature | +| [Telemetry.md](Telemetry.md) | The sink, the envelope, the event catalogue | Any feature (every feature emits) | +| [UI.md](UI.md) | HUD, prompts, the theme asset, world-space text, localisation | Any screen or prop text | + +## Notation + +``` +Server*(...) runs on the authority only. Validates the instigator and re-checks every precondition, + then mutates. A UFUNCTION(Server, Reliable) RPC or a plain method guarded by HasAuthority(). +Get* / Preview* side-effect-free query. Safe to call from UI every frame, on any peer. +// pure deterministic, no world access, unit-tested, reused by the client preview and the server verdict. +On* (delegate) a multicast delegate a service raises. UI subscribes; UI never polls. +*_Cosmetic a BlueprintAssignable hook a designer may wire. Never load-bearing. +UFooDefinition a UPrimaryDataAsset, authored content. +FFooInstance runtime state with identity (an FGuid). +FFooRecord the persisted snapshot of an instance. Separate type from the instance on purpose. +TAG_Foo_Bar a native gameplay tag, "Foo.Bar", declared once in code. Never a string literal at a call site. +``` + +Status markers inside a spec: + +``` +[DECIDED] settled, with the reason. Logged in ../Decisions.md. +[PROPOSED] the recommended shape, not yet built on. Becomes [DECIDED] when the step that builds it closes. +[SALVAGED] an idea carried over from the two earlier projects, reshaped for this one. See ../Ideas.md. +Qn an open question, collected at the end of each document. +``` + +## Cross-cutting rules + +Every spec obeys these. They are stated once here so no spec has to restate them, and any spec that appears to +break one is wrong. + +1. **The server decides. The client asks, and predicts only its own body.** Every mutation has an explicit + instigator and is re-validated on the authority, even when the game is being played alone. Standalone and + listen-server play are development conveniences; the dedicated server is the real target and nothing may assume + it is absent. See [Networking.md](Networking.md). +2. **One formula, many consumers.** A preview and the real thing call the same pure function. Damage, coherence, + naming, assembly validation, spec matching and interaction prompts all follow this. If a UI-side "fast copy" of a + rule ever appears, it is a bug. +3. **One funnel per kind of consequence.** All damage goes through one execution. All interactions go through one + service. All activity quality goes through one result type. A second path is where rules quietly diverge. +4. **Rules are plain C++ in the core module; actors and components adapt them to the world.** The core module knows + no `AActor`. This is what makes rules testable without a level and runnable on a headless server. +5. **Content is data, addressed by gameplay tag or primary asset id, never by string.** A new sword, enemy or + substance is an asset, not a code change. If adding a variant needs code, the model is wrong. +6. **Every rejection carries a reason a player can read.** A refused interaction, an illegal assembly, a blocked + ability: each returns a reason tag that the prompt or panel shows. Silent refusal is a failed step. +7. **Nothing is destroyed silently.** Dropped things persist on the floor, full containers refuse with a reason, + thrown things land where they land. The rare deliberate deletion is announced before it happens. +8. **Bonuses, never locks.** A class, a level or a piece of gear changes speed, quality or numbers. It never changes + what a station accepts or what an assembly permits, and it never grants another class's signature ability. +9. **Solo viability is a gate, not a mode.** Every rule is checked against one player. Where it costs a solo player + something, the spec says so and names the tuning knob. +10. **Derived state is recomputed on load, never trusted from storage.** A rebalance re-scores old items everywhere. +11. **Telemetry from the first line.** Every feature emits through the injected sink into a catalogue that exists + before there is anyone to measure. Retrofitting emit calls is the tax this avoids. +12. **Determinism where it is promised.** Anything generated from a seed uses its own `FRandomStream`, integer + coordinates and ordered collections, and never reads physics, time or frame counts. Nothing today is generated + from a seed; the rule exists so the first thing that is inherits it. +13. **One stat block; every outside influence is an effect on it.** Every body carries the same attribute set. + A buff, a debuff, ground, weight, gear and base values are all gameplay effects applied to it; counters are + tags and attributes on the receiver. No system keeps its own multiplier. See [Stats.md](Stats.md). + +## How a spec relates to the steps + +[`../Steps.md`](../Steps.md) schedules; these documents specify. A step links to the sections it builds and states +what proves it done. If a step and its spec disagree, the spec wins and the step is corrected. A spec that turns out +wrong in the building is corrected in the same pull request as the code, under a **What was built, and where it +differs** heading at the end of the affected document, so the next reader is not misled by the sketch. diff --git a/Docs/Spec/Stats.md b/Docs/Spec/Stats.md new file mode 100644 index 0000000..1fcb0fd --- /dev/null +++ b/Docs/Spec/Stats.md @@ -0,0 +1,307 @@ +# 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/Core/Stats/ +├── StatMath.h / .cpp // pure: Resisted(magnitude, kind, resist) +└── CarryMath.h / .cpp // pure: SpeedFactor(weight, capacity) + +Source//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//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.` 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 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 Effect; // a Status.* effect + UPROPERTY(EditAnywhere) float Magnitude = 0.6f; // Data.Magnitude + UPROPERTY(EditAnywhere) bool bAffectsPlayers = true, bAffectsEnemies = true; + UPROPERTY(VisibleAnywhere) TObjectPtr 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 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_` with an immunity component (`UImmunityGameplayEffectComponent`) matching `Status.` | 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.` and `Immune.Damage.`; 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. diff --git a/Docs/Spec/Telemetry.md b/Docs/Spec/Telemetry.md new file mode 100644 index 0000000..f02c0bf --- /dev/null +++ b/Docs/Spec/Telemetry.md @@ -0,0 +1,180 @@ +# Telemetry + +Owns the seam gameplay emits events through, the envelope, the format on disk and the catalogue of events. It +does **not** own any backend, storage or analysis; there is none, and the format is chosen so one can be bolted +on later without touching the game. + +Read [Architecture.md](Architecture.md) first. Step 2 in [`../Steps.md`](../Steps.md), before the character +exists, on purpose. + +## Why this exists before there is anyone to measure + +Two reasons, and the second is the real one. + +1. **Retrofitting emit calls is expensive and lossy.** Instrumenting a finished system means re-reading it and + guessing what mattered at the time. Instrumenting as it is written costs a line. +2. **Some questions can only be answered with data that starts early.** Which camera mode people live in, whether + the anvil is fun or a chore, whether a crafted sword's damage curve is sane, whether prompts explain themselves. + Every one of those is a distribution over sessions, and the sessions that never emitted are gone. + +So the schema is fixed now, the sink writes a local file, and the catalogue grows one step at a time the way the +cheats do. + +## Decisions + +``` +[DECIDED] One subsystem, one sink interface, three sinks on day one: null (the shipping default), log (the +editor), JSON Lines to a file (playtests). The emitter never knows where events go. +``` + +``` +[DECIDED] JSON Lines. One event per line, UTF-8, snake_case keys. Append-only, so a crash mid-write loses one line +and not the file; schema-flexible, so a new field breaks no old reader; greppable during development; and the +native food of every log and analytics pipeline that might sit downstream. Events, never aggregates: every metric is +computed from raw facts later, because aggregation choices change and raw events do not. +``` + +``` +[DECIDED] No personal data. Player identity on the wire is the server-minted player id, hashed. No names, no +platform ids, no chat, no free text. Established now because a field is much harder to remove than to never add. +``` + +## Layout + +``` +Source/Core/Telemetry/ +├── TelemetrySink.h // ITelemetrySink, FNullTelemetrySink, FLogTelemetrySink, FJsonlTelemetrySink +├── TelemetryEvent.h // FTelemetryEvent, FTelemetryEnvelope +└── TelemetryEvents.h // the event name constants: TelemetryEvents::PlayerDowned etc. Never a literal at a call site. + +Source//Core/ +└── TelemetrySubsystem.h // UGameInstanceSubsystem: owns the sink, stamps the envelope, exposes Emit +``` + +## Types + +```cpp +// Source/Core/Telemetry/TelemetryEvent.h +struct FTelemetryEvent +{ + FName Name; // from TelemetryEvents, never a literal + TSharedPtr Payload; // event-specific fields; built with a small fluent helper + // The subsystem stamps the envelope; call sites fill Name and Payload only. +}; + +struct FTelemetryEnvelope // stamped by the subsystem onto every event +{ + int32 SchemaVersion = 1; + FGuid EventId; // unique per emission, for de-duplication + FDateTime TimestampUtc; + float GameTime; // seconds since the world began + FGuid SessionId; // minted by the server, adopted by clients on join + FString PlayerId; // hashed server-minted id; empty on the server + bool bIsServer; // authoritative events versus observed ones + int32 PartySize; + FString Build; // FApp::GetBuildVersion() plus the git hash from Scripts/ + FString Map; + bool bCheatsUsed; // sticky true after the first cheat this session: tainted sessions are filterable, not deleted +}; + +// Source/Core/Telemetry/TelemetrySink.h +class ITelemetrySink +{ +public: + virtual ~ITelemetrySink() = default; + virtual void Emit(const FTelemetryEnvelope& Envelope, const FTelemetryEvent& Event) = 0; + virtual void Flush() {} +}; +// FNullTelemetrySink: does nothing. The shipping default until there is somewhere to send anything. +// FLogTelemetrySink: one line of JSON to the output log under LogTelemetry. Verifies a step emitted what it claims. +// FJsonlTelemetrySink: appends to Saved/Telemetry/session__.jsonl through a queue drained by a background +// task every two seconds and on Flush; the game thread only builds a small object per event. Flushed on quit and +// from the unhandled-exception handler so a crash loses at most two seconds of events. +``` + +```cpp +// Source//Core/TelemetrySubsystem.h +UCLASS() +class UTelemetrySubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() +public: + void Emit(FName Name, TSharedPtr Payload = nullptr); // stamps the envelope, hands to the sink + void BeginSession(FGuid SessionId); // server mints; a client adopts the replicated id + void SetSink(TUniquePtr Sink); // Log in the editor, Jsonl with -telemetry, Null otherwise + void MarkCheatUsed(); // called by every bs.* console command +}; +``` + +**There is no static `Telemetry::Log(...)` and there will not be one.** Gameplay code resolves the subsystem from +its game instance once and keeps the pointer. A static helper is a global with gameplay state in it, which +[Architecture.md](Architecture.md) bans, and it makes every emitting class untestable. + +## Where the sink lives on each peer + +Every peer emits its own events to its own file. The server's file is the authoritative record of what happened; +a client's file is the record of what that player saw and did. `session_id` is the same across all of them and +`is_server` tells them apart. Nothing is forwarded, because the interesting questions are per-peer by nature. + +Which sink is installed: the log sink in the editor, the file sink when the executable is launched with +`-telemetry` or the console variable `telemetry.File 1` is set, the null sink otherwise. Playtests run with the file +on and the folder is collected by hand afterwards; that is the whole backend for now. + +## The catalogue + +Each feature spec's Telemetry section lists what that feature emits; this table is the rollup and the two must +agree. A row is added to a spec and here in the same pull request as the emit call. + +| Group | Events | +| --- | --- | +| Session | `app_started`, `session_started`, `session_ended`, `player_joined`, `player_left`, `settings_changed`, `cheat_used` | +| Movement | `movement_sample`, `jump`, `land`, `camera_mode_changed` | +| Interaction | `prop_interacted`, `interaction_refused`, `object_picked_up`, `object_dropped`, `object_thrown`, `object_handed_over` | +| Stats | `status_applied`, `status_blocked`, `status_removed` | +| Combat | `enemy_spawned`, `enemy_killed`, `ability_used`, `player_damaged`, `player_downed`, `player_revived`, `player_died`, `party_wiped`, `grief_action` | +| Crafting | `substance_processed`, `piece_shaped`, `piece_enchanted`, `item_assembled`, `assembly_rejected`, `activity_completed`, `characteristic_discovered`, `weapon_equipped` | +| Technical | `perf_sample`, `error_logged`, `net_correction` (count of visible movement corrections per 30 s, from `p.NetShowCorrections`) | + +### The rows that carry weight + +``` +item_assembled family, piece_ids[], name, quality, synergy, substance_value, time_at_bench_s +weapon_equipped family, crafted, damage, quality +enemy_killed enemy, killer_class, weapon, ability, time_to_kill_s +``` + +Together these are the crafting-to-combat loop as data: does a better-made sword kill faster, and by how much. +That is the one question the whole prototype exists to answer, and it cannot be answered from memory. + +``` +activity_completed activity, operators, quality, auto_play, faults, contribution_by_player +``` + +`quality` against `auto_play` says whether the anvil is fun or a chore: if the hold-to-work floor is where most +sessions live, the minigame is not earning its place. + +``` +interaction_refused prop, reason +assembly_rejected family, reason, part +``` + +The two cheapest possible measures of whether the game explains itself. A reason that dominates is a prompt that +does not. + +## Tests + +- Automation: the JSONL sink writes one valid line per event and never blocks the calling thread longer than + building the object; the envelope carries every field; an event with no payload serialises as an empty object. +- Functional: launching the gym with the log sink installed emits `app_started` and `session_started` in that + order; every event name a feature test triggers is a constant in `TelemetryEvents`. + +## Open questions + +- **Q1. Sampling.** `movement_sample` and `player_damaged` are the high-frequency rows. Every 5 s and every hit + are fine at prototype scale; revisit when a file exceeds a few megabytes per session. +- **Q2. An analytics provider.** The engine has an `IAnalyticsProvider` interface that several backends implement. + When there is a backend, a fourth sink adapts to it; the emitter does not change. +- **Q3. Opt-out.** A playtest build needs a telemetry toggle in settings before strangers play it. With the + settings pass, not before. +- **Q4. Typed payloads.** A JSON object per event is convenient and unsafe. A typed struct per event name is the + later answer if typos in payload keys start costing analysis time. diff --git a/Docs/Spec/UI.md b/Docs/Spec/UI.md new file mode 100644 index 0000000..b3fe487 --- /dev/null +++ b/Docs/Spec/UI.md @@ -0,0 +1,177 @@ +# UI + +Owns what interface exists, what it is built on, and the rules that keep it one interface rather than a dozen: +the HUD, the prompt, the action bar, the one theme asset, world-space text on props, localisation and the menu. +It does **not** own what any readout means; the health number belongs to [Combat.md](Combat.md), the bench panel to +[Crafting.md](Crafting.md). + +Read [Architecture.md](Architecture.md) first. There is deliberately little here. A prototype about moving, +fighting and crafting needs a crosshair, a health number, a prompt, four ability slots and a way to quit. + +## The rule + +``` +[SALVAGED] The player acts on the world physically; the game reports state conventionally. + + Everything the player DOES is a thing in the world reached through the interaction system: a bench, a station, + a weapon on the ground, a downed teammate. Screen space is for readouts: health, the prompt, the crosshair, the + action bar. The test for anything new: can the player do it by touching something in the world? If yes it is a + prop. If it only tells them something, it may be HUD. + + The earlier guild project made this a pillar and forbade all menus but Escape. This project keeps the test and + drops the absolutism: a world this is headed for will need screens the prototype does not, and the rule's job is + to make every one of them earn its place, not to forbid them. +``` + +## Decisions + +``` +[DECIDED] UMG with CommonUI. Widgets are Blueprint children of C++ base classes that own the data binding; the +Blueprint owns layout and look only. CommonUI supplies input routing, gamepad focus and per-device glyphs, which +is exactly the part nobody wants to write twice. +``` + +``` +[DECIDED] One theme asset. Every colour, type size, spacing step and motion duration is a token on one data asset, +named by role and never by hue. No widget holds a literal colour, size or duration. A colourblind palette is a +second asset with the same roles. +``` + +``` +[DECIDED] Prop text is world-space and has no canvas. Text on a thing in the world is a UTextRenderComponent, or a +world-space UWidgetComponent when it needs layout (the bench panel). It reads the same theme. +``` + +``` +[DECIDED] Everything a player reads is FText from a string table. FString is for identifiers and logs. +``` + +## Layout + +``` +Source//UI/ +├── UITheme.h // UPrimaryDataAsset: the tokens +├── ThemedText.h // UCommonTextBlock subclass reading a type token and a colour role +├── HudWidget.h // the base: health, crosshair, prompt, action bar, downed state; binds to the local player +├── InteractionPromptWidget.h // verb, target, reason, hold fill, live glyph +├── ActionBarWidget.h // four slots: glyph, name, cooldown; reads the ability system and the input subsystem +├── MenuWidget.h // Escape: Resume, Settings, Quit. Never pauses. +└── SettingsWidget.h // comfort sliders, motion scale, rebinding rows through the engine's user settings + +Content/UI/ +├── DA_Theme_Default +├── WBP_Hud, WBP_InteractionPrompt, WBP_ActionBar, WBP_ActionBarSlot, WBP_Menu, WBP_Settings +└── ST_Game // the string table; one key per readable string, prop prompts included +``` + +## The theme + +```cpp +UCLASS(BlueprintType) +class UUITheme : public UPrimaryDataAsset +{ + GENERATED_BODY() +public: + // Roles, never hues. A widget names a role; the asset says what it looks like. + UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor HudNeutral, HudGood, HudWarning, HudDanger; + UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor PromptAvailable, PromptBlocked; + UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor OutlineInRange, OutlineTargeted; + UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor Scrim, PanelSurface, PanelText; + UPROPERTY(EditDefaultsOnly, Category = "Colour") TMap DomainColours; // Domain.Forge, .Wood, .Enchant + UPROPERTY(EditDefaultsOnly, Category = "Colour") TArray PlayerColours; // by join order, low saturation, rings not fills + + // Type scale, in steps rather than free numbers. HUD sizes in pixels at 1080p; world sizes in centimetres. + UPROPERTY(EditDefaultsOnly, Category = "Type") FSlateFontInfo HudPrimary, HudSecondary, Prompt, Caption; + UPROPERTY(EditDefaultsOnly, Category = "Type") float PropTitleCm = 12.f, PropBodyCm = 7.f, PropSmallCm = 5.f; + + UPROPERTY(EditDefaultsOnly, Category = "Space") TArray SpacingSteps = {4, 8, 16, 24, 40}; + UPROPERTY(EditDefaultsOnly, Category = "Motion") float Fast = 0.12f, Normal = 0.2f, Slow = 0.4f; // every one scaled by the motion setting +}; +``` + +Meaning never rides on hue alone: a blocked prompt is grey **and** worded differently; a danger readout is red +**and** changes shape. `HudGood` leans teal and `HudDanger` leans orange so the two separate under deuteranopia. +Substance colours are not theme tokens; they are on the substance definitions because the substance is the palette. + +The placeholder look: neutral, stylised, legible. The two source projects each had a fully specified palette (a +parchment-and-ink guild hall; a warm forge with domain tints). Neither is this game's, so the default asset is +plain white HUD over greybox and the domain tints, and an art direction decides the rest when there is one. + +## The HUD + +One widget, added by the player controller for the local player, reading the local player state's ability system +and the pawn's interaction component. It never computes: health is the attribute, the prompt is +`UInteractionComponent::GetCurrentPrompt`, the cooldown is the cooldown tag's remaining time. + +| Element | Reads | Shows | +| --- | --- | --- | +| Crosshair | nothing | four pixels, `HudNeutral` | +| Health | `Health`, `MaxHealth`, `State.Downed` | number plus a status line; colour by fraction, and shape when downed | +| Prompt | `GetCurrentPrompt` | `[glyph] Verb Target`, greyed with the reason when blocked, a radial fill while holding | +| Action bar | the four `Input.Ability.n` abilities, the input subsystem | glyph (live binding, per device), name, seconds left; dim while empty or cooling | +| Downed | `State.Downed`, `GE_BleedOut` remaining | "Downed: 24 s" and who is nearest | +| Carry | `UCarryComponent::GetHeld` | the held object's name, and "hands full" on a blocked verb | +| Statuses | active `Status.*` tags and remaining durations | a row of icons from `DT_StatusIcons`, debuffs first; the "Immune" flash on a blocked one comes from the cue, not the HUD | + +Two canvases, and the split is a rebuild-cost decision: the static one (crosshair, chrome) and the volatile one +(everything that changes). Neither has hit testing; the HUD is never clicked. + +## Input modes + +One place decides what the cursor does and which mapping context is live, and it is CommonUI's input routing on +the activatable widget stack. Gameplay input stops while the menu is up; **the world does not.** Enemies keep +moving, the forge keeps burning. This is the difference between a mode switch and a pause, and it is the design: +opening Settings in a fight is a bad idea, which is correct, and it means no second behaviour for the same key that +would be untested in one of the two modes. + +The prompt's glyph and the action bar's keys come from the engine's Enhanced Input user settings through the +input subsystem, by the device the player touched last. No widget prints a literal key. + +## World-space text + +Text on a prop is a `UTextRenderComponent` reading the theme's world sizes and a colour role, on the prop itself, +with no canvas: it stays readable as you walk round it and lights with the scene. The surface rule: light surface, +`PanelText`; dark surface, `HudNeutral`. The bench's assembly panel, which needs slots and a gauge, is a +`UWidgetComponent` in world space reading the same theme and laid out by the family's authored grid so a sword +reads as a sword. Its quality gauge shows the synergy segment separately, so players learn why matching themes +score higher. + +## The menu + +Escape: Resume, Settings, Quit. Settings: FOV per mode, head stabilisation, bob, motion scale, text scale, and a +rebind row per bindable action through the engine's user settings. Nothing else, until a step needs something +else and says why here. + +## Localisation + +Every readable string is a key in `ST_Game`, resolved through `FText::FromStringTable`. Prompts are keyed by verb +tag (`Interact.Verb.Revive` resolves to `prompt.verb.revive`), reasons by reason tag, ability names on the ability +class as `FText`. The engine's localisation dashboard gathers from string tables and `FText` properties; nothing +else is needed until there is a second language. + +## Accessibility hooks + +Hooks now, content later; none of the later work touches a widget. + +| Hook | Exists | Filled in later | +| --- | --- | --- | +| Text scale | `ThemedText` multiplies every size by it | a slider | +| Colourblind palettes | roles on the theme, no literals in widgets | alternative theme assets | +| Motion scale | every theme duration, camera kick, hit stop and bob multiplies by it | the slider ships in step 3, a comfort requirement for first person | +| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings | +| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb | + +## Telemetry + +`settings_changed` with `setting_id` as the dotted path (`camera.fov.first_person`, `input.bindings.interact`) and +the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and +`prop_interacted` already answers whether players find things. + +## Open questions + +- **Q1. World-space widget legibility.** A `UWidgetComponent` at bench distance in both camera modes has to be + checked once with real text before the assembly panel is built on it. Ten minutes in step 13, not a spike. +- **Q2. A font.** The engine's default until a look exists. When one is chosen it is one face with material + presets, not three font assets. +- **Q3. Does the HUD scale with resolution?** Scale with screen size at 1080p reference is assumed; ultrawide needs + one look. diff --git a/Docs/Steps.md b/Docs/Steps.md new file mode 100644 index 0000000..d4cdd50 --- /dev/null +++ b/Docs/Steps.md @@ -0,0 +1,270 @@ +# Steps + +The order of work. Not a roadmap: there are no phases, no dates and no milestones, only a ladder of small proofs +where each rung stands on the one below. The next few steps are written in full. The rest are sketches, and a +sketch is detailed only when the step before it closes; anything written further ahead than that would be a plan +pretending not to be one. + +The [`Spec/`](Spec/README.md) documents specify; this document schedules. A step links the sections it builds and +never restates them. If a step and its spec disagree, the spec wins and this file is corrected. + +## The rules of the ladder + +- **A step is closed by a proof.** A test passes, a checklist is ticked, a person performs the sentence. + "Implemented" closes nothing. +- **A step lands with its docs.** The spec's "what was built, and where it differs" section, a decision-log line + if a decision was taken, a worklog line, and the status here, all in the same change. +- **A step that cannot be closed by one proof is two steps.** A step under half a day belongs inside a neighbour. +- **Ids are stable.** A dropped step is struck through with a reason, never renumbered. +- **Every replicated step is proved with a dedicated server** in the editor and network emulation at 100 ms and + 5 % loss. See [Networking.md](Spec/Networking.md). + +Status: `☐` not started · `◐` in progress · `☑` done · `⊘` dropped · `⏸` blocked (names the blocker) + +## The ladder + +| # | Step | Proves | Status | +| --- | --- | --- | --- | +| 1 | The project, two modules, tests, a dedicated server in the editor | It builds, tests run headless, and the server posture is real from the first commit | ☐ | +| 2 | The telemetry seam | Every later step can emit into something | ☐ | +| 3 | A body, a camera, an input map and the gym | You can walk, in both camera modes, on a server | ☐ | +| 4 | The feel pass | The written checklist passes with a person at the keyboard | ☐ | +| 5 | The ability foundation and the stat block, proved on dodge, blink and mud | Abilities predict cleanly, and every body has the one set of numbers every effect changes | ☐ | +| 6 | Interaction and carrying | One system touches every prop; things you hold are real | ☐ | +| 7 | The swing | One damage funnel, one weapon, one dummy, and it feels good to hit | ☐ | +| 8 | The goblin | An enemy with a tell, going down, being revived, a wipe | ☐ | +| 9 | Kits and the action bar | Two classes with a signature each, and the bar tells the truth about keys | ☐ | +| 10 | The second pair of kits | Parity: four kits finish the same fight | ☐ | +| 11 | Projectiles | Aim matters at range | ☐ | +| 12 | Crafting data and rules | The pure rules pass their tests; the reference example composes | ☐ | +| 13 | The bench | You assemble a sword, equip it, and its quality changes the damage number | ☐ | +| 14 | Substances and the forge | Ore becomes ingot through a bed you tend, and walking away is not an exploit | ☐ | +| 15 | The anvil and enchanting | Ore to ingot to blade to a sword of embers, all physical, all server-validated | ☐ | + +What comes after fifteen is decided when fifteen closes. The candidates are in [`Ideas.md`](Ideas.md). + +--- + +### 1 · The project, two modules, tests, a dedicated server in the editor + +**Build:** The `.uproject` at the repository root; runtime modules `Core` and `` with the folder +layout from Architecture; the `Server` build target; plugins enabled: Gameplay Abilities, Gameplay Tags, Enhanced +Input, CommonUI, StateTree; `Config/Tags/` with the top-level namespaces; the asset manager configured for the +definition types; `Scripts/run-tests.sh` and `Scripts/build.sh`; `.gitattributes` with Git LFS for binary assets; +one placeholder automation test in Core; an empty `L_Gym`. +**Spec:** [Architecture.md](Spec/Architecture.md) (modules, content, tags, testing, conventions). +**Depends:** the open decisions on engine version and project name ([Decisions.md](Decisions.md), OD-01, OD-02). +**Done when:** the editor opens the project with no warnings about the modules; `Scripts/run-tests.sh` runs the +placeholder test headless and reports green; Play In Editor with two clients and "Run Dedicated Server" puts two +default pawns in the empty gym; `git lfs ls-files` lists the first `.uasset`. +**Notes:** The launcher build of the engine cannot compile a packaged server; the editor's dedicated-server option +is the everyday test and is enough until a packaged build is wanted. Do not create a third module, a plugin or an +editor module here; each arrives when something needs it. + +### 2 · The telemetry seam + +**Build:** `ITelemetrySink` with the null, log and JSON Lines sinks; `FTelemetryEvent` and the envelope; the +event-name constants; `UTelemetrySubsystem` on the game instance; `app_started`, `session_started`, +`session_ended`, `cheat_used`; the session id minted by the game mode and adopted by clients through the game state. +**Spec:** [Telemetry.md](Spec/Telemetry.md). +**Depends:** 1. +**Done when:** launching the gym with `-telemetry` produces `Saved/Telemetry/session_*.jsonl` with `app_started` +then `session_started`, every line parses, and a client's file carries the server's session id; the sink test +passes; `bs.TelemetryTest` emits an event and sets `cheats_used`. +**Notes:** Before the character, deliberately. Adding emit calls to a system that already exists means reading +it again and guessing what mattered; adding them as it is written costs a line. + +### 3 · A body, a camera, an input map and the gym + +**Build:** `ABaseCharacter`, `APlayerCharacter`, `UExtendedCharacterMovement` with the sprint flag in the saved +move, `UMovementTuning` and `DA_Tuning_Player`; `IMC_Gameplay` and every `IA_*` from the layout table, bound by +asset; `FLookModel` in Core with its tests; `UCameraModeDefinition` for both modes and `UCameraModeComponent` with +the stabilised head socket, the hidden local head, the spring arm; `RemoteHeadYaw`; the mannequin with the +template's animation blueprint; the gym greyboxed to the section table; the first `movement_sample`, `jump`, +`land` and `camera_mode_changed` events; the Settings widget's comfort sliders (FOV, stabilisation, bob, motion +scale) and the rebind rows through the engine's user settings. +**Spec:** [Movement.md](Spec/Movement.md) (input, the character, the look model, camera modes, the gym), +[UI.md](Spec/UI.md) (the menu and settings). +**Depends:** 2. +**Done when:** the whole gym can be walked in both camera modes; the five look-model tests pass; two clients and a +dedicated server at 100 ms and 5 % show smooth remote bodies and no visible local correction with +`p.NetShowCorrections 1`; a rebind of Interact survives a restart; every setting change emits `settings_changed`. +**Notes:** Sprint is a compressed flag, not an ability. Coyote time and the jump buffer are in this step because +they are the movement component's business; the feel numbers are guesses until step 4. `GetMaxSpeed` already +multiplies by the `MoveSpeed` attribute, which reads as one until the stat block exists in step 5. The head is +hidden by bone on the local pawn only; this is the rigging rule that everything head-worn must parent to the head +bone. + +### 4 · The feel pass + +**Build:** Nothing new. The tuning asset's numbers, the gym's sections adjusted where they lied, the three gym +functional tests (`FT_Gym_Stairs`, `FT_Gym_Slopes`, `FT_Gym_Gaps`), and a worklog entry with the numbers that made +the checklist pass and the ones that did not. +**Spec:** [Movement.md, The feel checklist](Spec/Movement.md#the-feel-checklist). +**Depends:** 3. +**Done when:** every line of the checklist is ticked in both camera modes by a person at the keyboard, the three +functional tests pass, and `DA_Tuning_Player` is committed with the values that did it. +**Notes:** This is the step most likely to be called done early. It is not done while any line says "mostly". +Q1 in Movement (the default camera mode) is not decided here; both modes must pass. + +### 5 · The ability foundation and the stat block, proved on dodge, blink and mud + +**Build:** `UExtendedAbilitySystemComponent` on the player state with input-tag routing, `ApplyDefaults` and +`ApplyStatus`; `InitAbilityActorInfo` on both sides; `UStatBlockAttributeSet` with every attribute in the table +and its clamps, `FStatBlockDefaults` and `GE_InitStats`; the movement component reading `MoveSpeed` and +`JumpPower`; `GE_Status_Slow`, `GE_Status_Haste`, `GE_Immune_Slow`, `GE_Cleanse` with their cues and the +`Status.*`, `Immune.*` and `Surface.*` tags; `AEffectVolume`; `UPhysicalMaterialWithTags`, the ground trace and +the gym's mud and ice patches; `UKitAbility`; `GA_Dodge` (constant-force root motion, `State.Invulnerable` for its +duration, a cooldown effect) and `GA_Blink` (move-to-force along the aim, flattened); `IA_Dodge` and `IA_Ability1` +routed by tag; the HUD's status icon row; `ability_used`, `status_applied`, `status_blocked`, `status_removed`. +**Spec:** [Stats.md](Spec/Stats.md), [Combat.md](Spec/Combat.md) (abilities and kits), +[Movement.md, Hooks for other systems](Spec/Movement.md#hooks-for-other-systems). +**Depends:** 4. +**Done when:** dodge and blink activate on their keys, predict locally and show no visible correction at 100 ms and +5 %; a dodge through a debug damage volume takes no damage during its i-frames and damage after; walking onto the +mud slows the body to the surface's magnitude and the icon appears, walking off restores it; a slow volume over +the mud does not stack with it but a haste multiplies against it; `bs.ApplyStatus Slow 0.5 10` then +`bs.GrantImmunity Slow` shows the blocked cue on the next application; the resistance and carry-factor tests and +the attribute clamp test pass; `bs.GrantAbility`, `bs.ApplyStatus`, `bs.GrantImmunity` and `bs.Cleanse` exist. +**Notes:** GAS proves itself on movement before combat depends on it, because a movement ability that snaps is +the most visible thing prediction can get wrong. The stat block lands here rather than in combat so that the +first debuff, the first patch of ground and the carry penalty in step 6 all arrive on a system that exists, +instead of each growing a field of its own. `UAbilitySystemGlobals::InitGlobalData` is called from the asset +manager's startup or target data will not serialise; it is the classic first-day GAS bug. + +### 6 · Interaction and carrying + +**Build:** `IInteractable`, `FInteractionPrompt`, `UInteractionSubsystem` with the server funnel, +`UInteractionComponent` with the camera sweep, the highlight sweep and hold-to-confirm; the `Interactable` and +`Carryable` trace channels; `UInteractionHighlightComponent` and the outline post-process; `WBP_InteractionPrompt` +reading the live glyph; `ICarryable`, `UCarryableComponent`, `UCarryComponent` with hands, pick up, drop, throw +(G tap and hold) and hand over; `State.Carrying` tags blocking the right verbs; `GE_Encumbered` from +`CarryMath::SpeedFactor` against `CarryCapacity`; two test props in the gym (a lever that toggles a light, a +crate that is two-handed); all six interaction events. +**Spec:** [Interaction.md](Spec/Interaction.md). +**Depends:** 5. +**Done when:** the lever's prompt appears only in reach and greys with a reason from too far; a request forged +from beyond reach is refused and emits `interaction_refused`; the crate blocks the lever with "hands full"; a throw +lands within tolerance on two clients; the outline shows which of two crates the press will take; the interaction +functional test passes. +**Notes:** Before combat, because a downed teammate is a prop and reviving must not invent a mechanism. Pick-up is +routed by the interaction component but is not an interaction; the boundary is in the spec and it is load-bearing. + +### 7 · The swing + +**Build:** `DamageMath` and its tests; `UDamageExecution`, `GE_Damage` and the friendly-fire gate through +`IGenericTeamAgentInterface`; `FWeaponProfile`, `UWeaponDefinition`, `AWeaponActor` on the hand socket; +`GA_MeleeAttack` with the montage, `ANS_HitWindow`, the server box sweep and the line-of-sight check; the hit +cues: impact, client-side hit stop, camera kick, the procedural hit reaction; `GE_FallDamage` from `Landed`; a +training dummy in the arena with a health number over it; the health HUD; `player_damaged`, `enemy_damaged`. +**Spec:** [Combat.md](Spec/Combat.md) (the damage funnel, weapons and the melee swing). +**Depends:** 6. +**Done when:** the dummy takes exactly the profile's damage once per swing and none through a wall; a swing at +another player moves them and costs no health, and the functional test asserts both; a 500 cm drop damages; hit +stop never runs on the server; and a person says hitting the dummy feels good and writes the numbers down. +**Notes:** Combat feel is the honest unknown of the whole ladder. Budget iteration here and do not move on until +one dummy is satisfying. The box after the wind-up is the shipped shape; a blade sweep is the fallback in Q4. + +### 8 · The goblin + +**Build:** `UEnemyDefinition` with its `BaseStats` and `DA_Enemy_Goblin`; `AEnemyCharacter` with its own ability +system and the same stat block, so the mud from step 5 slows it; +`AEnemyController` with perception, the StateTree (idle, chase, attack with wind-up, flee, leash) and +`FThreatTable`; `UCombatantComponent` with the downed state, `GA_Downed`, `GE_BleedOut`, the revive prop, +`GA_Revive`, the solo-down-is-a-wipe rule; `UEncounterSubsystem` spawning from spawn points in the arena with the +sublinear table; respawn on wipe; every remaining combat event. +**Spec:** [Combat.md](Spec/Combat.md) (health, down, revive, wipe; enemies). +**Depends:** 7. +**Done when:** three goblins fight one player and the tells can be read and stepped out of; a goblin chasing +across the mud is slowed exactly as the player is; a downed player is revived through the interaction path by a +second client; alone, a down is an immediate wipe and the arena resets; the goblin flees for its flee time when an +ally dies; the threat table tests pass. +**Notes:** The enemy uses the same melee ability class as the player on its own component. Numbers are the earlier +project's guesses (30 health, 8 damage every 1.2 s with a 0.35 s wind-up) until they are not. + +### 9 · Kits and the action bar + +**Build:** `UClassKitDefinition` with `BaseStats`, `GrantKit`; the Warrior (Taunt as `Status.Debuff.Taunted`, Shoulder charge) and the +Cleric (Mass heal, Mend with the overheal launch, faster revive); `bs.SetClass`; cooldown effects per ability; +`WBP_ActionBar` with live glyphs and cooldowns; `grief_action` from the charge and the launch. +**Spec:** [Combat.md, Abilities and kits](Spec/Combat.md#abilities-and-kits), [UI.md, The HUD](Spec/UI.md#the-hud). +**Depends:** 8. +**Done when:** a taunt pulls every goblin in range off a teammate; a charge damages goblins and launches a +teammate without damaging them; an overheal launches; the bar shows the rebound key after a rebind; the kit +validation test refuses a second signature. +**Notes:** Class lives on the player state and survives a body dying. The recoverability contract applies to the +charge and the launch from the first day they exist: the victim's recovery is standing up and walking back. + +### 10 · The second pair of kits + +**Build:** The Rogue (Backstab, Shadowstep) and the Mage's Blink promoted into a kit with a placeholder second +ability; the parity measurement: `enemy_killed.killer_class` against `time_to_kill_s` over a scripted arena wave. +**Spec:** [Combat.md, Abilities and kits](Spec/Combat.md#abilities-and-kits). +**Depends:** 9. +**Done when:** four kits each clear the same three-goblin wave solo within a spread the worklog records; no +ability writes health directly (grep the abilities for the attribute setter and find nothing). +**Notes:** Sketch. Detail when 9 closes. + +### 11 · Projectiles + +**Build:** `AProjectileActor`, the local cosmetic copy on activation, `GA_Volley` and the bow profile, `GA_Fireball` +for the Mage; the Ranger kit (Mark, Volley). +**Spec:** [Combat.md, Projectiles](Spec/Combat.md#projectiles-step-11). +**Depends:** 10. +**Done when:** an arrow leaves the hand without a visible round trip at 100 ms, drops over distance, and damages +through the funnel with `Damage.Source.Projectile`; a marked goblin takes more from a teammate's sword. +**Notes:** Sketch. + +### 12 · Crafting data and rules + +**Build:** Every definition class, `FPieceInstance`, `FAssembledItem`, `FSubstanceInstance`, `UCraftingConfig`; +the `Item.*`, `Piece.*`, `Substance.*`, `Theme.*`, `Domain.*` and `Craft.Reason.*` tags; every pure rule in +`CraftingRules` with its named tests; `DA_Family_Sword` with the four piece types, four characteristics, iron and +oak, two enchantments; `bs.SpawnPiece`. +**Spec:** [Crafting.md](Spec/Crafting.md) (data, runtime state, the pure rules). +**Depends:** 6 (for the carryable piece actor). Can run in parallel with 7 to 11 in the core module. +**Done when:** the reference example composes to *Blunt Mithril Sword of Embers*; every rejection reason has a +passing named test; a guardless sword validates; the durability case computes; `MakeWeaponProfile` lands a +mid-quality crafted sword inside the authored sword's numbers. +**Notes:** Sketch. The rules exist before a bench does, on purpose: word salad in names is the risk that surfaces +earliest this way. + +### 13 · The bench + +**Build:** `AStationActor`, `AAssemblyBench` with staging, the live preview on a world-space panel laid out by the +family's grid, and the commit through `ServerAssembleItem`; `AAssembledItemActor` built from resolved attach +chains on named sockets, tinted by substance; the Equip verb and the crafted weapon in hand; `item_assembled`, +`assembly_rejected`, `weapon_equipped`. +**Spec:** [Crafting.md, Stations](Spec/Crafting.md#stations), [From item to weapon](Spec/Crafting.md#from-item-to-weapon). +**Depends:** 12, 7. +**Done when:** three spawned pieces are inserted, the panel shows the name and the synergy segment, the commit +produces the sword on the bench, a second client sees the blade on the handle's socket, equipping it changes the +damage number on the dummy in proportion to coherence, and a piece the slot rejects shows the reason. +**Notes:** Sketch. This is the step where crafting and fighting meet; it is why both exist. + +### 14 · Substances and the forge + +**Build:** `ASubstanceActor` as a carryable; the raw-to-processed pipeline; `UActivityRuntime`, +`UActivityDefinition`, `FActivityResult`; `AForgeStation` with the 5×5 bed, the near simulation at 10 Hz, the far +lumped model, the collapse and expand, the equivalence harness; place, rake, fuel and bellows inputs; the +bellows as a second operator; the substance spawner cheat. +**Spec:** [Crafting.md, Substances](Spec/Crafting.md#substances), [Activities](Spec/Crafting.md#activities). +**Depends:** 13. +**Done when:** iron ore in the bed becomes an ingot with a quality that reflects time in the working band; a +second client on the bellows raises the ceiling; walking away and coming back yields the same ingot as staying, +within the harness's tolerance; the output buffer full stalls the forge and says so. +**Notes:** Sketch. The heat harness is written with the first heat code, not after. + +### 15 · The anvil and enchanting + +**Build:** `AAnvilStation` and the strike activity with the growing zone, the temperature gate, the timestamped +strike, two strikers alternating; characteristic discovery on the player state; an enchanting station and +`ServerApplyEnchantment`; the enchantment reaching the weapon profile's damage type and granted tags. +**Spec:** [Crafting.md, Activities](Spec/Crafting.md#activities), [Enchanting](Spec/Crafting.md#enchanting). +**Depends:** 14. +**Done when:** ore to ingot to blade to a sword of embers, all physical objects, all server-validated, in one +session with two clients; a strike at 150 ms of emulated latency scores the same as one at 0 ms pressed at the +same moment; with no input the anvil completes at the baseline score; the enchanted sword's hit carries +`Item.Enchant.Fire.Penetration` into the funnel. +**Notes:** Sketch. When this closes, the three feels exist, and what to build next is a decision taken then, from +[`Ideas.md`](Ideas.md), with the telemetry of fifteen steps to take it with. diff --git a/Docs/Worklog.md b/Docs/Worklog.md new file mode 100644 index 0000000..203e98e --- /dev/null +++ b/Docs/Worklog.md @@ -0,0 +1,27 @@ +# Worklog + +One line per item, newest at the top of each section. What was done, what worked, what did not. Keep it terse; +the reasoning lives in the specs, this is the memory. + +## Done + +- 2026-09-15 — `Spec/Stats.md`: one stat block on every body, every outside influence an effect, counters on the + receiver (D-37, D-38). Replaced the movement component's own speed-multiplier map, which was the first strand + of the spaghetti this exists to prevent; enemies and kits now carry `FStatBlockDefaults`; step 5 builds it. +- 2026-09-15 — The documentation set written for Unreal from the two earlier projects' docs: `Design.md`, + `Steps.md` (fifteen rungs, the first six in full), `Ideas.md`, `Decisions.md` (D-01 to D-36, OD-01 to OD-08), + and the eight specs under `Spec/`. No code, no project yet; step 1 is next and waits on OD-01 and OD-02. + +## Worked + +- (nothing built yet) + +## Did not work + +- (nothing built yet) + +## Open + +- OD-01 (engine version) and OD-02 (project name) block step 1. +- The default camera mode (OD-03) is deliberately undecided until step 8. +- The numbers in every spec are the earlier projects' guesses in centimetres and seconds. None has been played. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b4ab35 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Unreal prototype + +An Unreal Engine 5 project that builds three things in order and proves each one feels right before anything is +built around it: a movement controller, fighting, and crafting. Server-authoritative from the first line, C++ +first, data-driven, telemetry from step 2, and worked as a ladder of small proofs rather than a roadmap. + +Nothing is built yet. The repository currently holds the documentation set. + +## Read + +- [`Docs/Design.md`](Docs/Design.md): what this is, in about two hundred lines. Start here. +- [`Docs/Steps.md`](Docs/Steps.md): the order of work and what closes each step. +- [`Docs/Spec/`](Docs/Spec/README.md): the specifications an implementer builds from. +- [`Docs/Ideas.md`](Docs/Ideas.md): what was carried over from the two earlier projects and not yet built. +- [`Docs/Decisions.md`](Docs/Decisions.md): every decision, the open ones, the deferred ones. +- [`CLAUDE.md`](CLAUDE.md): the instructions an AI agent works from in this repository. + +## Layout + +``` +UnrealEnginePrototyping/ +├── CLAUDE.md Instructions for an AI agent: conventions, layout, commands, upkeep. +├── README.md This file. +├── Docs/ Design, steps, ideas, decisions, worklog, and Spec/ with one document per system. +├── .uproject Created in step 1, at the root, which is what the .gitignore assumes. +├── Source/ Core (rules, no actors) and (gameplay). +├── Content/ Assets, one folder per feature; Maps/L_Gym; Tests/. +├── Config/ Engine and game config; Tags/ holds the gameplay tag sources. +└── Scripts/ Test and build scripts run by hand; Authoring/ for editor Python. +``` + +## Getting started + +After step 1: open `.uproject` with the pinned engine version, run `Scripts/run-tests.sh`, and press Play +in `L_Gym` with two clients and "Run Dedicated Server" on. Until then there is nothing to run. + +Binary assets go through Git LFS; run `git lfs install` once per machine.