docs/stats-and-power-cut.md Cut 6, purely additive per its own map: the enum member, the resolver's generation-cache and the bus's PowerSupply write already existed (Cuts 2/3/5) as an identity everywhere. This lands the two things Cut 6 actually adds. The curve: EquippedItem.PowerSupplyFactor now returns pow(PowerSupply, exponent) instead of the Cut-3 identity stub, so a stat that declares a PowerSupply term degrades as this tick's grant falls and reads unchanged at full supply (pow(1, n) == 1 regardless of n). PowerBus.AllocateTiers now invalidates StatSource.PowerSupply once per draw per tick -- the missing half of the wiring named in the cut map -- so the resolver actually recomputes when the grant moves, using the same per-source generation scheme Heat/Durability already use (unconditional, at most once per tick, never touching a stat that did not declare the source). The request-independence rule: a stat that decides a power request may not depend on power supply, directly or through a modifier chain. Direct dependency is checked statically (StatValidation.ValidateNoPowerSupplyOnRequest, ItemData.cs) against a fixed registry of (BehaviorData type, field) pairs -- the one PerformanceStat each IPowerConsumer's own PowerRequest evaluates as its request, a compile-time fact rather than something authored per instance -- and runs at catalog load (AetheriaStores.Open), at Upsert, and again at equip (EquippedItem's constructor) as defense against the two named bypasses that write a catalog document without going through Upsert. The modifier-chain half is dynamic (StatModifier.ValidateNoPowerSupplyChain): a modifier's own magnitude stat is walked through the same magnitude->target edges ValidateNoCycle already builds for the self-cycle check, and refused at Initialize (equip time) if it is power-tainted and targets a known request stat, naming both items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
5.5 KiB
C#
92 lines
5.5 KiB
C#
using System;
|
|
using System.Linq;
|
|
using GameCult.Caching;
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
// Aetheria's stores: one home store per type, composed here and nowhere else.
|
|
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), 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.
|
|
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
|
|
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
|
|
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false)
|
|
{
|
|
var cache = new CultCache();
|
|
try
|
|
{
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, !catalogWritable), CatalogTypes);
|
|
if (runPath != null) cache.AddBackingStore(new SingleFileMessagePackBackingStore(runPath), RunTypes);
|
|
if (playerPath != null) cache.AddBackingStore(new SingleFileMessagePackBackingStore(playerPath), PlayerTypes);
|
|
var seeding = catalogWritable && !cache.AllStoredDocuments.Any(stored =>
|
|
CatalogTypes.Any(home => home.IsAssignableFrom(stored.Descriptor.DocumentType)));
|
|
var missing = seeding ? null : cache.Registry.AllDescriptors.FirstOrDefault(descriptor =>
|
|
descriptor.IsGlobal &&
|
|
CatalogTypes.Any(home => home.IsAssignableFrom(descriptor.DocumentType)) &&
|
|
cache.AllStoredDocuments.All(stored => stored.Descriptor != descriptor));
|
|
if (missing != null)
|
|
throw new InvalidOperationException($"Catalog {catalogPath} has no {missing.SchemaName} record; catalog globals are authored, never invented.");
|
|
// R-heat (docs/stats-and-power-cut.md): every equippable design's heat response must describe a
|
|
// coherent range before anything reads it. Fails loudly, naming the item.
|
|
foreach (var data in cache.GetAll<EquippableItemData>())
|
|
{
|
|
StatValidation.ValidateHeatResponse(data);
|
|
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
|
// Cut 6 (docs/stats-and-power-cut.md): a power request may not depend on power supply.
|
|
StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);
|
|
}
|
|
// Cut 2 (docs/stats-and-power-cut.md): a consumable's own StatModifierData needs the same check;
|
|
// consumables carry no heat response, so this is not folded into the loop above.
|
|
foreach (var data in cache.GetAll<ConsumableItemData>())
|
|
{
|
|
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
|
StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);
|
|
}
|
|
return cache;
|
|
}
|
|
catch
|
|
{
|
|
cache.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static class CultRecordRefs
|
|
{
|
|
public static bool IsSet(this CultRecordKey key) => !string.IsNullOrEmpty(key.Value);
|
|
public static bool IsSet<T>(this CultRecordRef<T> reference) => reference.Key.IsSet();
|
|
|
|
// The document a reference names, or null when it is unset or names nothing this cache holds.
|
|
public static T Get<T>(this CultCache cache, CultRecordRef<T> reference) where T : class =>
|
|
reference.IsSet() ? cache.Get<T>(reference.Key) : null;
|
|
|
|
// A reference to a document this cache holds. The key comes from the cache, never from the document.
|
|
public static CultRecordRef<T> RefOf<T>(this CultCache cache, T document) where T : class =>
|
|
new CultRecordRef<T>(cache.TryGetHandle(document)?.Key ??
|
|
throw new InvalidOperationException($"This cache holds no such {document.GetType().Name}."));
|
|
|
|
// Writes a document to its home store; the cache mints its key the first time. Every catalog write in this
|
|
// repository (tools, tests, migration scripts) goes through this one function, so it is where R-heat's
|
|
// validation closes the gap AetheriaStores.Open leaves: Open only checks what is already on disk when a
|
|
// session starts, not what a session then writes. Validating here means an invalid heat response is refused
|
|
// before the write ever reaches disk, not just the next time someone reopens the file.
|
|
public static CultRecordRef<T> Upsert<T>(this CultCache cache, T document) where T : class
|
|
{
|
|
if (document is EquippableItemData data)
|
|
{
|
|
StatValidation.ValidateHeatResponse(data);
|
|
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
|
StatValidation.ValidateNoPowerSupplyOnRequest(data.Name, data.Behaviors);
|
|
}
|
|
if (document is ConsumableItemData consumable)
|
|
{
|
|
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors);
|
|
}
|
|
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
|
|
}
|
|
}
|