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:
parent
d8c4ec4dd8
commit
415f8e1152
13 changed files with 305 additions and 92 deletions
|
|
@ -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)));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
Assets/Scripts/ServerShared/Provenance.cs
Normal file
120
Assets/Scripts/ServerShared/Provenance.cs
Normal 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;
|
||||
}
|
||||
2
Assets/Scripts/ServerShared/Provenance.cs.meta
Normal file
2
Assets/Scripts/ServerShared/Provenance.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c1f676effae6b894586934cb64fe1857
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue