Keep a missing catalog global loud for writable opens

Globals are owned by lifecycle, so the cache must never run without one,
however the catalog is opened. Before this, opening the catalog writable
skipped the check entirely. The editor now opens the catalog writable, so a
missing global went silent in play mode.

AetheriaStores.Open now skips the check only for a writable catalog that
has no records: a seed store being authored from nothing, as the test
fixtures do. A populated catalog is always checked, and so is a read-only
empty one. Nothing else relied on the old skip: the game has no catalog
globals, AetherDb opens the populated Aetheria.cc, and every writable test
reopen already holds its fixture global.

WritablePopulatedCatalogMissingGlobalIsLoud removes the global from a
populated catalog and checks that a writable reopen throws. It fails when
the old writable-skip condition is restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 23:13:22 +02:00
commit ab4ced0f53
2 changed files with 20 additions and 3 deletions

View file

@ -11,8 +11,8 @@ public static class AetheriaStores
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
// A read-only catalog throws when a [CultGlobal] type routed to it has no record. A writable catalog is being
// authored (the importer starts from an empty file), so it is not held to that.
// 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();
@ -21,7 +21,9 @@ public static class AetheriaStores
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 missing = catalogWritable ? null : cache.Registry.AllDescriptors.FirstOrDefault(descriptor =>
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));

View file

@ -121,6 +121,21 @@ public sealed class AetheriaStoresTests : IDisposable
using (AetheriaStores.Open(Catalog)) { }
}
// Writability exempts only an empty seed catalog: a populated catalog missing a global is loud even when writable.
[Fact]
public void WritablePopulatedCatalogMissingGlobalIsLoud()
{
using (var cache = AetheriaStores.Open(Catalog, catalogWritable: true))
{
var global = cache.GetGlobal<TestCatalogGlobal>();
var key = cache.RefOf(global).Key;
Assert.True(cache.Commit(batch => batch.Remove(key)));
}
var error = Assert.Throws<InvalidOperationException>(() => AetheriaStores.Open(Catalog, catalogWritable: true));
Assert.Contains("aetheria.tests.catalogglobal", error.Message);
}
private static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
private static string[] SchemaNames(string path)