From 8d93b937bc6b489e56ee2d4a21268257d4eca4cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 12 Sep 2026 01:12:12 +0200 Subject: [PATCH] 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 --- .../ServerShared/CultCache/DatabaseEntry.cs | 3 +- Assets/Scripts/ServerShared/Entity.cs | 4 +- Assets/Scripts/ServerShared/Extensions.cs | 9 ++ Assets/Scripts/ServerShared/FactionProduct.cs | 54 +++++++ .../ServerShared/FactionProduct.cs.meta | 2 + Assets/Scripts/ServerShared/ItemData.cs | 22 ++- Assets/Scripts/ServerShared/ItemInstance.cs | 48 ++++-- Assets/Scripts/ServerShared/ItemManager.cs | 29 +++- .../Scripts/ServerShared/LoadoutGenerator.cs | 141 ++++++++++-------- docs/three-gates-scope.md | 18 ++- tools/AetherDb/scripts/migrate-products.cs | 40 +++++ 11 files changed, 275 insertions(+), 95 deletions(-) create mode 100644 Assets/Scripts/ServerShared/FactionProduct.cs create mode 100644 Assets/Scripts/ServerShared/FactionProduct.cs.meta create mode 100644 tools/AetherDb/scripts/migrate-products.cs diff --git a/Assets/Scripts/ServerShared/CultCache/DatabaseEntry.cs b/Assets/Scripts/ServerShared/CultCache/DatabaseEntry.cs index dcbdbb5be..a9fad3a70 100644 --- a/Assets/Scripts/ServerShared/CultCache/DatabaseEntry.cs +++ b/Assets/Scripts/ServerShared/CultCache/DatabaseEntry.cs @@ -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)] diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 80ca8732a..6fdad39bb 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -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; diff --git a/Assets/Scripts/ServerShared/Extensions.cs b/Assets/Scripts/ServerShared/Extensions.cs index 45bf223d7..4cbfdcf0a 100644 --- a/Assets/Scripts/ServerShared/Extensions.cs +++ b/Assets/Scripts/ServerShared/Extensions.cs @@ -117,6 +117,15 @@ public static class Extensions // public static T RandomElement(this IEnumerable 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; diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs b/Assets/Scripts/ServerShared/FactionProduct.cs new file mode 100644 index 000000000..335045452 --- /dev/null +++ b/Assets/Scripts/ServerShared/FactionProduct.cs @@ -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 Roles = new List(); + + [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; +} diff --git a/Assets/Scripts/ServerShared/FactionProduct.cs.meta b/Assets/Scripts/ServerShared/FactionProduct.cs.meta new file mode 100644 index 000000000..56ff9c3ad --- /dev/null +++ b/Assets/Scripts/ServerShared/FactionProduct.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9946bad1a83d3c8428d0d53cce21572f \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/ItemData.cs b/Assets/Scripts/ServerShared/ItemData.cs index 5425cf100..7b0cf4725 100644 --- a/Assets/Scripts/ServerShared/ItemData.cs +++ b/Assets/Scripts/ServerShared/ItemData.cs @@ -319,8 +319,19 @@ public class SimpleCommodityData : ItemData [MessagePackObject, JsonObject(MemberSerialization.OptIn), JsonConverter(typeof(JsonKnownTypesConverter))] 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 Roles = new List(); +} + +// 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> _scaleModifiers; [IgnoreMember] private Dictionary> _constantModifiers; diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index d8c7e3427..3de52a20b 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -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))] 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))] 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 Ingredients = new List(); - - //[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 Ingredients = new List(); + + // 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 { -} \ No newline at end of file +} diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index e0f78875b..1e71f4221 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -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(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]; diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 2f446b165..850a2b767 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -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 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(2, item => item.Shape.FitsWithin(emptyShape, out _, out _), required: true); + var (dockingBayProduct, dockingBayData) = RandomProduct(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 inventory = RandomItems(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(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 hullFilter = null) + public (FactionProductData product, HullData design) RandomHull(HullType type, Predicate hullFilter = null) { - return RandomItem(0, item => + return RandomProduct(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(int count, float sizeExponent, Predicate 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(int count, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { - var candidates = ItemManager.ItemData.GetAll() - .Where(item => - item.Price > 0 && - item.Manufacturer != Guid.Empty && - (filter?.Invoke(item) ?? true)) + var candidates = ItemManager.ItemData.GetAll() + .Select(product => (product, design: ItemManager.ItemData.Get(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(float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData + public (FactionProductData product, T design) RandomProduct(float sizeExponent, Predicate 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(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData + public (FactionProductData product, T design) RandomProduct(HardpointData hardpoint, float sizeExponent, Predicate filter = null, bool required = false) where T : EquippableItemData { - return RandomItem(sizeExponent, item => item.HardpointType == hardpoint.Type && + return RandomProduct(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(); + 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(hardpoint, 2, + var (controllerProduct, controllerData) = RandomProduct(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(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(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(3, item => + var (cargoProduct, cargoData) = RandomProduct(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(2, item => + var (capacitorProduct, capacitorData) = RandomProduct(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(); + } } public class InvalidLoadoutException : Exception @@ -269,4 +278,4 @@ public class InvalidLoadoutException : Exception : base(message, inner) { } -} \ No newline at end of file +} diff --git a/docs/three-gates-scope.md b/docs/three-gates-scope.md index 492a09c95..f7abbf3f8 100644 --- a/docs/three-gates-scope.md +++ b/docs/three-gates-scope.md @@ -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 diff --git a/tools/AetherDb/scripts/migrate-products.cs b/tools/AetherDb/scripts/migrate-products.cs new file mode 100644 index 000000000..75938dee0 --- /dev/null +++ b/tools/AetherDb/scripts/migrate-products.cs @@ -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().ToList(); +var designs = db.Cache.GetAll() + .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(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()).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.");