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

45 KiB
Raw Permalink Blame History

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), what happens when the weapon lands (Combat.md), or any economy, customer or shop: none of those exist here, and the ideas are parked in ../Ideas.md.

Read Architecture.md first. Steps 12 to 15 in ../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

// 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

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.

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) 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), 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.

Stations

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.
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, 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.

// 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

// 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.
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.