Author manufacturer variation as faction products

A design lists role names; a faction product is a manufacturer's branded
build of one, carrying its name, its flavor text, and a pseudo-gaussian
quality distribution per role whose mean is the technology put in and
whose deviation is their quality control. A PerformanceStat may name the
role whose quality it reads, unset reading the item own workmanship as
before, so old saves behave unchanged. Generation selects products rather
than designs, which makes what a faction makes the availability rule.

This is Njordr shape at one dimension: role names, per-role derivation
and per-product distributions carry over when quality widens into
dimensions. No parts exist yet; the product authors the role quality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-12 01:12:12 +02:00
commit 8d93b937bc
11 changed files with 275 additions and 95 deletions

View file

@ -50,7 +50,8 @@ public interface INamedEntry
Union(28, typeof(PlanetData)),
Union(29, typeof(CargoBayData)),
Union(30, typeof(DockingBayData)),
Union(31, typeof(WeaponItemData))]
Union(31, typeof(WeaponItemData)),
Union(32, typeof(FactionProductData))]
public abstract class DatabaseEntry
{
[JsonProperty("id"), Key(0)]

View file

@ -1079,7 +1079,7 @@ public class ConsumableItemEffect
public float Evaluate(PerformanceStat stat)
{
var effectiveness = Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration);
var quality = pow(Item.Quality, stat.QualityExponent);
var quality = pow(Item.QualityForRole(stat.FromRole), stat.QualityExponent);
var result = lerp(stat.Min, stat.Max, effectiveness * quality);
@ -1255,7 +1255,7 @@ public class EquippedItem
{
var heat = pow(ThermalPerformance, ThermalExponent * stat.HeatExponentMultiplier);
var durability = pow(DurabilityPerformance, DurabilityExponent * stat.DurabilityExponentMultiplier);
var quality = pow(EquippableItem.Quality, stat.QualityExponent);
var quality = pow(EquippableItem.QualityForRole(stat.FromRole), stat.QualityExponent);
var scaleModifier = 1.0f;
var scaleModifiers = stat.GetScaleModifiers(Entity).Values;

View file

@ -117,6 +117,15 @@ public static class Extensions
// public static T RandomElement<T>(this IEnumerable<T> enumerable) => enumerable.ElementAt(Random.NextInt(0, enumerable.Count()));
public static float NextPowerDistribution(this ref Random random, float min, float max, float exp, float randexp) =>
pow((pow(max, exp + 1) - pow(min, exp + 1)) * pow(random.NextFloat(), randexp) + pow(min, exp + 1), 1 / (exp + 1));
// Box-Muller: two uniforms become a normal deviate. Used for part quality, where a manufacturer's mean is
// its technology in a role and its deviation is quality control. Callers clamp to their own valid range.
public static float NextGaussian(this ref Random random, float mean, float deviation)
{
var u1 = max(random.NextFloat(), 1e-6f);
var u2 = random.NextFloat();
return mean + deviation * sqrt(-2 * log(u1)) * cos(2 * PI * u2);
}
public static float NextUnbounded(this ref Random random) => 1 / (1 - random.NextFloat()) - 1;
public static float NextUnbounded(this ref Random random, float bias, float power, float ceiling) => 1 / (1 - pow(min(random.NextFloat(), ceiling), 1 - pow(clamp(bias,0,.99f), 1 / power))) - 1;

View file

@ -0,0 +1,54 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using MessagePack;
using Newtonsoft.Json;
// A manufacturer's branded variant of a generic design: its name, its flavor text, and how it builds each of
// the design's roles. Which products exist is what a faction makes, so market segmentation and regional
// progression are authored here rather than as duplicate item entries.
[DatabaseCategory("Items"), Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class FactionProductData : DatabaseEntry, INamedEntry
{
[Inspectable, JsonProperty("name"), Key(1)]
public string Name;
[InspectableText, JsonProperty("description"), Key(2)]
public string Description;
[InspectableDatabaseLink(typeof(CraftedItemData)), JsonProperty("design"), Key(3)]
public Guid Design;
[InspectableDatabaseLink(typeof(Faction)), JsonProperty("manufacturer"), Key(4)]
public Guid Manufacturer;
[Inspectable, JsonProperty("roles"), Key(5)]
public List<ProductRole> Roles = new List<ProductRole>();
[IgnoreMember]
public string EntryName
{
get => Name;
set => Name = value;
}
}
// How good this product's part is in one of its design's roles: a pseudo-gaussian distribution whose mean is
// the technology the manufacturer puts in and whose standard deviation is their quality control. A market
// segment is a second product with a role pumped up; what makes that part better is the flavor text's to tell,
// because a part here carries quality and nothing else.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ProductRole
{
[Inspectable, JsonProperty("role"), Key(0)]
public string Role;
[Inspectable, JsonProperty("mean"), Key(1)]
public float Mean = .5f;
[Inspectable, JsonProperty("standardDeviation"), Key(2)]
public float StandardDeviation = .15f;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9946bad1a83d3c8428d0d53cce21572f

View file

@ -319,8 +319,19 @@ public class SimpleCommodityData : ItemData
[MessagePackObject, JsonObject(MemberSerialization.OptIn), JsonConverter(typeof(JsonKnownTypesConverter<CraftedItemData>))]
public abstract class CraftedItemData : ItemData
{
// [Inspectable, JsonProperty("ingredientQualityWeight"), Key(9)]
// public float IngredientQualityWeight = .5f;
// The named slots this design is assembled from. A stat may read the quality of the part filling one.
[Inspectable, JsonProperty("roles"), Key(9)]
public List<ItemRole> Roles = new List<ItemRole>();
}
// One slot in a design: every laser has a focusing array. A role is a name and nothing else. What fills it is
// a quality, authored per role by each manufacturer's product; the part itself earns a record when crafting
// needs one to exist.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ItemRole
{
[Inspectable, JsonProperty("name"), Key(0)]
public string Name;
}
[DatabaseCategory("Items"), Inspectable, MessagePackObject]
@ -584,9 +595,10 @@ public class PerformanceStat
[JsonProperty("qualityExponent"), Key(4)]
public float QualityExponent;
//[JsonProperty("id"), Key(5)] public Guid ID = Guid.NewGuid();
// [JsonProperty("ingredient"), Key(5)] public Guid? Ingredient;
// The design role whose part quality this stat reads; unset reads the item's own workmanship quality.
// Njordr states the same rule as a derivation over dimensions; this is that rule with one dimension.
[Inspectable, JsonProperty("fromRole"), Key(5)]
public string FromRole;
[IgnoreMember] private Dictionary<Entity,Dictionary<Behavior,float>> _scaleModifiers;
[IgnoreMember] private Dictionary<Entity,Dictionary<Behavior,float>> _constantModifiers;

View file

@ -1,4 +1,4 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
@ -15,11 +15,11 @@ using Newtonsoft.Json;
using Unity.Mathematics;
using static Unity.Mathematics.math;
[Union(0, typeof(SimpleCommodity)),
Union(1, typeof(CompoundCommodity)),
[Union(0, typeof(SimpleCommodity)),
Union(1, typeof(CompoundCommodity)),
Union(2, typeof(EquippableItem)),
Union(3, typeof(ConsumableItem)),
JsonObject(MemberSerialization.OptIn),
JsonObject(MemberSerialization.OptIn),
JsonConverter(typeof(JsonKnownTypesConverter<ItemInstance>))]
public abstract class ItemInstance
{
@ -27,22 +27,40 @@ public abstract class ItemInstance
[JsonProperty("rotation"), Key(1)] public ItemRotation Rotation;
}
[Union(0, typeof(CompoundCommodity)),
Union(1, typeof(EquippableItem)),
Union(2, typeof(ConsumableItem)),
[Union(0, typeof(CompoundCommodity)),
Union(1, typeof(EquippableItem)),
Union(2, typeof(ConsumableItem)),
JsonObject(MemberSerialization.OptIn),
JsonConverter(typeof(JsonKnownTypesConverter<CraftedItemInstance>))]
public abstract class CraftedItemInstance : ItemInstance
{
// The workmanship of this particular unit, as distinct from the parts that went into it.
[JsonProperty("quality"), Key(2)] public float Quality;
//[JsonProperty("ingredients"), Key(3)] public List<ItemInstance> Ingredients = new List<ItemInstance>();
//[JsonProperty("blueprint"), Key(4)] public Guid Blueprint;
//[JsonProperty("name"), Key(3)] public string Name;
//[JsonProperty("sourceEntity"), Key(4)] public Guid SourceEntity;
// The part filling each of the design's roles, one level deep. Empty on items made before roles existed,
// and on parts themselves; stats then read Quality as they always did.
[JsonProperty("ingredients"), Key(3)] public List<RoleFill> Ingredients = new List<RoleFill>();
// The manufacturer's branded product this was built as, naming it and carrying its flavor text.
[JsonProperty("product"), Key(4)] public Guid Product;
// The quality a stat reads for one of the design's roles. An unnamed role, a design without roles, and an
// item built before roles existed all fall back to this item's own workmanship.
public float QualityForRole(string role)
{
if (string.IsNullOrEmpty(role) || Ingredients == null) return Quality;
foreach (var fill in Ingredients)
if (fill.Role == role) return fill.Quality;
return Quality;
}
}
// One filled slot: the design's role name and how good the part that went into it turned out to be.
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class RoleFill
{
[JsonProperty("role"), Key(0)] public string Role;
[JsonProperty("quality"), Key(1)] public float Quality;
}
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
@ -64,4 +82,4 @@ public class EquippableItem : CraftedItemInstance
[MessagePackObject, JsonObject(MemberSerialization.OptIn)]
public class ConsumableItem : CraftedItemInstance
{
}
}

View file

@ -99,7 +99,7 @@ public class ItemManager
public float Evaluate(PerformanceStat stat, EquippableItem item)
{
var data = GetData(item);
var quality = pow(item.Quality, stat.QualityExponent);
var quality = pow(item.QualityForRole(stat.FromRole), stat.QualityExponent);
var durabilityExponent = lerp(
GameplaySettings.DurabilityQualityMin,
GameplaySettings.DurabilityQualityMax,
@ -190,6 +190,33 @@ public class ItemManager
return CreateInstance(item, tier.Quality);
}
// Builds one of a manufacturer's branded products: the unit's own workmanship rolled as before, and each of
// the design's roles filled with a part whose quality is drawn from that manufacturer's distribution for it.
public CraftedItemInstance CreateInstance(FactionProductData product)
{
var design = ItemData.Get<CraftedItemData>(product.Design);
if (design == null)
{
_logger($"Product {product.Name} names a design that does not exist!");
return null;
}
var instance = CreateInstance(design);
instance.Product = product.ID;
if (design.Roles == null) return instance;
foreach (var role in design.Roles)
{
var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole();
instance.Ingredients.Add(new RoleFill
{
Role = role.Name,
Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1)
});
}
return instance;
}
public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item)
{
var tier = GameplaySettings.Tiers[0];

View file

@ -16,11 +16,11 @@ public class LoadoutGenerator
public float PriceExponent { get; }
public LoadoutGenerator(
ref Random random,
ItemManager itemManager,
Galaxy galaxy,
GalaxyZone zone,
Faction faction,
ref Random random,
ItemManager itemManager,
Galaxy galaxy,
GalaxyZone zone,
Faction faction,
float priceExponent)
{
Random = random;
@ -30,16 +30,16 @@ public class LoadoutGenerator
Faction = faction;
PriceExponent = priceExponent;
}
public EntityPack GenerateShipLoadout(Predicate<HullData> hullFilter = null)
{
var hullData = RandomHull(HullType.Ship, hullFilter);
var (hullProduct, hullData) = RandomHull(HullType.Ship, hullFilter);
if(hullData==null)
{
ItemManager.Log("Unable to generate ship loadout: no compatible hull found!");
return null;
}
var hull = ItemManager.CreateInstance(hullData) as EquippableItem;
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
if(hull==null)
ItemManager.Log("WHAT???");
var entity = new Ship(ItemManager, null, hull, ItemManager.GameplaySettings.DefaultEntitySettings);
@ -50,13 +50,13 @@ public class LoadoutGenerator
public OrbitalEntityPack GenerateTurretLoadout()
{
var hullData = RandomHull(HullType.Turret);
var (hullProduct, hullData) = RandomHull(HullType.Turret);
if(hullData==null)
{
ItemManager.Log("Unable to generate turret loadout: no compatible hull found!");
return null;
}
var hull = ItemManager.CreateInstance(hullData) as EquippableItem;
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
var entity = new OrbitalEntity(ItemManager, null, hull, Guid.Empty, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
OutfitEntity(entity);
@ -65,13 +65,13 @@ public class LoadoutGenerator
public OrbitalEntityPack GenerateStationLoadout()
{
var hullData = RandomHull(HullType.Station);
var (hullProduct, hullData) = RandomHull(HullType.Station);
if(hullData==null)
{
ItemManager.Log("Unable to generate station loadout: no compatible hull found!");
return null;
}
var hull = ItemManager.CreateInstance(hullData) as EquippableItem;
var hull = ItemManager.CreateInstance(hullProduct) as EquippableItem;
var entity = new OrbitalEntity(ItemManager, null, hull, Guid.Empty, ItemManager.GameplaySettings.DefaultEntitySettings);
entity.Faction = Faction;
@ -80,11 +80,11 @@ public class LoadoutGenerator
var emptyShape = entity.UnoccupiedSpace;
var dockingBayData = RandomItem<DockingBayData>(2, item => item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
var (dockingBayProduct, dockingBayData) = RandomProduct<DockingBayData>(2, item => item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
if (dockingBayData == null) throw new InvalidLoadoutException("No compatible docking bay found for station!");
dockingBayData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
var dockingBay = ItemManager.CreateInstance(dockingBayData) as EquippableItem;
var dockingBay = ItemManager.CreateInstance(dockingBayProduct) as EquippableItem;
dockingBay.Rotation = cargoRotation;
if (!entity.TryEquip(dockingBay, cargoPosition))
{
@ -94,61 +94,63 @@ public class LoadoutGenerator
FillInterior(entity);
var cargo = entity.CargoBays.First();
IEnumerable<EquippableItemData> inventory = RandomItems<EquippableItemData>(16, 1,
data => !(data is HullData hull && hull.HullType != HullType.Ship) && !(data is CargoBayData));
inventory = inventory
.OrderByDescending(item=>item.Shape.Coordinates.Length);
foreach (var item in inventory)
var inventory = RandomProducts<EquippableItemData>(16, 1,
data => !(data is HullData hull && hull.HullType != HullType.Ship) && !(data is CargoBayData))
.OrderByDescending(entry => entry.design.Shape.Coordinates.Length);
foreach (var entry in inventory)
{
var instance = ItemManager.CreateInstance(item);
var instance = ItemManager.CreateInstance(entry.product);
cargo.TryStore(instance);
}
entity.CanTow = hullData.CanTow;
return EntitySerializer.Pack(entity) as OrbitalEntityPack;
}
// Every entity needs a hull, so hulls are always required
public HullData RandomHull(HullType type, Predicate<HullData> hullFilter = null)
public (FactionProductData product, HullData design) RandomHull(HullType type, Predicate<HullData> hullFilter = null)
{
return RandomItem<HullData>(0, item =>
return RandomProduct<HullData>(0, item =>
(hullFilter?.Invoke(item) ?? true) &&
item.HullType == type, required: true);
}
// Items normally come only from manufacturers present in the galaxy and known to the zone's faction.
// A required item falls back to any manufacturer when none of those make one; that means the item
// database lacks variety, so it is logged rather than hidden.
public T[] RandomItems<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
// What a faction makes is which products it has, so generation selects branded products rather than designs.
// Products normally come only from manufacturers present in the galaxy and known to the zone's faction; a
// required item falls back to any manufacturer when none of those make one, which means the product table
// lacks variety, so it is logged rather than hidden.
public (FactionProductData product, T design)[] RandomProducts<T>(int count, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
var candidates = ItemManager.ItemData.GetAll<T>()
.Where(item =>
item.Price > 0 &&
item.Manufacturer != Guid.Empty &&
(filter?.Invoke(item) ?? true))
var candidates = ItemManager.ItemData.GetAll<FactionProductData>()
.Select(product => (product, design: ItemManager.ItemData.Get<CraftedItemData>(product.Design) as T))
.Where(entry =>
entry.design != null &&
entry.design.Price > 0 &&
entry.product.Manufacturer != Guid.Empty &&
(filter?.Invoke(entry.design) ?? true))
.ToArray();
var available = candidates.Where(IsAvailable).ToArray();
var available = candidates.Where(entry => IsAvailable(entry.product)).ToArray();
var preferManufacturers = true;
if (available.Length == 0 && required && candidates.Length > 0)
{
ItemManager.Log($"No {typeof(T).Name} available from manufacturers known to {Faction?.Name ?? "this galaxy"}; using any manufacturer. The item database needs more variety.");
ItemManager.Log($"No {typeof(T).Name} product available from manufacturers known to {Faction?.Name ?? "this galaxy"}; using any manufacturer. The product table needs more variety.");
available = candidates;
preferManufacturers = false;
}
return available.WeightedRandomElements(ref Random, item =>
(preferManufacturers ? ManufacturerPreference(item.Manufacturer) : 1) *
pow(item.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(item.Price, PriceExponent), // Penalize item price to a controllable degree
return available.WeightedRandomElements(ref Random, entry =>
(preferManufacturers ? ManufacturerPreference(entry.product.Manufacturer) : 1) *
pow(entry.design.Shape.Coordinates.Length, sizeExponent) / // Prioritize larger items
pow(entry.design.Price, PriceExponent), // Penalize item price to a controllable degree
count);
}
private bool IsAvailable(EquippableItemData item) =>
private bool IsAvailable(FactionProductData product) =>
Galaxy.IsPrelude ||
Galaxy.ContainsFaction(item.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(item.Manufacturer));
Galaxy.ContainsFaction(product.Manufacturer) && (Faction == null || Faction.Allegiance.ContainsKey(product.Manufacturer));
// Prioritize items from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
// Prioritize products from the zone faction and its allies, penalizing distance to the manufacturer's headquarters
private float ManufacturerPreference(Guid manufacturer)
{
if (Faction == null) return 1;
@ -159,14 +161,14 @@ public class LoadoutGenerator
return allegiance / (1 + distance);
}
public T RandomItem<T>(float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
public (FactionProductData product, T design) RandomProduct<T>(float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomItems(1, sizeExponent, filter, required).FirstOrDefault();
return RandomProducts(1, sizeExponent, filter, required).FirstOrDefault();
}
public T RandomItem<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
public (FactionProductData product, T design) RandomProduct<T>(HardpointData hardpoint, float sizeExponent, Predicate<T> filter = null, bool required = false) where T : EquippableItemData
{
return RandomItem<T>(sizeExponent, item => item.HardpointType == hardpoint.Type &&
return RandomProduct<T>(sizeExponent, item => item.HardpointType == hardpoint.Type &&
(filter?.Invoke(item) ?? true) &&
item.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _) &&
item.Shape.Coordinates.Length==hardpoint.Shape.Coordinates.Length, required);
@ -182,16 +184,16 @@ public class LoadoutGenerator
{
var hullData = ItemManager.GetData(entity.Hull) as HullData;
foreach (var v in hullData.Shape.Coordinates) entity.HullConductivity[v.x, v.y] = true;
var previousItems = new List<EquippableItemData>();
var previousProducts = new List<(FactionProductData product, GearData design)>();
foreach (var hardpoint in hullData.Hardpoints.OrderByDescending(h=>h.Shape.Coordinates.Length))
{
if (hardpoint.Type == HardpointType.ControlModule)
{
var controllerData = RandomItem<GearData>(hardpoint, 2,
var (controllerProduct, controllerData) = RandomProduct<GearData>(hardpoint, 2,
item => item.Behaviors.Any(b => entity is Ship && b is CockpitData || entity is OrbitalEntity && b is TurretControllerData), required: true);
if (controllerData == null)
if (controllerData == null)
throw new InvalidLoadoutException("No compatible controller found for entity!");
var controller = ItemManager.CreateInstance(controllerData) as EquippableItem;
var controller = ItemManager.CreateInstance(controllerProduct) as EquippableItem;
if (!entity.TryEquip(controller))
{
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
@ -199,24 +201,22 @@ public class LoadoutGenerator
}
else
{
// If a previously selected item fits, use that one (this is why we must process larger hardpoints first)
var itemData = previousItems
.FirstOrDefault(i => i.HardpointType == hardpoint.Type && i.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _));
var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == itemData);
itemData ??= RandomItem<GearData>(hardpoint, 2);
if (itemData == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
// If a previously selected product fits, use that one (this is why we must process larger hardpoints first)
var entry = previousProducts
.FirstOrDefault(e => e.design.HardpointType == hardpoint.Type && e.design.Shape.FitsWithin(hardpoint.Shape, hardpoint.Rotation, out _));
var previousItem = entity.Equipment.FirstOrDefault(item => item.Data == entry.design);
if (entry.design == null) entry = RandomProduct<GearData>(hardpoint, 2);
if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
else
{
//throw new InvalidLoadoutException($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!");
EquippableItem item;
if(previousItem!=null)
item = ItemManager.CreateInstance(itemData, previousItem.EquippableItem.Quality) as EquippableItem;
else item = ItemManager.CreateInstance(itemData) as EquippableItem;
var item = ItemManager.CreateInstance(entry.product) as EquippableItem;
// Matching hardpoints carry matching units: the same brand, workmanship, and parts
if (previousItem != null) CopyBuild(previousItem.EquippableItem, item);
if (!entity.TryEquip(item))
{
throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!");
}
previousItems.Add(itemData);
previousProducts.Add(entry);
}
}
}
@ -228,30 +228,39 @@ public class LoadoutGenerator
{
var emptyShape = entity.UnoccupiedSpace;
var cargoData = RandomItem<CargoBayData>(3, item =>
var (cargoProduct, cargoData) = RandomProduct<CargoBayData>(3, item =>
!(item is DockingBayData) &&
item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
if (cargoData == null) throw new InvalidLoadoutException("No compatible cargo bay found for entity!");
cargoData.Shape.FitsWithin(emptyShape, out var cargoRotation, out var cargoPosition);
var cargo = ItemManager.CreateInstance(cargoData) as EquippableItem;
var cargo = ItemManager.CreateInstance(cargoProduct) as EquippableItem;
cargo.Rotation = cargoRotation;
if (!entity.TryEquip(cargo, cargoPosition))
throw new InvalidLoadoutException("Failed to equip selected cargo bay!");
emptyShape = entity.UnoccupiedSpace;
var capacitorData = RandomItem<GearData>(2, item =>
var (capacitorProduct, capacitorData) = RandomProduct<GearData>(2, item =>
item.Behaviors.Any(b => b is CapacitorData) &&
item.Shape.FitsWithin(emptyShape, out _, out _), required: true);
if (capacitorData == null) throw new InvalidLoadoutException("No compatible capacitor found for entity!");
capacitorData.Shape.FitsWithin(emptyShape, out var capacitorRotation, out var capacitorPosition);
var capacitor = ItemManager.CreateInstance(capacitorData) as EquippableItem;
var capacitor = ItemManager.CreateInstance(capacitorProduct) as EquippableItem;
capacitor.Rotation = capacitorRotation;
if (!entity.TryEquip(capacitor, capacitorPosition))
throw new InvalidLoadoutException("Failed to equip selected capacitor!");
}
// A second unit off the same line: same workmanship and the same parts, not a fresh roll.
private static void CopyBuild(CraftedItemInstance from, CraftedItemInstance to)
{
to.Quality = from.Quality;
to.Ingredients = from.Ingredients?
.Select(fill => new RoleFill { Role = fill.Role, Quality = fill.Quality })
.ToList() ?? new List<RoleFill>();
}
}
public class InvalidLoadoutException : Exception
@ -269,4 +278,4 @@ public class InvalidLoadoutException : Exception
: base(message, inner)
{
}
}
}

View file

@ -118,11 +118,19 @@ What Aetheria authors now is the same shape, one dimension wide:
`Derivation { output, from_role, from_dimension }` with the dimension
pinned to quality, and it fixes the legacy model's mistake of naming an
ingredient item rather than a role.
- A crafted instance records the part filling each role, with continuous
quality. Parts are invisible to players until crafting exists.
- A faction's competence per role is its tech level: one number per role, not
an item per faction. Manufacturer variation emerges from the parts, so no
duplicate item entries are authored to create variety.
- A **faction product** is a manufacturer's branded build of a design: its
name, its flavor text, and a pseudo-gaussian quality distribution per role,
whose mean is the technology they put in and whose deviation is their
quality control. Which products exist is what a faction makes, so
availability, market segmentation, and regional progression are authored
there rather than as duplicate item entries.
- A market segment is a second product with a role's mean raised; what makes
that part better is the flavor text's to tell, because a part here carries
quality and nothing else. Nuanced trade-offs wait for Njordr's dimensions.
- A crafted instance records the rolled quality per role, continuous. No part
entry exists yet: the role's quality is authored by the product directly,
and a part earns a record when crafting needs one.
- Flavor text on products is most of the lore this release delivers.
- Tier, color, and price stay projections of quality.
Migration to Njörðr widens quality into dimensions and turns designs into

View file

@ -0,0 +1,40 @@
// Mints a faction product for every design that still carries a manufacturer, so branded variants exist for
// what the database already had. Copies the design's name and description as the brand's, and adds a default
// quality distribution row per role. Dry run unless passed "apply".
using System;
using System.Collections.Generic;
using System.Linq;
var db = AetherDb.Open();
var apply = args.Any(a => a == "apply");
var products = db.Cache.GetAll<FactionProductData>().ToList();
var designs = db.Cache.GetAll<CraftedItemData>()
.Where(d => d.Manufacturer != Guid.Empty)
.Where(d => !products.Any(p => p.Design == d.ID && p.Manufacturer == d.Manufacturer))
.ToArray();
Console.WriteLine($"{products.Count} products exist; {designs.Length} designs need one\n");
foreach (var design in designs)
{
var maker = db.Cache.Get<Faction>(design.Manufacturer);
var product = new FactionProductData
{
Name = design.Name,
Description = design.Description,
Design = design.ID,
Manufacturer = design.Manufacturer,
// A design authored before roles existed has none; its product gets rows when roles are added to it.
Roles = (design.Roles ?? new List<ItemRole>()).Select(r => new ProductRole { Role = r.Name }).ToList()
};
Console.WriteLine($" {product.Name,-28} by {maker?.ShortName ?? "(unknown)",-12} roles: " +
(product.Roles.Count == 0 ? "none" : string.Join(", ", product.Roles.Select(q => q.Role))));
if (apply) db.Cache.Add(product);
}
if (apply && designs.Length > 0)
{
db.Save();
Console.WriteLine($"\nSaved {designs.Length} products to AetherDB.msgpack");
}
else if (!apply)
Console.WriteLine("\nDry run. Pass \"apply\" to write these to the database.");