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.");