diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 6d0226dd7..8916d52b1 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -244,7 +244,7 @@ public class ActionGameManager : MonoBehaviour { var (game, zones) = RunSave.Capture(CultCache, CurrentGalaxy, Zone, DockedEntity ?? CurrentEntity, IsTutorial, _actionBarSlots.Select(s => s.Save()).ToArray()); - RunSave.Commit(CultCache, game, zones); + RunSave.Commit(CultCache, game, zones, ItemManager.Lots); } } @@ -261,7 +261,7 @@ public class ActionGameManager : MonoBehaviour EntityInstance.EffectManagerParent = EffectManagerParent; ConsoleController.MessageReceiver = this; - ItemManager = new ItemManager(CultCache, Settings.GameplaySettings, Debug.Log); + ItemManager = new ItemManager(CultCache, RunSave.Lots(CultCache), Settings.GameplaySettings, Debug.Log); ZoneRenderer.ItemManager = ItemManager; // If hiding minimap asteroids, turn them off to start with @@ -496,7 +496,7 @@ public class ActionGameManager : MonoBehaviour .FirstOrDefault(itemData => string.Equals(itemData.Name, itemName, StringComparison.InvariantCultureIgnoreCase)); if (item != null) { - _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(item, .95f)); + _currentEntity.CargoBays.First().TryStore(ItemManager.CreateInstance(ItemManager.CreateLot(item, default, .95f))); } }); diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index c16a2181a..0a702a7b8 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -7,7 +7,7 @@ using GameCult.Caching.MessagePack; public static class AetheriaStores { public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) }; - public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame) }; + public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) }; public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) }; // Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given. diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index a3be9bed3..12e6a6344 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -1051,6 +1051,7 @@ public class ConsumableItemEffect public Entity Entity { get; } public ConsumableItem Item { get; } public ConsumableItemData Data { get; } + public Lot Lot { get; } public Behavior[] Behaviors { get; } public ConsumableItemEffect(ConsumableItem item, Entity entity) @@ -1058,6 +1059,7 @@ public class ConsumableItemEffect Item = item; Entity = entity; Data = (ConsumableItemData) entity.ItemManager.GetData(item); + Lot = entity.ItemManager.GetLot(item); RemainingDuration = Data.Duration; Behaviors = Data.Behaviors @@ -1082,7 +1084,7 @@ public class ConsumableItemEffect public float Evaluate(PerformanceStat stat) { var effectiveness = Data.Effectiveness.Evaluate((Data.Duration - RemainingDuration) / Data.Duration); - var quality = pow(Item.QualityForRole(stat.FromRole), stat.QualityExponent); + var quality = pow(Lot.QualityForRole(stat.FromRole), stat.QualityExponent); var result = lerp(stat.Min, stat.Max, effectiveness * quality); @@ -1113,6 +1115,7 @@ public class EquippedItem public Shape InsetShape { get; } public Entity Entity { get; } public EquippableItemData Data { get; } + public Lot Lot { get; } public ReactiveProperty ThermalOnline { get; } = new ReactiveProperty(false); public ReactiveProperty DurabilityOnline { get; } = new ReactiveProperty(false); @@ -1213,16 +1216,17 @@ public class EquippedItem Data = ItemManager.GetData(item); Entity = entity; EquippableItem = item; + Lot = ItemManager.GetLot(item); Position = position; Conductivity = Data.Conductivity; ThermalExponent = lerp( ItemManager.GameplaySettings.ThermalQualityMin, ItemManager.GameplaySettings.ThermalQualityMax, - pow(item.Quality, ItemManager.GameplaySettings.ThermalQualityExponent)); + pow(Lot.Quality, ItemManager.GameplaySettings.ThermalQualityExponent)); DurabilityExponent = lerp( ItemManager.GameplaySettings.DurabilityQualityMin, ItemManager.GameplaySettings.DurabilityQualityMax, - pow(item.Quality, ItemManager.GameplaySettings.DurabilityQualityExponent)); + pow(Lot.Quality, ItemManager.GameplaySettings.DurabilityQualityExponent)); var hullData = itemManager.GetData(entity.Hull); InsetShape = hullData.Shape.Inset(Data.Shape, position, item.Rotation); if (Entity.Temperature != null) oldTemperature = Temperature; @@ -1258,7 +1262,7 @@ public class EquippedItem { var heat = pow(ThermalPerformance, ThermalExponent * stat.HeatExponentMultiplier); var durability = pow(DurabilityPerformance, DurabilityExponent * stat.DurabilityExponentMultiplier); - var quality = pow(EquippableItem.QualityForRole(stat.FromRole), stat.QualityExponent); + var quality = pow(Lot.QualityForRole(stat.FromRole), stat.QualityExponent); var scaleModifier = 1.0f; var scaleModifiers = stat.GetScaleModifiers(Entity).Values; @@ -1287,7 +1291,7 @@ public class EquippedItem DurabilityPerformance = EquippableItem.Durability / Data.Durability; var performanceThreshold = Entity.Settings.ShutdownPerformance; Wear = (1 - pow(ThermalPerformance, - (1 - pow(EquippableItem.Quality, ItemManager.GameplaySettings.QualityWearExponent)) * + (1 - pow(Lot.Quality, ItemManager.GameplaySettings.QualityWearExponent)) * ItemManager.GameplaySettings.ThermalWearExponent) + deltaTemp * ItemManager.GameplaySettings.DeltaTempWearExponent ) * Data.Durability / Data.ThermalResilience; diff --git a/Assets/Scripts/ServerShared/EntitySerializer.cs b/Assets/Scripts/ServerShared/EntitySerializer.cs index 8bd057547..993e8b92e 100644 --- a/Assets/Scripts/ServerShared/EntitySerializer.cs +++ b/Assets/Scripts/ServerShared/EntitySerializer.cs @@ -145,6 +145,22 @@ public static class EntitySerializer return (items.Select(i => i.GetBehavior()).ToList(), items.ToList()); }).ToArray(); } + + // Every item instance an entity pack carries, recursively through its children: hull, equipment, cargo and + // docking bay units, and their stored contents. The provenance ledger's GC root walk. + public static IEnumerable Items(EntityPack pack) + { + yield return pack.Hull; + foreach (var (_, item) in pack.Equipment) yield return item; + foreach (var (_, item) in pack.CargoBays) yield return item; + foreach (var (_, item) in pack.DockingBays) yield return item; + foreach (var bay in pack.CargoContents) + foreach (var (_, item) in bay) yield return item; + foreach (var bay in pack.DockingBayContents) + foreach (var (_, item) in bay) yield return item; + foreach (var child in pack.Children) + foreach (var item in Items(child)) yield return item; + } } [MessagePackObject] diff --git a/Assets/Scripts/ServerShared/ItemInstance.cs b/Assets/Scripts/ServerShared/ItemInstance.cs index 8021501f1..739b7835b 100644 --- a/Assets/Scripts/ServerShared/ItemInstance.cs +++ b/Assets/Scripts/ServerShared/ItemInstance.cs @@ -32,33 +32,10 @@ public abstract class ItemInstance JsonObject(MemberSerialization.OptIn)] 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; + // Keys 2 (Quality), 9 (Ingredients) and 10 (Product) belonged to fields moved to Lot; do not reuse them. - // How good the part filling each of the design's roles turned out. Empty on items made before roles - // existed; stats then read Quality as they always did. - [JsonProperty("ingredients"), Key(9)] public List Ingredients = new List(); - - // The manufacturer's branded product this was built as, naming it and carrying its flavor text. - [JsonProperty("product"), Key(10)] public CultRecordRef 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; + // The lot this unit was minted from: its design, provenance, workmanship and role fills all live there. + [JsonProperty("lot"), Key(11)] public int Lot; } [MessagePackObject, JsonObject(MemberSerialization.OptIn)] diff --git a/Assets/Scripts/ServerShared/ItemManager.cs b/Assets/Scripts/ServerShared/ItemManager.cs index 091faf518..3d7037950 100644 --- a/Assets/Scripts/ServerShared/ItemManager.cs +++ b/Assets/Scripts/ServerShared/ItemManager.cs @@ -22,11 +22,13 @@ public class ItemManager private Action _logger; public CultCache ItemData { get; } + public ProvenanceLedger Lots { get; } public GameplaySettings GameplaySettings { get; } - public ItemManager(CultCache itemData, GameplaySettings settings, Action logger) + public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action logger) { ItemData = itemData; + Lots = lots; GameplaySettings = settings; _logger = logger; } @@ -66,15 +68,19 @@ public class ItemManager }; } + // The one resolution path from a crafted instance to the lot it was minted from. + public Lot GetLot(CraftedItemInstance item) => Lots[item.Lot]; + // Returns stat when not equipped public float Evaluate(PerformanceStat stat, EquippableItem item) { var data = GetData(item); - var quality = pow(item.QualityForRole(stat.FromRole), stat.QualityExponent); + var lot = GetLot(item); + var quality = pow(lot.QualityForRole(stat.FromRole), stat.QualityExponent); var durabilityExponent = lerp( GameplaySettings.DurabilityQualityMin, GameplaySettings.DurabilityQualityMax, - pow(item.Quality, GameplaySettings.DurabilityQualityExponent)); + pow(lot.Quality, GameplaySettings.DurabilityQualityExponent)); var durability = pow(item.Durability / data.Durability, durabilityExponent * stat.DurabilityExponentMultiplier); var result = lerp(stat.Min, stat.Max, quality * durability); if (float.IsNaN(result)) @@ -86,34 +92,12 @@ public class ItemManager public int GetPrice(CraftedItemInstance item) { var data = GetData(item); - return (int) (GameplaySettings.QualityPriceModifier.Evaluate(item.Quality) * data.Price); + return (int) (GameplaySettings.QualityPriceModifier.Evaluate(GetLot(item).Quality) * data.Price); } - public CraftedItemInstance CreateInstance(CraftedItemData item, float quality) + // The tier roll every mint applies, whether or not the lot fills roles. + public float RollQuality() { - if (item is EquippableItemData equippableItemData) - { - return new EquippableItem - { - Data = ItemData.RefOf(item), Quality = quality, Durability = equippableItemData.Durability - }; - } - - var newCommodity = new CompoundCommodity - { - Data = ItemData.RefOf(item), - Quality = quality - }; - return newCommodity; - } - - public CraftedItemInstance CreateInstance(CraftedItemData item) - { - if (item == null) - { - throw new NullReferenceException("Attempted to create crafted item instance using missing or incorrect item data!"); - } - var quality = Random.NextFloat(); var tier = GameplaySettings.Tiers[0]; foreach (var t in GameplaySettings.Tiers) @@ -122,11 +106,66 @@ public class ItemManager tier = t; } - return CreateInstance(item, tier.Quality); + return 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 int CreateLot(FactionProductData product) + { + var design = ItemData.Get(product.Design); + var lot = new Lot + { + Design = ItemData.RefOf(design), + Origin = new Attributed { Faction = product.Manufacturer }, + Quality = RollQuality(), + Roles = new List() + }; + if (design.Roles != null) + foreach (var role in design.Roles) + { + var build = product.Roles?.FirstOrDefault(b => b.Role == role.Name) ?? new ProductRole(); + lot.Roles.Add(new RoleFill + { + Role = role.Name, + Quality = clamp(Random.NextGaussian(build.Mean, build.StandardDeviation), .01f, 1) + }); + } + + return Lots.Add(lot); + } + + // No branded product: a lot attributed to maker (which may be unset) with no role fills, e.g. console `give`. + public int CreateLot(CraftedItemData design, CultRecordRef maker, float quality) + { + var lot = new Lot + { + Design = ItemData.RefOf(design), + Origin = new Attributed { Faction = maker }, + Quality = quality + }; + return Lots.Add(lot); + } + + // The one writer of a crafted instance's Data: copied from the lot's design, never independently set. + public CraftedItemInstance CreateInstance(int lot) + { + var l = Lots[lot]; + var design = ItemData.Get(l.Design) as CraftedItemData; + if (design is EquippableItemData equippableItemData) + { + return new EquippableItem + { + Data = l.Design, Lot = lot, Durability = equippableItemData.Durability + }; + } + + return new CompoundCommodity + { + Data = l.Design, Lot = lot + }; + } + public CraftedItemInstance CreateInstance(FactionProductData product) { var design = ItemData.Get(product.Design); @@ -136,29 +175,38 @@ public class ItemManager return null; } - var instance = CreateInstance(design); - instance.Product = ItemData.RefOf(product); - 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 CreateInstance(CreateLot(product)); + } - return instance; + // Who made an item and what it is branded as, derived from its lot's provenance. Null maker (an unset faction, + // or an Extracted lot with none) means no brand at all; a set maker with no matching product still shows the + // maker, with no product name or flavour text. + public (Faction Maker, FactionProductData Product) Brand(CraftedItemInstance item) + { + var lot = GetLot(item); + var maker = lot.Origin switch + { + Attributed attributed => attributed.Faction, + Produced produced => produced.Faction, + _ => default + }; + if (!maker.IsSet()) return (null, null); + + var product = ItemData.GetAll() + .Where(p => p.Manufacturer.Key.Equals(maker.Key) && p.Design.Key.Equals(item.Data.Key)) + .OrderBy(p => ItemData.RefOf(p).Key.Value, StringComparer.Ordinal) + .FirstOrDefault(); + return (ItemData.Get(maker), product); } public (RarityTier tier, int upgrades) GetTier(CraftedItemInstance item) { + var quality = GetLot(item).Quality; var tier = GameplaySettings.Tiers[0]; foreach (var t in GameplaySettings.Tiers) - if (item.Quality + .001f > t.Quality) + if (quality + .001f > t.Quality) tier = t; - int upgrades = (int) ((item.Quality - tier.Quality) / .0499f); + int upgrades = (int) ((quality - tier.Quality) / .0499f); return (tier, upgrades); } } diff --git a/Assets/Scripts/ServerShared/Loadout.cs b/Assets/Scripts/ServerShared/Loadout.cs index 8403d4854..d8b868cf5 100644 --- a/Assets/Scripts/ServerShared/Loadout.cs +++ b/Assets/Scripts/ServerShared/Loadout.cs @@ -91,7 +91,8 @@ public static class Loadouts // any failure nothing is returned and the zone and cache are unchanged, but a failed fit has already drawn from // itemManager.Random. A design is built by its first available product in record-key order. A game spawner is to // pass LoadoutGenerator.IsAvailable as isAvailable, with no fallback to any manufacturer. The ship always gets - // exactly WeaponGroupCount groups; a loadout with fewer is padded with empty groups. + // exactly WeaponGroupCount groups; a loadout with fewer is padded with empty groups. A failed materialization + // leaves any minted lots in the live ledger; they are not roots, so the next commit drops them. public static Ship Materialize(ItemManager itemManager, Zone liveZone, Loadout loadout, Predicate isAvailable, List failures) { diff --git a/Assets/Scripts/ServerShared/LoadoutGenerator.cs b/Assets/Scripts/ServerShared/LoadoutGenerator.cs index 303925fb0..da432b7c2 100644 --- a/Assets/Scripts/ServerShared/LoadoutGenerator.cs +++ b/Assets/Scripts/ServerShared/LoadoutGenerator.cs @@ -215,9 +215,10 @@ public class LoadoutGenerator if (entry.design == null) ItemManager.Log($"No compatible item found for entity {Enum.GetName(typeof(HardpointType), hardpoint.Type)} hardpoint!"); else { - 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); + // Matching hardpoints carry matching units: same lot + var item = (previousItem != null + ? ItemManager.CreateInstance(previousItem.EquippableItem.Lot) + : ItemManager.CreateInstance(entry.product)) as EquippableItem; if (!entity.TryEquip(item)) { throw new InvalidLoadoutException($"Failed to equip selected {Enum.GetName(typeof(HardpointType), hardpoint.Type)}!"); @@ -259,14 +260,6 @@ public class LoadoutGenerator 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 diff --git a/Assets/Scripts/ServerShared/Provenance.cs b/Assets/Scripts/ServerShared/Provenance.cs new file mode 100644 index 000000000..b04a4aeeb --- /dev/null +++ b/Assets/Scripts/ServerShared/Provenance.cs @@ -0,0 +1,120 @@ +/* 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 GameCult.Caching; +using MessagePack; + +// The run's lot ledger: one run-scoped global document, LotId -> Lot. The live instance is ItemManager.Lots. +[CultDocument("aetheria.provenanceledger", "1"), CultGlobal, MessagePackObject] +public class ProvenanceLedger +{ + [Key(0)] public int NextLot = 1; + [Key(1)] public Dictionary Lots = new Dictionary(); + + // The only way a lot enters the ledger. + public int Add(Lot lot) + { + var id = NextLot++; + Lots[id] = lot; + return id; + } + + // Absent lots throw: a dangling LotId is corruption, and it must be loud. No try-variant. + public Lot this[int id] + { + get + { + if (!Lots.TryGetValue(id, out var lot)) + throw new InvalidOperationException($"Lot {id} is not in the provenance ledger."); + return lot; + } + } + + // A new ledger holding only lots reachable from roots, closed over Produced.Facility and Produced.Inputs. Never + // mutates this: the live ledger keeps unreachable lots, because a floor pickup can still be collected after a save. + public ProvenanceLedger Reachable(IEnumerable roots) + { + var reached = new Dictionary(); + var pending = new Queue(roots); + while (pending.Count > 0) + { + var id = pending.Dequeue(); + if (reached.ContainsKey(id)) continue; + var lot = this[id]; + reached[id] = lot; + if (lot.Origin is Produced produced) + { + pending.Enqueue(produced.Facility); + if (produced.Inputs != null) + foreach (var input in produced.Inputs) + pending.Enqueue(input); + } + } + + return new ProvenanceLedger { NextLot = NextLot, Lots = reached }; + } +} + +// One lot: the design it built, who made it and from what, its rolled workmanship, and how well each design role +// was filled. Immutable once minted; instances carry a LotId, never a copy. +[MessagePackObject] +public class Lot +{ + [Key(0)] public CultRecordRef Design; + [Key(1)] public Provenance Origin; + [Key(2)] public float Quality; + [Key(3)] public List Roles; + + // The quality a stat reads for one of the design's roles. An unnamed role, a design without roles, and a lot + // minted before roles existed all fall back to the lot's own workmanship. + public float QualityForRole(string role) + { + if (string.IsNullOrEmpty(role) || Roles == null) return Quality; + foreach (var fill in Roles) + 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] +public class RoleFill +{ + [Key(0)] public string Role; + [Key(1)] public float Quality; +} + +// Who built a lot, where, and from what. Terminus mints Attributed placeholders; Produced and Extracted are the +// forward shape for backward generation, next scope. +[Union(0, typeof(Attributed)), + Union(1, typeof(Produced)), + Union(2, typeof(Extracted))] +public abstract class Provenance { } + +// The Terminus placeholder: only the faction is known. No other level of the crafting pyramid is generated yet. +[MessagePackObject] +public class Attributed : Provenance +{ + [Key(0)] public CultRecordRef Faction; +} + +// Built in place at a station, from a factory item's own lot and a set of input lots. +[MessagePackObject] +public class Produced : Provenance +{ + [Key(0)] public CultRecordRef Faction; + [Key(1)] public int Station; + [Key(2)] public int Facility; + [Key(3)] public int[] Inputs; +} + +// A raw commodity pulled from a zone. +[MessagePackObject] +public class Extracted : Provenance +{ + [Key(0)] public int Zone; + [Key(1)] public CultRecordRef Commodity; +} diff --git a/Assets/Scripts/ServerShared/Provenance.cs.meta b/Assets/Scripts/ServerShared/Provenance.cs.meta new file mode 100644 index 000000000..adb32abf3 --- /dev/null +++ b/Assets/Scripts/ServerShared/Provenance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1f676effae6b894586934cb64fe1857 \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/SavedGame.cs b/Assets/Scripts/ServerShared/SavedGame.cs index d6863b60a..301be9795 100644 --- a/Assets/Scripts/ServerShared/SavedGame.cs +++ b/Assets/Scripts/ServerShared/SavedGame.cs @@ -49,7 +49,8 @@ public class SavedGame public FactionRelationship[] Relationships; } -// The run's lifecycle over the run store. Only Commit creates SavedGame and SavedZone records. +// The run's lifecycle over the run store. Only Commit creates SavedGame, SavedZone and ProvenanceLedger records; +// the ledger is the fifth run record. public static class RunSave { // The live run as plain documents. Writes nothing. @@ -90,10 +91,14 @@ public static class RunSave return (game, zones); } + // The stored ledger, or a fresh empty one when the run has minted nothing yet. + public static ProvenanceLedger Lots(CultCache cache) => cache.GetGlobal() ?? new ProvenanceLedger(); + // The only writer of SavedGame and SavedZone: one Commit to the run store. Zone i lands at savedzone-{i}, stable // because a run's zone array is fixed at generation, and every stored SavedZone outside that set is removed. The // run store's single-file commit writes its whole view, so orbits and bodies staged by zone generation land too. - public static void Commit(CultCache cache, SavedGame game, IReadOnlyList zones) + // The ledger lands too, pruned to what the committed zones' entities still reach; the live ledger is untouched. + public static void Commit(CultCache cache, SavedGame game, IReadOnlyList zones, ProvenanceLedger lots) { var keys = zones.Select((_, i) => new CultRecordKey($"savedzone-{i}")).ToArray(); var kept = new HashSet(keys); @@ -102,10 +107,16 @@ public static class RunSave .Select(stored => stored.Key) .ToArray(); game.Zones = keys.Select(key => new CultRecordRef(key)).ToArray(); + var roots = zones + .SelectMany(zone => zone.Contents?.Entities ?? new List()) + .SelectMany(EntitySerializer.Items) + .OfType() + .Select(item => item.Lot); cache.Commit(batch => { for (var i = 0; i < keys.Length; i++) batch.Upsert(typeof(SavedZone), zones[i], keys[i]); batch.Upsert(game); + batch.Upsert(lots.Reachable(roots)); foreach (var key in stale) batch.Remove(key); }); } diff --git a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs index f31ffe0c5..06c406eb6 100644 --- a/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs +++ b/Assets/Scripts/UI/Properties Panel/PropertiesPanel.cs @@ -368,8 +368,18 @@ public class PropertiesPanel : MonoBehaviour AddProperty("Quantity", () => simpleCommodity.Quantity.ToString()); var sheet = AddStatSheet(); - var manufacturer = ActionGameManager.CultCache.Get(data.Manufacturer); - sheet.AddStat("Manufacturer", () => manufacturer?.Name ?? "GameCult"); + // Branding is derived from provenance, not stored on the design; a SimpleCommodity carries no lot and shows none. + if (item is CraftedItemInstance crafted) + { + var (maker, product) = GameManager.ItemManager.Brand(crafted); + if (maker != null) + sheet.AddStat("Manufacturer", () => maker.Name); + if (product != null) + { + AddProperty(product.Name); + AddProperty(product.Description); + } + } sheet.AddStat("Mass", () => ActionGameManager.PlayerSettings.Format(GameManager.ItemManager.GetMass(item))); //AddProperty("Thermal Mass", () => Context.GetThermalMass(item).SignificantDigits(Context.GameplaySettings.SignificantDigits)); diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a0cf326c8..01334b388 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -70,6 +70,22 @@ public static class Program Console.WriteLine($" {product.Name,-28} by {db.Cache.Get(product.Manufacturer)?.ShortName ?? "(none)",-12} " + string.Join(", ", product.Roles.Select(r => $"{r.Role} {r.Mean:0.00}±{r.StandardDeviation:0.00}"))); if (authored.Length == 0) Console.WriteLine(" none yet: add roles to a design, then set each product's means"); + + // Brand() picks the first (maker, design) product in record-key order; more than one means the choice is + // arbitrary rather than authored, which the rule until segment bands exist forbids. + var duplicateBrands = products + .Where(p => p.Manufacturer.IsSet()) + .GroupBy(p => (Maker: p.Manufacturer.Key, Design: p.Design.Key)) + .Where(g => g.Count() > 1) + .ToArray(); + Console.WriteLine($"\n{duplicateBrands.Length} (maker, design) pairs with more than one product:"); + foreach (var group in duplicateBrands) + { + var maker = db.Cache.Get(group.Key.Maker); + var design = db.Cache.Get(group.Key.Design); + Console.WriteLine($" {maker?.ShortName ?? "(unknown)"} / {design?.Name ?? group.Key.Design.Value}: " + + string.Join(", ", group.Select(p => p.Name))); + } return 0; } @@ -359,6 +375,7 @@ public static class Program Console.WriteLine($"{run.Zones.Length} zones, current zone {run.CurrentZone}\n"); var suspect = 0; + var crafted = new List(); for (var i = 0; i < run.Zones.Length; i++) { var zone = db.Cache.Get(run.Zones[i]); @@ -370,11 +387,25 @@ public static class Program : "(none)"; var packedEmpty = zone.Contents.Orbits.Count > 0 && stations == 0; if (packedEmpty) suspect++; + crafted.AddRange(zone.Contents.Entities.SelectMany(EntitySerializer.Items).OfType()); Console.WriteLine($" [{i,3}] {zone.Name,-18} owner {owner,-12} orbits {zone.Contents.Orbits.Count,3} " + $"stations {stations,2} ships {ships,2}{(packedEmpty ? " <- packed with orbits but no stations" : "")}"); } Console.WriteLine($"\n{suspect} visited zones packed with orbits but no stations"); + + // The only headless check of a real played save: does every crafted instance still resolve its lot, and + // does its Data still equal the lot's Design (the one invariant CreateInstance(int) is meant to hold)? + var lots = RunSave.Lots(db.Cache); + var absent = 0; + var mismatched = 0; + foreach (var item in crafted) + { + if (!lots.Lots.TryGetValue(item.Lot, out var lot)) { absent++; continue; } + if (!lot.Design.Key.Equals(item.Data.Key)) mismatched++; + } + Console.WriteLine($"\n{lots.Lots.Count} lots, {crafted.Count} crafted instances, " + + $"{absent} instances whose lot is absent, {mismatched} instances whose Data differs from Lot.Design"); return 0; } @@ -392,7 +423,7 @@ public static class Program QualityPriceModifier = new ExponentialLerp() }; var log = new List(); - var itemManager = new ItemManager(db.Cache, settings, log.Add); + var itemManager = new ItemManager(db.Cache, new ProvenanceLedger(), settings, log.Add); var failures = 0; foreach (var hull in db.Cache.GetAll().OrderBy(h => h.HullType).ThenBy(h => h.Name))