# Crafting Owns what an item is made of and how it comes to exist: families, parts, pieces and their three trait layers; the substances pieces are shaped from and the pipeline that refines them; the pure rules for legality, naming and quality; stations and the one activity runtime their hands-on work runs on; enchanting; and the seam through which a crafted weapon becomes a weapon you fight with. It does **not** own how a piece is picked up or inserted ([Interaction.md](Interaction.md)), what happens when the weapon lands ([Combat.md](Combat.md)), or any economy, customer or shop: none of those exist here, and the ideas are parked in [`../Ideas.md`](../Ideas.md). Read [Architecture.md](Architecture.md) first. Steps 12 to 15 in [`../Steps.md`](../Steps.md). This is the largest spec because the earlier smithing project had already worked its crafting model through several review passes; what follows is that model, restated for Unreal and cut to what a fighting prototype needs. ## Decisions ``` [DECIDED] Three words, three levels. A FAMILY declares PARTS. A PIECE is a crafted object filling one part. A piece carries three TRAIT layers: a characteristic (its shape), a substance (what it is made of) and zero or more enchantments (its ornament). An item is a family's parts, filled. Blade: mithril, blunt · Guard: mithril, pointy · Handle: dragonhide, of embers · Pommel: adamantine -> "Blunt Mithril Sword of Embers". The player made that; they did not pick it from a list. ``` ``` [DECIDED] The design word "material" is "substance" in this project's code and docs. UMaterial is the engine's render material and the collision would be permanent. Substance is the word the earlier design used in its own subtitle, so nothing of meaning is lost. ``` ``` [DECIDED] No recipes, anywhere. A family lists slots and filters. Any slot-complete, filter-satisfying set of pieces is a legal item. If a combination needs a table to be legal, the filters are wrong. ``` ``` [DECIDED] Substances are objects, not stacks. Twelve iron ingots are twelve actors, each with its own identity and quality. Quality variance is then free, provenance is free, and "the sword made from that dragon's bone" is a queryable fact. Affordable only because storage is physical and caps the count: any future feature that lets substance accumulate without occupying space breaks the assumption and is refused on that ground. ``` ``` [DECIDED] Discovery unlocks, choice applies. You discover characteristics through play; you choose them at the station. Strike quality decides how well a shape came out, never which shape you got, so a crafted item is always a deliberate act and a request for "a serrated blade" is always fillable. ``` ``` [DECIDED] One activity runtime, three guarantees. Every hands-on station task runs on one runtime that owns participation, authority, progress, pause, quality accumulation and telemetry; a minigame implements only its own rules. The guarantees, binding forever: completion is never gated on skill; skill modulates a quality band and nothing else; a second pair of hands is always additive and never required. ``` ``` [DECIDED] The assembly bench is domain-neutral. Every pipeline converges there; gating it would make one player a bottleneck. Domains gate the WORK (a station refuses a piece of the wrong domain) and never the WORKER (anyone may use any station). Class changes speed and quality only. ``` ``` [DECIDED] Durability is a part property summed into an item total, and the item is lost at zero. Nothing in the shop wears out from crafting; wear is something the world does to gear later. ``` ## Vocabulary and tags | Term | Meaning | Tag namespace | | --- | --- | --- | | Family | A kind of item and the schema of its parts, as a tree | `Item.Family.Sword`, `.Axe`, `.Bow`, `.Shield` | | Part | A position in an item | `Item.Part.Blade`, `.Guard`, `.Handle`, `.Pommel` | | Piece type | What a piece is, before its traits | `Item.PieceType.Blade`, `.Guard`, `.Handle`, `.Pommel` | | Piece | A crafted object filling a part; has an `FGuid` | runtime, `FPieceInstance` | | Trait layer | Characteristic, substance, enchantment | `Piece.Layer.*` | | Characteristic | Shape: serrated, blunt, curved, heavy | `Item.Characteristic.*` | | Substance | What a piece is made of | `Item.Substance.Iron`, `.Oak`, `.Mithril` ... | | Enchantment | Ornament: of embers, of frost | `Item.Enchant.Fire.Penetration` ... | | Substance object | One physical ingot, log, hide; has an `FGuid` | runtime, `FSubstanceInstance` | | Domain | Which station family works a thing | `Domain.Forge`, `.Wood`, `.Enchant` | | Theme | Synergy vocabulary shared by characteristics and enchantments | `Theme.Aggressive`, `.Defensive`, `.Fire` ... | | Coherence | An item's quality score: piece values plus synergy | runtime | Other namespaces this spec owns: `Substance.Class.[Metal|Wood|Hide|Bone|Arcane|Fuel]`, `Substance.Rarity.[Common|Uncommon|Rare|Unique]`, `Substance.State.[Raw|Processed]`, `Station.Type.*`, `Station.State.[Idle|Occupied|Working|Blocked|Paused]`, `Activity.[Heat|Strike|Assemble]`, `Activity.State.*`, `Fault.*`, `Mark.*`, and `Craft.Reason.*` for every rejection. ## Layout ``` Source/Core/Crafting/ ├── CraftingDefinitions.h // the definition asset classes (data only, no world) ├── CraftingTypes.h // FPieceInstance, FAssembledItem, FSubstanceInstance, FProvenance, verdict structs ├── CraftingRules.h / .cpp // pure: CanFillSlot, ValidateAssembly, ResolveAttachChains, ComposeName, ScoreCoherence, │ // PieceValue, PartDurability, MakeWeaponProfile └── ActivityTypes.h // FActivityResult, FActivitySession, the strike and heat rule structs (pure parts) Source//Crafting/ ├── CraftingSubsystem.h // UWorldSubsystem, server: the Server* flows, the item registry, discovery ├── SubstanceActor.h // a carryable physical substance object ├── PieceActor.h // a carryable piece ├── AssembledItemActor.h // a carryable assembled item; builds its visual from sockets; equippable if a weapon ├── Stations/ │ ├── StationDefinition.h // UPrimaryDataAsset │ ├── StationActor.h // IInteractable receptacle, buffers, operators, state machine │ ├── AssemblyBench.h // staging, preview, commit │ ├── AnvilStation.h // hosts the strike activity │ └── ForgeStation.h // hosts the heat activity, the bed └── Activities/ ├── ActivityRuntime.h // UWorldSubsystem: sessions, participants, pause, the one result funnel ├── ActivityDefinition.h // UPrimaryDataAsset per activity ├── StrikeActivity.h // the growing zone └── HeatActivity.h // the coal bed grid, two LODs Content/Crafting/ ├── Definitions/Families/DA_Family_Sword ├── Definitions/PieceTypes/DA_PieceType_Blade, _Guard, _Handle, _Pommel ├── Definitions/Characteristics/DA_Char_Straight, _Serrated, _Blunt, _Curved ├── Definitions/Substances/DA_Sub_IronOre, _IronIngot, _OakLog, _OakPlank, _Coal ├── Definitions/Enchantments/DA_Ench_Embers, _Frost ├── Definitions/Stations/DA_Station_AssemblyBench, _Anvil, _Forge ├── Definitions/Activities/DA_Activity_Strike, _Heat ├── DA_CraftingConfig // the weights and thresholds every pure rule reads └── Meshes/SM_Proto_Blade ... // primitives with named sockets, tinted by substance ``` ## Data ```cpp // Source/Core/Crafting/CraftingDefinitions.h UCLASS(BlueprintType) class UPieceTypeDefinition : public UPrimaryDataAsset // "Blade", "Guard", "Handle", "Pommel" { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag PieceTypeTag; // Item.PieceType.Blade UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Blade: which part it can fill UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge | Domain.Wood: who shapes it UPROPERTY(EditDefaultsOnly) FGameplayTagContainer FitsFamilies; // Item.Family.Sword, .Dagger ... UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Sword": root candidate when this part dominates UPROPERTY(EditDefaultsOnly) int32 PartWeight = 1; // dominance for naming and scoring: blade 4, guard 2, handle 2, pommel 1 UPROPERTY(EditDefaultsOnly) int32 BaseValue = 10; UPROPERTY(EditDefaultsOnly) int32 SubstanceObjects = 1; // objects consumed to shape one: blade 3, guard 1, handle 2, pommel 1 UPROPERTY(EditDefaultsOnly) int32 BaseDurability = 100; UPROPERTY(EditDefaultsOnly) int32 AddonSockets = 1; // enchantment capacity UPROPERTY(EditDefaultsOnly) int32 HotSpots = 4; // for the strike activity, 3..6 UPROPERTY(EditDefaultsOnly) FVector2D ScaleRange = FVector2D(0.7f, 1.6f); // absurd proportions are legal, bounded UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // what this part adds to a weapon profile, see the seam UPROPERTY(EditDefaultsOnly) TSoftObjectPtr Mesh; // carries the named sockets }; UCLASS(BlueprintType) class UCharacteristicDefinition : public UPrimaryDataAsset // "Serrated", "Blunt", "Curved", "Heavy" { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag CharacteristicTag; UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToParts; // a blade can be serrated; a pommel cannot UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // synergy input UPROPERTY(EditDefaultsOnly) FText NameFragment; // "Serrated", the prefix UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0; UPROPERTY(EditDefaultsOnly) FWeaponContribution Weapon; // serrated: +damage, -durability, say UPROPERTY(EditDefaultsOnly) bool bKnownFromStart = false; // else discovered }; UCLASS(BlueprintType) class USubstanceDefinition : public UPrimaryDataAsset // the TYPE: "Iron Ingot", "Oak Log" { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag SubstanceTag; // Item.Substance.Iron UPROPERTY(EditDefaultsOnly) FGameplayTag ClassTag; // Substance.Class.Metal UPROPERTY(EditDefaultsOnly) FGameplayTag RarityTag; // Substance.Rarity.Common UPROPERTY(EditDefaultsOnly) FGameplayTag StateTag; // Substance.State.Raw | .Processed UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; UPROPERTY(EditDefaultsOnly) FGameplayTagContainer WorkableBy; // Domain.Forge ... UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ArcaneAffinity; // which enchantment themes it accepts UPROPERTY(EditDefaultsOnly) int32 BaseValue = 5; UPROPERTY(EditDefaultsOnly) float DurabilityFactor = 1.f; // dragonbone 1.5, oak 0.1 UPROPERTY(EditDefaultsOnly) FLinearColor PaletteColour; // the substance IS the palette in flat-shaded low poly // heat, for the forge: working band and burn point in arbitrary heat units, thermal mass UPROPERTY(EditDefaultsOnly) float WorkingTempMin = 0.f, WorkingTempMax = 0.f, BurnPoint = 0.f, ThermalMass = 1.f; UPROPERTY(EditDefaultsOnly) bool bTwoHanded = false; // a log takes both hands // processing: Raw -> Processed UPROPERTY(EditDefaultsOnly) TObjectPtr ProcessesInto; UPROPERTY(EditDefaultsOnly) int32 ProcessInputCount = 1, ProcessOutputCount = 1; UPROPERTY(EditDefaultsOnly) FGameplayTag ProcessDomainTag; UPROPERTY(EditDefaultsOnly) TSoftObjectPtr Mesh; }; UCLASS(BlueprintType) class UEnchantmentDefinition : public UPrimaryDataAsset // "of Embers" { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag EnchantmentTag; UPROPERTY(EditDefaultsOnly) FGameplayTagContainer ApplicableToSubstances; // affinity gate, both sides must agree UPROPERTY(EditDefaultsOnly) FGameplayTag ThemeTag; // Theme.Fire UPROPERTY(EditDefaultsOnly) FGameplayTagContainer GrantsTags; // Item.Enchant.Fire.Penetration: reaches the weapon profile UPROPERTY(EditDefaultsOnly) TSubclassOf HolderEffect; // optional: granted to whoever equips the item, e.g. GE_Immune_Burning (Stats.md) UPROPERTY(EditDefaultsOnly) FGameplayTag DamageType; // Damage.Type.Magic, optional UPROPERTY(EditDefaultsOnly) FText NameFragment; // "of Embers", the suffix UPROPERTY(EditDefaultsOnly) int32 ValueDelta = 0, SocketCost = 1; }; USTRUCT() struct FPartSlot { GENERATED_BODY() UPROPERTY(EditDefaultsOnly) FGameplayTag PartTag; // Item.Part.Guard UPROPERTY(EditDefaultsOnly) bool bRequired = false; // blade and handle yes; guard and pommel no UPROPERTY(EditDefaultsOnly) FGameplayTagContainer Accepts; // piece-type tags this slot takes UPROPERTY(EditDefaultsOnly) TArray AttachesTo; // ordered parent preference: [Guard, Handle] UPROPERTY(EditDefaultsOnly) FName ParentSocket; // "Socket_Attach_Blade" on the parent's mesh UPROPERTY(EditDefaultsOnly) FIntPoint GridPos; // the assembly panel's layout, traces the silhouette }; UCLASS(BlueprintType) class UItemFamilyDefinition : public UPrimaryDataAsset // "Sword" { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag FamilyTag; // Item.Family.Sword UPROPERTY(EditDefaultsOnly) TArray Parts; // the schema; exactly one slot has an empty AttachesTo (the root) UPROPERTY(EditDefaultsOnly) FText RootFragment; // "Sword", if no part supplies a root UPROPERTY(EditDefaultsOnly) bool bIsWeapon = true; UPROPERTY(EditDefaultsOnly) FSoftObjectPath Montage; // the swing animation every sword shares UPROPERTY(EditDefaultsOnly) FGameplayTag HandSocket; // where an equipped one attaches // IsDataValid: one root, no duplicate part tags, every AttachesTo names a part in this family, sockets non-empty. }; UCLASS(BlueprintType) class UCraftingConfig : public UPrimaryDataAsset // the weights every pure rule reads; one asset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) float SubstanceSynergyWeight = 3.f, ThemeSynergyWeight = 4.f, CompletenessWeight = 5.f; UPROPERTY(EditDefaultsOnly) float SubstanceMajorityThreshold = 0.5f; // share of PartWeight one substance needs to be named UPROPERTY(EditDefaultsOnly) int32 MaxNameWords = 5; UPROPERTY(EditDefaultsOnly) FVector2D QualityFactorRange = FVector2D(0.85f, 1.15f); // what MaterialQuality and CraftQuality scale between UPROPERTY(EditDefaultsOnly) float DamagePerQualityPoint = 0.2f; // the seam's exchange rate UPROPERTY(EditDefaultsOnly) float ScrapReturnFraction = 0.2f; }; ``` The sword family, as data: ``` DA_Family_Sword (Item.Family.Sword) Handle required root (AttachesTo empty) ├── Guard optional AttachesTo [Handle] ParentSocket "Socket_Attach_Guard" │ └── Blade required AttachesTo [Guard, Handle] ParentSocket "Socket_Attach_Blade" └── Pommel optional AttachesTo [Handle] ParentSocket "Socket_Attach_Pommel" ``` `Blade.AttachesTo = [Guard, Handle]` is what makes optional parts work without special cases: resolution walks the list and attaches to the first ancestor present, so a guardless sword's blade lands on the handle's own `Socket_Attach_Blade`, which every handle mesh therefore carries. Other families (axe: haft root with head and grip; bow: riser root with limbs and string; shield: face root with rim, boss and straps) are the same shape and are content, not code. A part tag is unique within a family; a second instance of the same kind of part is its own tag (`Item.Part.Pauldron.Left`), which the part-keyed map enforces structurally. Sockets are the engine's static mesh sockets, authored on the mesh asset and resolved by name, never by index, so an artist can move `Socket_Attach_Guard` without touching gameplay data. A missing socket logs a content error and degrades to the parent's origin rather than failing: a greybox item must always assemble. ## Runtime state ```cpp USTRUCT() struct FProvenance { GENERATED_BODY() UPROPERTY() FGameplayTag Kind; // Provenance.Starting | .Crafted | .Found | .Reward ...; extensible, never an enum UPROPERTY() FGuid Ref; // the crafter's player id, or whatever the kind refers to UPROPERTY() int32 Day = 0; // reserved }; USTRUCT() struct FSubstanceInstance // THE physical object: one per ingot, log, hide { GENERATED_BODY() UPROPERTY() FGuid ObjectId; UPROPERTY() TObjectPtr Type; UPROPERTY() float Quality = 0.5f; // 0..1, set by the activity that made it UPROPERTY() FProvenance Origin; UPROPERTY() FGameplayTagContainer Marks; // Mark.Overheated, Mark.Salvaged, Mark.Dented: flavour and valuation // Temperature is NOT here. It lives on the forge bed while the object is in it (HeatActivity), and it is not saved. }; USTRUCT() struct FPieceInstance // a crafted object filling one part { GENERATED_BODY() UPROPERTY() FGuid PieceId; UPROPERTY() TObjectPtr Type; UPROPERTY() TObjectPtr Characteristic; UPROPERTY() TObjectPtr Substance; UPROPERTY() float SubstanceQuality = 0.5f; // average of the objects consumed UPROPERTY() TArray SourceObjectIds; // WHICH objects: provenance and unique-substance binding UPROPERTY() float CraftQuality = 0.5f; // the shaping activity's contribution UPROPERTY() TArray> Enchantments; // bounded by Type->AddonSockets // DERIVED, recomputed on load, never persisted: Value, Durability }; USTRUCT() struct FAssembledItem { GENERATED_BODY() UPROPERTY() FGuid ItemId; UPROPERTY() TObjectPtr Family; UPROPERTY() TMap PartsFilled; // Item.Part.* -> piece. A map, so "what is in the Guard slot" is the question UPROPERTY() int32 Durability = 0; // CURRENT: persisted, it is history // DERIVED, recomputed on load: DerivedName, QualityScore, MaxDurability, the weapon profile }; ``` `SourceObjectIds` looks like overhead on the first day and is load-bearing later: it is what lets a finished sword answer "is this the bone from that particular kill" years on, and it is the field an audit trail needs. ## The pure rules All in `CraftingRules`, in the core module, no world, every one tested. They are the single authority consulted by the bench preview, the server commit, and later anything that asks whether an item satisfies a request. Preview and reality call the same function and cannot disagree. ```cpp namespace CraftingRules { struct FSlotVerdict { bool bAccept; FGameplayTag Reason; }; // Craft.Reason.* on rejection struct FAssemblyVerdict { bool bValid; FGameplayTag Reason; FGameplayTag OffendingPart; FText PreviewName; int32 PreviewQuality; int32 PreviewSynergy; int32 PreviewSubstanceValue; }; struct FAttachment { FGameplayTag Child, Parent; FName Socket; }; // pure. Both sides must agree: the slot accepts the type, the type fits the family, the shape fits the part. // No scale gating: a greatsword blade on a dagger handle is legal because it is funny; ScaleRange bounds it. FSlotVerdict CanFillSlot(const FPieceInstance& Piece, const FPartSlot& Slot, const UItemFamilyDefinition& Family); // Type->PartTag != Slot.PartTag -> WrongPart // !Slot.Accepts.HasTag(Type->PieceTypeTag) -> SlotRejectsType // !Type->FitsFamilies.HasTag(Family.FamilyTag) -> FamilyMismatch // !Characteristic->ApplicableToParts.HasTag(part) -> IllegalShape // pure. Every required slot filled, every filled slot legal, no foreign part, every attach chain resolves. FAssemblyVerdict ValidateAssembly(const UItemFamilyDefinition& Family, const TMap& Parts, const UCraftingConfig& Config); // MissingPart, ForeignPart (a pommel on a bow), UnreachablePart (an authoring error), else Valid with previews // pure. Walks each slot's AttachesTo and emits child -> first present parent -> socket, parent before child. TArray ResolveAttachChains(const UItemFamilyDefinition& Family, const TMap& Parts); // pure. Prefix + [substance] + root + suffix, at most one fragment per role. Word salad is structurally impossible. FText ComposeName(const UItemFamilyDefinition& Family, const TMap& Parts, const UCraftingConfig& Config); // dominant = the piece with the highest PartWeight // prefix = dominant.Characteristic->NameFragment // core = the substance holding >= SubstanceMajorityThreshold of total PartWeight, else nothing // (this is what stops "Iron Dragonhide Mithril Sword" from ever being generated) // root = dominant.Type->NameFragment, else Family.RootFragment // suffix = the highest-value enchantment's NameFragment across all pieces // over MaxNameWords: drop suffix, then core, then prefix. Prefix sharing a stem with root drops the prefix. // pure. A coherent item beats an expensive one. int32 ScoreCoherence(const UItemFamilyDefinition& Family, const TMap& Parts, const UCraftingConfig& Config, int32* OutSynergy = nullptr); // base = Σ PieceValue(piece) * PartWeight // synergy = SubstanceSynergy (n parts of one substance score n(n-1)/2 pairs: commitment, not accident) // + ThemeSynergy (matching pairs among all characteristic and enchantment themes) // + CompletenessBonus (optional parts filled; without it "required parts only" is strictly optimal) int32 PieceValue(const FPieceInstance& Piece, const UCraftingConfig& Config); // (Type->BaseValue + Substance->BaseValue + Characteristic->ValueDelta + Σ enchant ValueDelta) // * lerp(QualityFactorRange, SubstanceQuality) * lerp(QualityFactorRange, CraftQuality) int32 PartDurability(const FPieceInstance& Piece, const UCraftingConfig& Config); // Type->BaseDurability * Substance->DurabilityFactor * the two quality factors int32 MaxDurability(const FAssembledItem& Item, const UCraftingConfig& Config); // Σ PartDurability // pure. The seam to Combat.md. See "From item to weapon". FWeaponProfile MakeWeaponProfile(const FAssembledItem& Item, const UCraftingConfig& Config); } ``` The reference example, run through: dominant is the blade (weight 4), prefix "Blunt"; mithril holds blade plus guard, six of nine weight, so core "Mithril"; root "Sword"; the handle's fire enchantment supplies "of Embers". *Blunt Mithril Sword of Embers*. That is the first automation test. ## Substances **The pipeline, enforced by one tag.** A raw object is never valid input to a shaping station and a processed object is never valid input to a processing station; `StateTag` alone enforces it, with no per-station recipe list. ``` RAW PROCESSED PIECE Iron Ore -> Iron Ingot -> Iron Blade Oak Log -> Oak Plank -> Oak Handle Coal -> (fuel: never processed, burned by the forge bed) ``` Cost is authored on the piece type, not per type and substance, so substance choice is a free axis: any workable substance makes any piece of that type at the same object cost, with different value, colour and heat behaviour. Combinatorial variety, no combinatorial authoring. **Object semantics.** Identity always. No merging, no splitting: objects are created and consumed whole. Aggregated for display only ("Iron Ingot ×12, avg quality 0.62" is a HUD affordance over twelve distinct actors). Consumption picks **worst-fit-first** by default: a station needing three ingots from a rack of nine takes the three lowest-quality qualifying ones, shows which before starting, and the player may override. Good stock is saved for good work by default. `ASubstanceActor` is a carryable ([Interaction.md](Interaction.md)) with a `UStaticMeshComponent` tinted by the substance's palette colour through custom primitive data (no dynamic material instance per object), net dormancy on while at rest, and its `FSubstanceInstance` replicated to whoever is in range. Rare and unique objects on the floor are never swept away by any future tidy-up rule; they glow faintly. **Quality variance is the natural case.** The smelt activity's result lands in `Quality` and scales the object's value; a careless smelt is worth less, a careful one more, both are usable. **Class bonus applies to time and quality, never to yield**: a better smith's smelt is faster and purer, never more numerous, because a yield bonus is an efficiency lock. Concretely, a bonus is the `WorkSpeed` and `WorkQuality` attributes on the body's stat block ([Stats.md](Stats.md)), which the activity runtime reads; a class is base numbers there and nothing else. Supply, for these steps, is a spawner in the gym: `bs.SpawnSubstance ` and a crate that refills. Where substances come from in a world (purchase, contracts, the world itself) is [`../Ideas.md`](../Ideas.md). ## Stations ```cpp UCLASS(BlueprintType) class UStationDefinition : public UPrimaryDataAsset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag StationTag; // Station.Type.Anvil UPROPERTY(EditDefaultsOnly) FGameplayTag DomainTag; // Domain.Forge; empty on the assembly bench UPROPERTY(EditDefaultsOnly) TObjectPtr Activity; // null for a plain station UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1, always, everywhere UPROPERTY(EditDefaultsOnly) int32 InputCapacity = 4, OutputCapacity = 4; UPROPERTY(EditDefaultsOnly) float BaseSpeedMultiplier = 1.f; UPROPERTY(EditDefaultsOnly) FGameplayTagContainer AcceptsInsert; // Substance.State.Processed for an anvil, Item.PieceType for a bench }; /** * Every station. An interactable (join, leave, take output) and an insert receptacle. Operators are a LIST because * the anvil takes two smiths; a second operator raises the ceiling and never the floor. Work at a station PAUSES * when everyone leaves and never fails: losing progress would punish exactly the behaviour a co-op game wants * (dropping what you are doing to help someone). The clock that matters runs elsewhere. */ UCLASS() class AStationActor : public AActor, public IInteractable { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) TObjectPtr Definition; UPROPERTY(Replicated) FGameplayTag StateTag; // Station.State.* UPROPERTY(Replicated) TArray> Operators; UPROPERTY(Replicated) FGuid ActivitySession; // 0 when idle UPROPERTY(Replicated) TArray InputBuffer, OutputBuffer; // object ids; the forge's "buffer" is its bed // IInteractable: Interact.Verb.Operate joins (or leaves when already an operator); the prompt names who is on it // when full, in their colour, and nothing else happens: occupied stations do not queue you. TakeOutput is a // second verb when the output buffer holds something and your hands are free. // Insert (the receptacle path from Interaction.md): validate AcceptsInsert and DomainTag against the carried // object's tags; reject WrongDomain or NotAccepted with the reason; move the object into InputBuffer. // ServerStartWork: an operator, inputs present -> ActivityRuntime->ServerBeginActivity(...). On completion, // if the output buffer has no room the station is Blocked, never destroying anything, until someone empties it. // No queueing. Batching is physical: the forge bed holds many items at once because it is a bed. }; ``` **The assembly bench** is a station with no activity and no domain. Inserting a piece stages it into the part its type fills (the map makes a second piece in one part structurally impossible); every insertion re-runs `ValidateAssembly` and shows the preview name, quality, synergy segment and the failing reason on the bench's world-space panel; the `Assemble` verb commits through `ServerAssembleItem`, which re-runs the verdict server-side and never trusts the preview. The session's initiator commits; anyone may insert. The assembled item appears in the output buffer as an `AAssembledItemActor` built from the resolved attach chains, each piece's mesh on its parent's named socket, tinted by its substance. ## Activities ``` [SALVAGED] One runtime, so that the forge's interactivity is not lost because the anvil learned to take two players, and so that five copied minigames cannot drift into five bug surfaces. A minigame overrides OnBegin, ApplyInput, Tick, EvaluateProgress and CollectFaults. It cannot invent its own participation model, save shape or way of producing quality; those are closed. ``` ```cpp UCLASS(BlueprintType) class UActivityDefinition : public UPrimaryDataAsset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) FGameplayTag ActivityTag; // Activity.Strike UPROPERTY(EditDefaultsOnly) TSubclassOf Rules; // UStrikeActivity, UHeatActivity UPROPERTY(EditDefaultsOnly) int32 MaxOperators = 1; // MinOperators is 1: serialised as a constant so it is visible UPROPERTY(EditDefaultsOnly) FVector2D QualityBand = FVector2D(0.85f, 1.15f); // guarantee 2: skill modulates this and nothing else UPROPERTY(EditDefaultsOnly) float BaselineSeconds = 6.f; // "steady work" duration at one operator UPROPERTY(EditDefaultsOnly) float BaselineScore = 0.4f; // what steady work scores: guarantee 1 UPROPERTY(EditDefaultsOnly) bool bAllowAutoPlay = true; // hold-to-work is a first-class setting UPROPERTY(EditDefaultsOnly) float AssistWindowScale = 1.f; // accessibility: widens timing tolerances, never gated }; USTRUCT() struct FActivityResult // the ONE funnel; nothing else may produce quality { GENERATED_BODY() float QualityContribution = 0.f; // 0..1, mapped through QualityBand by the consumer float TimeSpent = 0.f; TMap ContributionByPlayer; // attribution: never clawed back, feeds telemetry and later XP FGameplayTagContainer Faults; // Fault.Overheated, Fault.Misstruck }; /** UWorldSubsystem, server. Owns every session; the station owns none of this. */ UCLASS() class UActivityRuntime : public UTickableWorldSubsystem { GENERATED_BODY() public: FGuid ServerBeginActivity(APlayerState* Instigator, AStationActor* Host, const UActivityDefinition& Def, const FActivityPayload& Payload); bool ServerJoinActivity(APlayerState* Who, FGuid Session); // additive, always optional; ActivityFull when at max void ServerLeaveActivity(APlayerState* Who, FGuid Session); // keeps their contribution; last one out pauses void ServerActivityInput(APlayerState* Who, FGuid Session, const FActivityInput& Input); // THE per-frame path // validates participant and reach (with a short leash and a visible warning before dropping out), // delta = Rules->ApplyInput(session, who, input); accumulates and attributes // Tick: Rules->Tick per running session; steady-work accrual when auto-play is engaged; progress advances at // the operators' summed WorkSpeed and the quality floor is raised by their best WorkQuality (both attributes on // the stat block, Stats.md); complete at progress 1: // result -> Host->DeliverResult(result, payload) -> the substance or piece is made; emit activity_completed. }; ``` **The anvil: the growing zone.** Not a rhythm game; a reaction-and-placement game scored as a percentage. The heated piece exposes 3 to 6 hot spots (authored per piece type, in the piece's own space so the data survives an art swap). Each round one becomes the active zone, a circle that starts small and grows; the player moves the hammer over it and strikes. Inside the zone, score is `InverseLerp(MaxRadius, MinRadius, radiusAtHit)`: tiny is excellent, huge is poor. Outside is `Fault.Misstruck` and the zone keeps growing. Never struck, the round expires at zero. No input at all, each round auto-resolves at `BaselineScore`: completion is never gated on reaction. After the authored round count, `QualityContribution` is the mean. **Temperature gates the whole thing:** below the substance's working band the metal resists, growth speeds up and scores are capped. Go back to the forge. That one rule is the loop between the two stations. Two strikers alternate the active zone; each hit is attributed. Because the zone's radius is a deterministic function of time, a strike is lag-compensated by arithmetic, not history: the client stamps its input, the server evaluates `radius(clientTime)` directly, clamped to a 250 ms window and never scoring better than the best achievable at the earliest legitimate arrival. Two strikers resolve in timestamp order. See [Networking.md](Networking.md), tier three; it is the only timing-scored input in the game. **The forge: heat in a bed.** A coal bed is a fixed 5×5 grid of cells with temperature and fuel. On the server at 10 Hz: fuel burns into temperature, temperature conducts to four neighbours (never all pairs), items in the bed sample the cells they overlap and move toward that temperature at their own conductivity over thermal mass, and an item over its substance's burn point takes `Fault.Overheated`. Inputs: place and move items, rake coals, add fuel, work the bellows (an airflow spike, the cleanest second role in the game: pure help, zero risk). An item is done when it has held its working band for the hold time; quality is time inside the band against time outside. The bed holds many items at once, which is the only batching there is. Heat runs at **two levels of detail**: the full grid while a player is within the sim radius, one lumped temperature and fuel pool ticked at 1 Hz when nobody is, with the spatial pattern stored across the collapse so the bed you walk back to is the bed you left. The binding rule: far-mode outcomes must match near-mode outcomes on average over the same wall-clock time, or walking away becomes an exploit. The comparison harness is thirty lines and is written with the first heat prototype, not after. Heat is scoped to a bed; an object outside any bed cools on a curve and stops simulating at ambient. Replication: 25 quantised bytes at 5 Hz to near clients, one byte to far ones, coal glow and metal colour driven locally from the values. **Discovery.** Working metal turns up shapes nobody told you about: a run of excellent strikes on an unusual hot-spot pattern, an odd substance at an odd temperature, a fault that turned out well. `ServerDiscoverCharacteristic` adds the definition to the player's known set (per player, on the player state, so it travels with them), fires `OnCharacteristicDiscovered` as a moment (the piece named and glowing on the anvil, a line in the log, never a screen-centre banner), and from then on the characteristic is an ordinary choice at the station. The glyph discovery the earlier project planned for enchanting is the same shape and should generalise this rather than grow beside it; for these steps enchantments are a fixed authored set. **Scrapping** runs no activity: a plain interaction returning `ScrapReturnFraction` of the constituent objects, rounded down, quality scaled, marked `Mark.Salvaged`. A frictionless sink for failed work. ## Enchanting Applied to a piece, before or after assembly, into addon sockets bounded by the piece type. Both filters must agree: the enchantment's `ApplicableToSubstances` and the substance's `ArcaneAffinity` meet in the middle, so a new substance declares what it accepts rather than every enchantment being edited. Enchanting an assembled item routes to the same call per target piece; there is no second code path. ```cpp // UCraftingSubsystem bool ServerApplyEnchantment(APlayerState* Instigator, FGuid PieceId, const UEnchantmentDefinition& Ench); // Validate: at a Domain.Enchant station; UsedSockets + SocketCost <= Type->AddonSockets else NoFreeSocket; // affinity both ways else SubstanceRejectsEnchant. Add; recompute derived; OnPieceEnchanted; emit piece_enchanted. ``` ## Server flows ```cpp // UCraftingSubsystem (world, server). Every entry validates the instigator, re-runs the pure verdict, mutates // through the registry, then notifies. In solo exactly as in co-op. bool ServerProcessSubstance(APlayerState* Instigator, AStationActor* Station, TArray Inputs); // all inputs one type and Raw else AlreadyProcessed; station domain matches ProcessDomainTag else WrongDomain; // count >= ProcessInputCount else NotEnough. Begin the heat activity with the inputs as payload. // On result: consume inputs; emit ProcessOutputCount objects of ProcessesInto with Quality from the result, // Origin Crafted(instigator), Mark.Overheated if the fault is present. OnSubstanceProcessed. bool ServerShapePiece(APlayerState* Instigator, AStationActor* Station, const UPieceTypeDefinition& Type, const UCharacteristicDefinition& Char, TArray Objects); // station domain == Type->DomainTag; Char applicable to Type->PartTag; Char known to instigator; // Objects.Num() >= Type->SubstanceObjects, one substance, all at working temperature (the anvil reads the bed). // Begin the strike activity. On result: new FPieceInstance with SubstanceQuality = avg, SourceObjectIds, // CraftQuality = result.QualityContribution; consume objects; output buffer; OnPieceShaped. bool ServerAssembleItem(APlayerState* Instigator, AAssemblyBench* Bench, const UItemFamilyDefinition& Family); // verdict = ValidateAssembly(Family, Bench->Staged, Config); if invalid reject(verdict.Reason) even if the // client's preview said otherwise. New FAssembledItem; Durability = MaxDurability; consume pieces (they cease // to exist as objects); spawn the item actor into the output buffer; OnItemAssembled; emit item_assembled. ``` ## From item to weapon ``` [PROPOSED] A crafted item of a weapon family IS a weapon. Nothing about the melee ability, the damage funnel or the action bar knows whether the sword in hand was authored or crafted; both are an FWeaponProfile. ``` ```cpp USTRUCT() struct FWeaponContribution // on a piece type and on a characteristic: what this part adds { GENERATED_BODY() UPROPERTY(EditDefaultsOnly) float Damage = 0.f, ReachCm = 0.f, WindupSeconds = 0.f, SwingSeconds = 0.f; UPROPERTY(EditDefaultsOnly) float DamageMultiplier = 1.f; // characteristics multiply; piece types add }; // CraftingRules::MakeWeaponProfile, pure: // Damage = (Σ piece Type->Weapon.Damage) * Π characteristic DamageMultiplier // + QualityScore * Config.DamagePerQualityPoint // coherence is what makes it hit harder // ReachCm = Σ Type->Weapon.ReachCm (the blade supplies most of it), scaled by the blade piece's authored scale // Windup, Swing = Σ contributions, clamped to sane bounds // DamageType = the highest-value enchantment's DamageType if set, else Damage.Type.Physical // GrantedTags = ∪ enchantment GrantsTags // the funnel and future rules read these // bTwoHanded = the family says so, or the blade piece's scale is past its range's midpoint // Montage = Family.Montage; WeaponTag = Family.FamilyTag ``` Equipping: an `AAssembledItemActor` whose family is a weapon offers `Interact.Verb.Equip` when it is on the ground or in your hand; the server builds an `AWeaponActor` from the profile, attaches it to the family's hand socket, applies every piece's enchantment `HolderEffect` to the wielder for as long as it is held, and drops the previously equipped weapon where you stand. One weapon equipped, whatever you carry. The combat step that proves this is step 13: a crafted sword's coherence score visibly changes the damage number on a dummy. ## Persistence shape Nothing persists in these steps; the shapes are written down now because the data-contract rules are cheap on the first day and expensive later. ``` FSubstanceRecord { FGuid Id; FPrimaryAssetId Type; float Quality; FProvenance Origin; TArray Marks; } FPieceRecord { FGuid Id; FPrimaryAssetId Type, Characteristic, Substance; float SubstanceQuality, CraftQuality; TArray SourceObjectIds; TArray Enchantments; } // no Value, no Durability FItemRecord { FGuid Id; FPrimaryAssetId Family; int32 Durability; TMap Parts; } // current durability only - Content by primary asset id, tags by name. Derived fields recomputed on load, so a rebalanced config re-scores old items. - Pieces recorded once and referenced by id from items, so a partial write can never duplicate one. - Each record carries a schema_version and an adjacent migration function. ``` ## Networking | State | Authority | Mechanism | | --- | --- | --- | | Substance and piece objects | Server | Replicated actors, dormant at rest, relevant by distance | | Station state, operators, buffers | Server | Replicated properties on the station | | Bench staging and the preview | Client previews, server verdicts | Same pure function on both; the client's preview is a question, never a claim | | Activity sessions and inputs | Server | Inputs are server RPCs; the strike is timestamped | | Heat grid | Server | 25 bytes at 5 Hz near, 1 byte far | | Assembled items | Server | Replicated actor; the item struct replicated to the owner for the panel | | Known characteristics | Server | On the player state, replicated to the owner | ## Telemetry | Event | When | Payload | | --- | --- | --- | | `substance_processed` | Smelt completes | `type`, `count`, `quality_avg`, `faults` | | `piece_shaped` | Strike completes | `type`, `characteristic`, `substance`, `craft_quality`, `rounds`, `round_scores[]` | | `piece_enchanted` | Enchant applied | `piece_type`, `enchantment` | | `item_assembled` | Commit succeeds | `family`, `piece_ids[]`, `name`, `quality`, `synergy`, `substance_value`, `time_at_bench_s` | | `assembly_rejected` | Commit or insert refused | `family`, `reason`, `part` | | `activity_completed` | Any activity | `activity`, `operators`, `quality`, `auto_play`, `faults`, `contribution_by_player` | | `characteristic_discovered` | The moment | `characteristic`, `cause` | | `weapon_equipped` | Equip | `family`, `crafted` (bool), `damage`, `quality` | `assembly_rejected` by reason is the friction signal on the crafting UX before anyone says "I didn't get it"; `round_scores[]` against `auto_play` says whether the anvil is fun or a chore. ## Tests - Automation, every rejection reason named: `CanFillSlot` for the four reasons; `ValidateAssembly` for missing, foreign and unreachable parts and for the guardless sword validating; `ComposeName` for the reference example, no prefix, no suffix, competing substances suppressing the core, the word limit and the stem collision; `ScoreCoherence` for synergy rising with matching themes and for completeness making optional parts worth it; `PartDurability` and `MaxDurability` for the dragonbone-hilt-oak-blade case; `MakeWeaponProfile` for a crafted sword landing inside the authored sword's numbers at mid quality; the heat LOD equivalence harness. - Functional: `FT_Crafting_Bench` spawns three pieces, inserts, commits and asserts the name and score; two clients see the same assembled mesh with the blade on the handle's socket; `FT_Crafting_Anvil` with no input completes at `BaselineScore`. ## Open questions - **Q1. Is the known-characteristic set per player or per world?** Per player, on the player state: it travels with them and gives a visiting veteran something real to bring. Cheap to move if wrong. - **Q2. Two strikers: alternating or simultaneous?** Alternating first; it is the one that still works when the two players have very different reaction speeds. - **Q3. Hot-spot count and growth curve.** 3 to 6 spots and the radius numbers are authored per piece type and the feel lives entirely there. A greybox pass, not a decision. - **Q4. Where do pieces and items live beyond hands and station buffers?** Nowhere yet. Storage as a place (containers with slots, no auto-pull, no infinite chest) is the earlier design and is parked in Ideas until a step needs it. The two-hand rule and the object model already assume it. - **Q5. Alloys.** A kiln combining two raw types into one processed type wants `ProcessesInto` to become a small recipe struct. Not now, and **nobody hard-codes 1:1 processing** in the meantime. - **Q6. Does substance quality want a visible tell?** A dull versus bright ingot at a glance would make the smith's care legible across a room. Cheap (a tint lerp), competes with substance identity colour. An art call.