Add the lot ledger and mint every crafted instance through it

Quality, Ingredients and Product leave CraftedItemInstance; a unit now
carries only a LotId into the run-scoped ProvenanceLedger. ItemManager
gains the one lot-minting path (CreateLot/CreateInstance(int)), GetLot,
and Brand(), which derives a maker and product from provenance instead
of a stored field. RunSave persists the ledger pruned to what the saved
zones' entities still reach; the live ledger is never pruned, so a
floor pickup collected after a save still resolves. CopyBuild is gone:
matching hardpoints now share the same lot instead of copying values.
PropertiesPanel shows the derived brand where the design's manufacturer
used to be. AetherDb's census flags duplicate (maker, design) products
and its save command checks every crafted instance's lot against a real
run store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-17 04:16:23 +02:00
commit 415f8e1152
13 changed files with 305 additions and 92 deletions

View file

@ -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)));
}
});

View file

@ -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.

View file

@ -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<bool> ThermalOnline { get; } = new ReactiveProperty<bool>(false);
public ReactiveProperty<bool> DurabilityOnline { get; } = new ReactiveProperty<bool>(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;

View file

@ -145,6 +145,22 @@ public static class EntitySerializer
return (items.Select(i => i.GetBehavior<Weapon>()).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<ItemInstance> 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]

View file

@ -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<RoleFill> Ingredients = new List<RoleFill>();
// The manufacturer's branded product this was built as, naming it and carrying its flavor text.
[JsonProperty("product"), Key(10)] public CultRecordRef<FactionProductData> 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)]

View file

@ -22,11 +22,13 @@ public class ItemManager
private Action<string> _logger;
public CultCache ItemData { get; }
public ProvenanceLedger Lots { get; }
public GameplaySettings GameplaySettings { get; }
public ItemManager(CultCache itemData, GameplaySettings settings, Action<string> logger)
public ItemManager(CultCache itemData, ProvenanceLedger lots, GameplaySettings settings, Action<string> 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<ItemData>(item), Quality = quality, Durability = equippableItemData.Durability
};
}
var newCommodity = new CompoundCommodity
{
Data = ItemData.RefOf<ItemData>(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<ItemData>(design),
Origin = new Attributed { Faction = product.Manufacturer },
Quality = RollQuality(),
Roles = new List<RoleFill>()
};
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<Faction> maker, float quality)
{
var lot = new Lot
{
Design = ItemData.RefOf<ItemData>(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<FactionProductData>()
.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);
}
}

View file

@ -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<FactionProductData> isAvailable, List<string> failures)
{

View file

@ -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<RoleFill>();
}
}
public class InvalidLoadoutException : Exception

View file

@ -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<int, Lot> Lots = new Dictionary<int, Lot>();
// 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<int> roots)
{
var reached = new Dictionary<int, Lot>();
var pending = new Queue<int>(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<ItemData> Design;
[Key(1)] public Provenance Origin;
[Key(2)] public float Quality;
[Key(3)] public List<RoleFill> 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> 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> 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<SimpleCommodityData> Commodity;
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c1f676effae6b894586934cb64fe1857

View file

@ -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<ProvenanceLedger>() ?? 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<SavedZone> 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<SavedZone> zones, ProvenanceLedger lots)
{
var keys = zones.Select((_, i) => new CultRecordKey($"savedzone-{i}")).ToArray();
var kept = new HashSet<CultRecordKey>(keys);
@ -102,10 +107,16 @@ public static class RunSave
.Select(stored => stored.Key)
.ToArray();
game.Zones = keys.Select(key => new CultRecordRef<SavedZone>(key)).ToArray();
var roots = zones
.SelectMany(zone => zone.Contents?.Entities ?? new List<EntityPack>())
.SelectMany(EntitySerializer.Items)
.OfType<CraftedItemInstance>()
.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);
});
}

View file

@ -368,8 +368,18 @@ public class PropertiesPanel : MonoBehaviour
AddProperty("Quantity", () => simpleCommodity.Quantity.ToString());
var sheet = AddStatSheet();
var manufacturer = ActionGameManager.CultCache.Get<Faction>(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));

View file

@ -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<Faction>(group.Key.Maker);
var design = db.Cache.Get<CraftedItemData>(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<CraftedItemInstance>();
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<CraftedItemInstance>());
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<string>();
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<HullData>().OrderBy(h => h.HullType).ThenBy(h => h.Name))