Aetheria/Assets/Scripts/ServerShared/AetheriaStores.cs

103 lines
6.8 KiB
C#
Raw Normal View History

Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
using System;
using System.IO;
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
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(NameFile), typeof(InputLayout), typeof(Loadout), typeof(FieldKindData), typeof(VerseGrammar), typeof(VerseVerb) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
// 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.
// 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)
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
{
// 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);
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
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 =>
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
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>());
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
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}."));
F7: route AetherDb's identity-preserving repairs through the same validation CultRecordRefs.Upsert is the one function every catalog write is supposed to go through, but tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair an existing record in place and must preserve its key -- Upsert's own cache.UpsertAsync(document) cannot take a caller-supplied key, so all three wrote raw and unvalidated through `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`. `brownout-migrate apply` is how the shipped catalog's PowerSupply terms were actually authored, unvalidated, the whole time. Extracted Upsert's validation body into CultRecordRefs.Validate(object), and call it directly from all three tool commands immediately before their own identity-preserving commit -- the same rules, invoked where Upsert itself cannot reach. The comment above StatValidation naming the write-bypass holes (landed with F6) now names all four instead of two: CultCache Studio's generic editor (still open -- not this repository's to patch) plus the three tool commands above (closed). Pinned in tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs: ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould and ValidateRefusesAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould pin that the extracted method actually refuses what Upsert would (fail under a reverted Validate that is a no-op); ValidateIsANoOpForADocumentTypeItDoesNotGovern pins that a document type it has no opinion about doesn't crash it. Verified tools/AetherDb/AetherDb.csproj still builds clean against the pinned CultLib worktree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:31 +02:00
// 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)
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
{
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
if (document is EquippableItemData data)
{
StatValidation.ValidateHeatResponse(data);
StatValidation.ValidateStatModifiers(data.Name, data.Behaviors);
StatValidation.ValidateRoleUsage(data.Name, data.Roles, data.Behaviors);
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
}
if (document is ConsumableItemData consumable)
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it 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>
2026-09-19 03:47:07 +02:00
{
Cut 2: give every entity its own StatResolver, delete the catalog leak PerformanceStat kept two Dictionary<Entity, ...> fields for attached modifiers, grown by one key per entity that ever evaluated the stat and never trimmed (docs/stats-and-power-cut.md §0.3). StatResolver.cs is the new sole owner of every resolved (item, stat) value and every attached modifier, one instance per Entity and reachable only from it. EquippedItem and ConsumableItemEffect now read and write through it instead of through the catalog object; the two dictionaries and their four accessors are deleted outright, not hidden. StatModifier no longer resolves its (type name, field name) reference by runtime reflection search on every equip: StatValidation.ResolveStatField does it once, memoized, and StatValidation.ValidateStatModifiers checks it at catalog load and at Upsert, naming the record instead of failing with an opaque NullReferenceException the first time something equips it. A modifier whose own magnitude stat is (directly or transitively, through another modifier on the same entity) one of the stats it writes is refused at equip: resolving it would depend on a value computed from itself one tick late. A resolved value now caches by the generation of the sources its declared Terms actually read, invalidated per source by EquippedItem.UpdatePerformance (Heat, Durability) and ConsumableItemEffect.Update (ConsumableProgress), so a stat recomputes only when a term it declared moves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:53:59 +02:00
StatValidation.ValidateStatModifiers(consumable.Name, consumable.Behaviors);
StatValidation.ValidateRoleUsage(consumable.Name, consumable.Roles, consumable.Behaviors);
Cut 6: wire StatSource.PowerSupply and refuse a power request that reads it 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>
2026-09-19 03:47:07 +02:00
}
F7: route AetherDb's identity-preserving repairs through the same validation CultRecordRefs.Upsert is the one function every catalog write is supposed to go through, but tools/AetherDb/Program.cs's Dangling, ShieldMigrate and BrownoutMigrate commands each repair an existing record in place and must preserve its key -- Upsert's own cache.UpsertAsync(document) cannot take a caller-supplied key, so all three wrote raw and unvalidated through `db.Cache.Commit(batch => batch.Upsert(document.GetType(), document, key))`. `brownout-migrate apply` is how the shipped catalog's PowerSupply terms were actually authored, unvalidated, the whole time. Extracted Upsert's validation body into CultRecordRefs.Validate(object), and call it directly from all three tool commands immediately before their own identity-preserving commit -- the same rules, invoked where Upsert itself cannot reach. The comment above StatValidation naming the write-bypass holes (landed with F6) now names all four instead of two: CultCache Studio's generic editor (still open -- not this repository's to patch) plus the three tool commands above (closed). Pinned in tests/Aetheria.Shared.Tests/AetheriaStoresTests.cs: ValidateRefusesAnEquippableItemWithAZeroSpanHeatRangeTheSameWayUpsertWould and ValidateRefusesAPowerRequestCarryingAPowerSupplyTermTheSameWayUpsertWould pin that the extracted method actually refuses what Upsert would (fail under a reverted Validate that is a no-op); ValidateIsANoOpForADocumentTypeItDoesNotGovern pins that a document type it has no opinion about doesn't crash it. Verified tools/AetherDb/AetherDb.csproj still builds clean against the pinned CultLib worktree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:47:31 +02:00
}
// 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);
Cut 1: close S5/S6's validation gaps, pin S7's surviving mutants StatValidation.ValidateHeatResponse now refuses NaN in any of the four heat-response fields, a zero-span range (Minimum == Maximum -- Tractor Beam's exact bug), and a plateau whose low or high edge pokes past its bounds, matching the R-heat ruling and the Performance() comment that already claimed this was enforced. The writable-path hole is closed for every path this repo owns: CultRecordRefs.Upsert (AetheriaStores.cs), the one function every tool, test and migration script writes catalog documents through, validates before a write reaches disk instead of waiting for the next Open(). CultCache Studio's generic editor bypasses Upsert and still has no per-document validation hook -- that hole is CultLib's, not this repo's, and stays named in the code rather than silently assumed closed. EquippableItemData's four heat fields get non-degenerate defaults (0/100/50/20, the same shape HeatResponseTests already hand-authors) because dozens of existing test fixtures across the suite build designs that never cared about heat and defaulted into the same zero-span trap Tractor Beam shipped with; enforcing the zero-span refusal without this would have required touching every one of those fixtures instead of the root cause. Four new mutations pin S7's survivors, each with its own dedicated test rather than a coincidentally-sensitive existing one: out-of-bounds semantics (return 1f instead of 0f outside bounds), the plateau-clamp validation's two sides, and a Quality term ignoring its own exponent (StatsReadTheLot's Exponent = 1 everywhere could not tell "reads the exponent" from "always uses 1"; the new test uses Exponent = 2). Performance()'s own runtime plateau clamp is separately confirmed dead code -- the outer bounds guard makes it unobservable at every input, verified by an exhaustive numeric sweep -- and is recorded as such rather than forced into a fake kill. 80 tests (9 new), 17 mutation cases in tests/mutation_tests_stats_power_cut1.py, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:42:10 +02:00
return new CultRecordRef<T>(cache.UpsertAsync(document).Result.Key);
}
Move the ServerShared data model onto CultLib's CultCache Cut 8b. Aetheria.Shared builds headless against GameCult.Caching and GameCult.Caching.MessagePack at the pinned CultLib; the pin guard also checks the Caching project exists. Documents: [CultDocument("aetheria.<type>", "1")] with [MessagePackObject] and integer [Key]s on the Q7 concrete types (the eight ItemData leaves, Faction, FactionProductData, PersonalityAttribute, NameFile, OrbitData, the four BodyData leaves, SavedZone, SavedGame, PlayerSettings, InputLayout). SavedGame and PlayerSettings are [CultGlobal]; Name members are [CultName]. ItemData, CraftedItemData, EquippableItemData, BodyData and AgentTask lose [MessagePackObject]; BodyData and AgentTask lose their [Union]s. InputLayout moves into ServerShared so AetheriaStores can route it. References: Guid and DatabaseLink<T> members become CultRecordRef<T>; Guid-keyed maps become ref-keyed with [CultReference(..., many: true)]; ZonePack holds refs to orbit and body records. Runtime maps (Zone orbits, bodies and belts, cargo ItemsOfType, agent targets) key by CultRecordKey. Identity is the record key: ID comparisons become reference or key comparisons. StatModifierData.RequireBehavior is a simple type name. AetheriaStores.Open composes the catalog (read-only unless writable), run and player stores, one home per type. CultRecordRefs carries IsSet, a null-safe Get(ref), RefOf (key from TryGetHandle) and Upsert. ItemManager.GetData is the one item resolution path. MathResolver is declared by [assembly: CultCacheFormatterResolver]. Inspector attributes: text, range and asset path map onto CultLib's CultInspector attributes (asset path only on string members); every replaced Aetheria attribute keeps [Inspectable] for the runtime panel. Builds: Aetheria.Shared. Does not build: AetherDb and the Unity project, which follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:00:42 +02:00
}