FieldYield and FieldKindData.Yields (key 3), ProvinceData, FieldKinds.Pick/Assign/Ensure/Yield with a province type, Provinces.Regions/Partition/Deal, Galaxy.Provinces built once by the generation and save constructors, SavedGame key 15 and SavedZone key 6, ProvinceZones setting. Not yet built or run; tests follow in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
8.5 KiB
C#
129 lines
8.5 KiB
C#
using System;
|
|
using System.IO;
|
|
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(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(ProvinceData), typeof(VerseGrammar), typeof(VerseVerb), typeof(StationKindData), typeof(RecipeData), typeof(OutsideMarketData) };
|
|
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.
|
|
// A catalog's station hull with no Kind is refused, unless the caller gives kindlessStation: the tool that repairs a catalog
|
|
// is told the hull's name and goes on.
|
|
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
|
|
// become catalog globals); the global check above is judged against whichever registry opened the cache.
|
|
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
|
|
CultDocumentRegistry registry = null, Action<string> kindlessStation = null)
|
|
{
|
|
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
|
|
if (!catalogWritable && !File.Exists(catalogPath))
|
|
throw new FileNotFoundException($"Catalog {catalogPath} does not exist; a read-only open never invents one.", catalogPath);
|
|
var cache = new CultCache(registry);
|
|
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.");
|
|
// Every catalog document is held to the one check Upsert applies on write: R-heat (docs/stats-and-power-
|
|
// cut.md), stat modifiers, role usage (Cut 7). Fails loudly, naming the item.
|
|
foreach (var data in cache.GetAll<EquippableItemData>()) CultRecordRefs.Validate(data);
|
|
foreach (var data in cache.GetAll<ConsumableItemData>()) CultRecordRefs.Validate(data);
|
|
VerseGrammarValidation.ValidateAll(cache.GetAll<VerseVerb>());
|
|
ValidateBins(cache);
|
|
StationKinds.Validate(cache, kindlessStation);
|
|
return cache;
|
|
}
|
|
catch
|
|
{
|
|
cache.Dispose();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// The mint and the weighting trust a bin: they never check its Floor or its Line. So the catalog read refuses one whose Floor is
|
|
// not a finite number (it would weigh NaN and poison every draw of its design), whose Line names no product the catalog holds, or
|
|
// whose Line is itself a bin (a bin is of a line, never of a bin). The refusals name the field and echo no value.
|
|
private static void ValidateBins(CultCache cache)
|
|
{
|
|
foreach (var product in cache.GetAll<FactionProductData>())
|
|
{
|
|
if (product.Bin == null) continue;
|
|
if (!float.IsFinite(product.Bin.Floor))
|
|
throw new InvalidOperationException("A product bin's Floor is not a finite number.");
|
|
var line = cache.Get(product.Bin.Line);
|
|
if (line == null)
|
|
throw new InvalidOperationException("A product bin's Line names no product this catalog holds.");
|
|
if (line.Bin != null)
|
|
throw new InvalidOperationException("A product bin's Line names another bin; a bin is of a line.");
|
|
}
|
|
}
|
|
}
|
|
|
|
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}."));
|
|
|
|
// F7 (docs/stats-and-power-cut.md, Soul pass 2026-09-19): the validation body itself, pulled out of Upsert
|
|
// below so it has exactly one owner. Every catalog write in this repository was supposed to go through
|
|
// Upsert, but three of tools/AetherDb/Program.cs's own migration commands (Dangling, ShieldMigrate,
|
|
// BrownoutMigrate) cannot: they repair an EXISTING record in place and must preserve its key, which Upsert's
|
|
// own cache.UpsertAsync(document) does not support (Upsert mints or resolves a key from the document itself,
|
|
// not from a caller-supplied one). Rather than leave those three writing raw and unvalidated -- as they did
|
|
// before this fix -- they call this method directly, immediately before their own identity-preserving
|
|
// batch.Upsert(document.GetType(), document, key) commit, so the same rules run either way. Takes `object`
|
|
// (not a generic T) because that is what those three tools' own (Document, Key) change lists are typed as.
|
|
public static void Validate(object document)
|
|
{
|
|
if (document is EquippableItemData data)
|
|
{
|
|
StatValidation.ValidateHeatResponse(data);
|
|
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
|
|
StatValidation.ValidateMunitionParts(data.Name, data.Behaviors);
|
|
StatValidation.ValidateMineLayerFlat(data.Name, data.Behaviors);
|
|
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
|
|
}
|
|
if (document is ConsumableItemData consumable)
|
|
{
|
|
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateMunitionParts(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateMineLayerFlat(consumable.Name, consumable.Behaviors);
|
|
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
|
|
}
|
|
}
|
|
|
|
// 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, or through Validate above
|
|
// directly when the caller must preserve an existing key (see Validate's own comment) -- so this 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
|
|
{
|
|
Validate(document);
|
|
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
|
|
}
|
|
}
|