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
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
# Crafting
|
||||
|
||||
Owns what an item is made of and how it comes to exist: families, parts, pieces and their three trait layers; the
|
||||
substances pieces are shaped from and the pipeline that refines them; the pure rules for legality, naming and
|
||||
quality; stations and the one activity runtime their hands-on work runs on; enchanting; and the seam through which
|
||||
a crafted weapon becomes a weapon you fight with. It does **not** own how a piece is picked up or inserted
|
||||
([Interaction.md](Interaction.md)), what happens when the weapon lands ([Combat.md](Combat.md)), or any economy,
|
||||
customer or shop: none of those exist here, and the ideas are parked in [`../Ideas.md`](../Ideas.md).
|
||||
|
||||
Read [Architecture.md](Architecture.md) first. Steps 12 to 15 in [`../Steps.md`](../Steps.md). This is the
|
||||
largest spec because the earlier smithing project had already worked its crafting model through several review
|
||||
passes; what follows is that model, restated for Unreal and cut to what a fighting prototype needs.
|
||||
|
||||
## Decisions
|
||||
|
||||
```
|
||||
[DECIDED] Three words, three levels. A FAMILY declares PARTS. A PIECE is a crafted object filling one part. A
|
||||
piece carries three TRAIT layers: a characteristic (its shape), a substance (what it is made of) and zero or more
|
||||
enchantments (its ornament). An item is a family's parts, filled.
|
||||
|
||||
Blade: mithril, blunt · Guard: mithril, pointy · Handle: dragonhide, of embers · Pommel: adamantine
|
||||
-> "Blunt Mithril Sword of Embers". The player made that; they did not pick it from a list.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The design word "material" is "substance" in this project's code and docs. UMaterial is the engine's
|
||||
render material and the collision would be permanent. Substance is the word the earlier design used in its own
|
||||
subtitle, so nothing of meaning is lost.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] No recipes, anywhere. A family lists slots and filters. Any slot-complete, filter-satisfying set of
|
||||
pieces is a legal item. If a combination needs a table to be legal, the filters are wrong.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Substances are objects, not stacks. Twelve iron ingots are twelve actors, each with its own identity
|
||||
and quality. Quality variance is then free, provenance is free, and "the sword made from that dragon's bone" is a
|
||||
queryable fact. Affordable only because storage is physical and caps the count: any future feature that lets
|
||||
substance accumulate without occupying space breaks the assumption and is refused on that ground.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Discovery unlocks, choice applies. You discover characteristics through play; you choose them at the
|
||||
station. Strike quality decides how well a shape came out, never which shape you got, so a crafted item is always a
|
||||
deliberate act and a request for "a serrated blade" is always fillable.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] One activity runtime, three guarantees. Every hands-on station task runs on one runtime that owns
|
||||
participation, authority, progress, pause, quality accumulation and telemetry; a minigame implements only its own
|
||||
rules. The guarantees, binding forever: completion is never gated on skill; skill modulates a quality band and
|
||||
nothing else; a second pair of hands is always additive and never required.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] The assembly bench is domain-neutral. Every pipeline converges there; gating it would make one player a
|
||||
bottleneck. Domains gate the WORK (a station refuses a piece of the wrong domain) and never the WORKER (anyone may
|
||||
use any station). Class changes speed and quality only.
|
||||
```
|
||||
|
||||
```
|
||||
[DECIDED] Durability is a part property summed into an item total, and the item is lost at zero. Nothing in the
|
||||
shop wears out from crafting; wear is something the world does to gear later.
|
||||
```
|
||||
|
||||
## Vocabulary and tags
|
||||
|
||||
| Term | Meaning | Tag namespace |
|
||||
| --- | --- | --- |
|
||||
| Family | A kind of item and the schema of its parts, as a tree | `Item.Family.Sword`, `.Axe`, `.Bow`, `.Shield` |
|
||||
| Part | A position in an item | `Item.Part.Blade`, `.Guard`, `.Handle`, `.Pommel` |
|
||||
| Piece type | What a piece is, before its traits | `Item.PieceType.Blade`, `.Guard`, `.Handle`, `.Pommel` |
|
||||
| Piece | A crafted object filling a part; has an `FGuid` | runtime, `FPieceInstance` |
|
||||
| Trait layer | Characteristic, substance, enchantment | `Piece.Layer.*` |
|
||||
| Characteristic | Shape: serrated, blunt, curved, heavy | `Item.Characteristic.*` |
|
||||
| Substance | What a piece is made of | `Item.Substance.Iron`, `.Oak`, `.Mithril` ... |
|
||||
| Enchantment | Ornament: of embers, of frost | `Item.Enchant.Fire.Penetration` ... |
|
||||
| Substance object | One physical ingot, log, hide; has an `FGuid` | runtime, `FSubstanceInstance` |
|
||||
| Domain | Which station family works a thing | `Domain.Forge`, `.Wood`, `.Enchant` |
|
||||
| Theme | Synergy vocabulary shared by characteristics and enchantments | `Theme.Aggressive`, `.Defensive`, `.Fire` ... |
|
||||
| Coherence | An item's quality score: piece values plus synergy | runtime |
|
||||
|
||||
Other namespaces this spec owns: `Substance.Class.[Metal|Wood|Hide|Bone|Arcane|Fuel]`,
|
||||
`Substance.Rarity.[Common|Uncommon|Rare|Unique]`, `Substance.State.[Raw|Processed]`,
|
||||
`Station.Type.*`, `Station.State.[Idle|Occupied|Working|Blocked|Paused]`, `Activity.[Heat|Strike|Assemble]`,
|
||||
`Activity.State.*`, `Fault.*`, `Mark.*`, and `Craft.Reason.*` for every rejection.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
Source/<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.
|
||||
Reference in New Issue
Block a user