StatValidation.ValidateRoleUsage walks every PerformanceStat field on every behaviour and refuses a StatSource.Quality term whose Role is not in the design's own declared Roles -- wired into AetheriaStores.Open and CultRecordRefs.Upsert alongside the existing heat/modifier/power-supply checks. Lot.QualityForRole already falls back to workmanship for a role an old lot never rolled (forward-compat for a role added after minting); this is the other direction: a term naming a role the design never declared at all is an authoring mistake, not a lot predating the role, and must fail loudly. RoleAuthoringTests.cs pins three rules with both a positive and negative control: Upsert refuses an absent-role term but accepts a declared one, and two products of one design with different ProductRole means mint lots that resolve a role-termed stat to different, deterministic values. tests/mutation_tests_stats_power_cut8.py (this repo's own numbering follows cut7.py's brownout work) mutates ValidateRoleUsage in both directions (never-refuse, always-refuse) and QualityForRole's fallback, plus ItemManager.CreateLot's mean read -- all six behave as declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
5.9 KiB
C#
97 lines
5.9 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 7 (docs/stats-and-power-cut.md): a stat naming a role its design lacks is refused.
|
|
StatValidation.ValidateRoleUsage(data.Name, data.Roles, 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);
|
|
StatValidation.ValidateRoleUsage(data.Name, data.Roles, 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);
|
|
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
|
|
}
|
|
if (document is ConsumableItemData consumable)
|
|
{
|
|
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateNoPowerSupplyOnRequest(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
|
|
}
|
|
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
|
|
}
|
|
}
|