Files
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

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

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


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

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

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

718 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<Project>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/<Project>/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/<Project>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<UStaticMesh> 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<USubstanceDefinition> ProcessesInto;
UPROPERTY(EditDefaultsOnly) int32 ProcessInputCount = 1, ProcessOutputCount = 1;
UPROPERTY(EditDefaultsOnly) FGameplayTag ProcessDomainTag;
UPROPERTY(EditDefaultsOnly) TSoftObjectPtr<UStaticMesh> 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<UGameplayEffect> 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<FGameplayTag> 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<FPartSlot> 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<USubstanceDefinition> 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<UPieceTypeDefinition> Type;
UPROPERTY() TObjectPtr<UCharacteristicDefinition> Characteristic;
UPROPERTY() TObjectPtr<USubstanceDefinition> Substance;
UPROPERTY() float SubstanceQuality = 0.5f; // average of the objects consumed
UPROPERTY() TArray<FGuid> SourceObjectIds; // WHICH objects: provenance and unique-substance binding
UPROPERTY() float CraftQuality = 0.5f; // the shaping activity's contribution
UPROPERTY() TArray<TObjectPtr<UEnchantmentDefinition>> Enchantments; // bounded by Type->AddonSockets
// DERIVED, recomputed on load, never persisted: Value, Durability
};
USTRUCT()
struct FAssembledItem
{
GENERATED_BODY()
UPROPERTY() FGuid ItemId;
UPROPERTY() TObjectPtr<UItemFamilyDefinition> Family;
UPROPERTY() TMap<FGameplayTag, FPieceInstance> 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<FGameplayTag, FPieceInstance>& 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<FAttachment> ResolveAttachChains(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& Parts);
// pure. Prefix + [substance] + root + suffix, at most one fragment per role. Word salad is structurally impossible.
FText ComposeName(const UItemFamilyDefinition& Family, const TMap<FGameplayTag, FPieceInstance>& 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<FGameplayTag, FPieceInstance>& 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 <tag> <count>` 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<UActivityDefinition> 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<UStationDefinition> Definition;
UPROPERTY(Replicated) FGameplayTag StateTag; // Station.State.*
UPROPERTY(Replicated) TArray<TObjectPtr<APlayerState>> Operators;
UPROPERTY(Replicated) FGuid ActivitySession; // 0 when idle
UPROPERTY(Replicated) TArray<FGuid> 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<UActivityRules> 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<FGuid, float> 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<FGuid> 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<FGuid> 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<FName> Marks; }
FPieceRecord { FGuid Id; FPrimaryAssetId Type, Characteristic, Substance; float SubstanceQuality, CraftQuality;
TArray<FGuid> SourceObjectIds; TArray<FPrimaryAssetId> Enchantments; } // no Value, no Durability
FItemRecord { FGuid Id; FPrimaryAssetId Family; int32 Durability; TMap<FName, FGuid> 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.