From ab2b3ef643b25a2aa034f4526ae31d2f5a4a0148 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 28 Jun 2026 16:17:38 +0100 Subject: [PATCH] Add CultMesh feature implementation guide --- docs/cultmesh-feature-implementation-guide.md | 855 ++++++++++++++++++ docs/developer-navigation.md | 6 +- 2 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 docs/cultmesh-feature-implementation-guide.md diff --git a/docs/cultmesh-feature-implementation-guide.md b/docs/cultmesh-feature-implementation-guide.md new file mode 100644 index 000000000..e08b1759b --- /dev/null +++ b/docs/cultmesh-feature-implementation-guide.md @@ -0,0 +1,855 @@ +# Implementing Aetheria Features With CultMesh + +Date: 2026-06-28 + +This guide explains the preferred path for adding a new daemon-owned simulation +state feature and consuming it from Unity. It is written for the post-Stage-7 +Aetheria shape: the daemon owns simulation truth, CultMesh owns typed state +access and sync, and Unity is a renderer/input client. + +The ergonomic rule is simple: + +```text +daemon simulation -> typed document -> AetheriaClientState -> Unity reactive typed document +Unity input -> typed operation -> daemon command apply -> next typed document +``` + +Callers should not walk record keys, schemas, frame rows, local facade indexes, +or protocol layers to get one gameplay fact. If a gameplay/UI caller needs a +state value, it should hold a managed reactive typed document or a named typed +client handle. If a renderer needs hot entity data, it should use the daemon +SoA/native view path. + +## Vocabulary + +`CultMeshDocumentHandle` + +The named typed document handle exposed by the Aetheria client facade. Handles +own the document identity, schema identity, routing, projection source metadata, +and latest/reactive access. + +`CultMeshReactiveDocument` + +The managed reactive typed document a client holds while it needs live state. +Use this directly in Unity callers for single-document state. Dispose it with +the component or binding lifetime. + +`LatestAsync()` + +One-shot read. Use in async bootstrap, tests, and non-frame-blocking setup. + +`Reactive()` + +Live read. Use for Unity presentation state, HUDs, panels, render settings, and +anything that should update as the daemon publishes. + +`AetheriaRuntimeDaemonFrameDocument` + +The broad authoritative frame publication. It is a source for projected client +documents, not the normal public client API. + +`AetheriaRuntimeDaemonSoaViewDocument` + +The high-performance current-zone slab descriptor. Use this for render/physics +hot paths, not for ordinary UI. + +`AetheriaRuntimeDaemonCommandDocument` + +The typed daemon command envelope. Unity should submit through `AetheriaControl` +or `AetheriaRuntimeDaemonOperationClient`, not manually construct raw command +documents in presentation code. + +## Choosing The Shape + +Before adding a feature, choose one of these shapes. + +Use a direct typed document when: + +- the value is moderate size; +- clients need latest/watch semantics; +- the data is useful across Unity, RTS, Eve, tests, or tooling; +- the data changes at daemon tick or command-application cadence. + +Use a viewport/query document when: + +- the data depends on a camera, zone, entity, selection, or other request key; +- clients should not receive the whole world; +- the result is still ordinary structured data. + +Use SoA/native view when: + +- the data is hot per-frame render/physics data; +- Unity jobs/Burst/Ymir need columnar access; +- row count is large enough that object graphs are the wrong shape. + +Use a daemon command operation when: + +- Unity/RTS input asks the daemon to mutate simulation truth; +- the operation needs authority, idempotency, acceptance, rejection, or frame + accounting. + +Do not add: + +- Unity-local gameplay truth; +- new `AetheriaRuntime*Session` wrappers for single-document access; +- public string command names plus payload dictionaries; +- UI code that reads daemon frames and joins rows by hand; +- helper names that hide multi-hop state access behind "facade" or "projector" + unless the class is a temporary render adapter fenced by the verifier. + +## Example Feature + +This guide uses a concrete example: `ZoneDefenseStatus`. + +The daemon computes a per-current-zone defense status: + +- current zone index; +- base shield ratio; +- incoming hostile count; +- active turret count; +- current alert level. + +Unity reads this document to show HUD/UI state. Unity can also submit a typed +operation to set the desired alert level. The daemon applies that operation and +publishes a new status on the next tick. + +The same pattern applies to heat support state, station service state, mission +wave status, scenario progression, commander infrastructure state, or any other +simulation feature. + +## Step 1: Define The Typed Document + +Add the document to the runtime package, because Unity, daemon, tests, and RTS +all need the same type: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs +``` + +For larger features, create a dedicated file in the same package and keep the +schema constant in `AetheriaRuntimeDaemonSchemas`. + +```csharp +public static class AetheriaRuntimeDaemonSchemas +{ + public const string ZoneDefenseStatus = + "gamecult.aetheria.zone_defense_status.v1"; +} + +[MessagePackObject] +[CultDocument(AetheriaRuntimeDaemonSchemas.ZoneDefenseStatus)] +public sealed class AetheriaRuntimeZoneDefenseStatusDocument +{ + [Key(0)] + public int ZoneIndex { get; set; } = -1; + + [Key(1)] + public double BaseShieldRatio { get; set; } + + [Key(2)] + public int IncomingHostileCount { get; set; } + + [Key(3)] + public int ActiveTurretCount { get; set; } + + [Key(4)] + public string AlertLevel { get; set; } = ""; +} +``` + +Rules: + +- Give every document a stable `gamecult.aetheria.*.v1` schema. +- Use explicit `[Key]` slots. Never renumber existing slots. +- Add new fields at the end. +- Default values must produce a safe empty document. +- Prefer primitive/string/array/document-row types that cross runtimes cleanly. +- If RTS/Electron consumes it, regenerate bindings after changing slots. + +## Step 2: Register The Document Type + +Add the document to the registry: + +```text +Aetheria.State/AetheriaDocumentRegistry.cs +``` + +```csharp +typeof(AetheriaRuntimeZoneDefenseStatusDocument), +``` + +This lets CultCache/CultNet/CultMesh know how to serialize, deserialize, and +bind the type across the local state node and network-facing surfaces. + +## Step 3: Decide Whether It Is Source State Or Projected State + +Most Unity-consumed simulation state is projected from daemon-owned run/frame +state. That is the right default: the daemon tick owns the authoritative +simulation, and CultMesh exposes a typed document derived from it. + +Use a projected document when the state can be derived from the latest frame: + +```text +latest daemon frame -> ZoneDefenseStatus document +``` + +Use a direct mutable/source document when the state is an independent domain +object such as settings, scenario config, player seat state, or a durable +session record. + +For the example, `ZoneDefenseStatus` is projected from the frame. + +## Step 4: Add The Projection Function + +Projection code should live in the runtime package near related runtime +projection code: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs +``` + +Example: + +```csharp +public static AetheriaRuntimeZoneDefenseStatusDocument ProjectZoneDefenseStatus( + AetheriaRuntimeDaemonFrameDocument frame) +{ + var zone = frame?.Zones? + .FirstOrDefault(candidate => candidate != null && + candidate.ZoneIndex == frame.CurrentZoneIndex); + + if (zone == null) + return new AetheriaRuntimeZoneDefenseStatusDocument(); + + return new AetheriaRuntimeZoneDefenseStatusDocument + { + ZoneIndex = zone.ZoneIndex, + BaseShieldRatio = ComputeBaseShieldRatio(zone), + IncomingHostileCount = CountIncomingHostiles(zone), + ActiveTurretCount = CountActiveTurrets(zone), + AlertLevel = ResolveAlertLevel(zone) + }; +} +``` + +Guidelines: + +- Keep projection deterministic. +- Do not read Unity objects. +- Do not mutate the frame. +- Keep heavy per-frame render data out of this path; publish SoA columns + instead. +- If projection needs catalog/loadout/session input, pass it explicitly from + `AetheriaRuntimeManagedClientInputs`, as station refit and Starbridge summary + already do. + +## Step 5: Publish The Document Through The Verse Client + +Expose the projected document in: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs +``` + +In the `CreateClientState` document list, add a `ProjectedDocument`: + +```csharp +ProjectedDocument( + "aetheria.zone.defense_status", + frame => Task.FromResult( + AetheriaRuntimeRtsProjection.ProjectZoneDefenseStatus(frame)), + AetheriaRuntimeDaemonSchemas.ZoneDefenseStatus), +``` + +This is where CultMesh gets the important metadata: + +- document id; +- Verse/runtime identity; +- schema id; +- projection sources; +- latest read; +- watch stream; +- route hint. + +Do not make Unity call `ProjectZoneDefenseStatus` directly. Unity asks +CultMesh for the typed document. CultMesh owns the managed sync path. + +## Step 6: Add The Handle To `AetheriaClientState` + +Add a `CultMeshDocumentHandle` to: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs +``` + +Constructor parameter: + +```csharp +CultMeshDocumentHandle zoneDefenseStatus, +``` + +Property: + +```csharp +public CultMeshDocumentHandle + ZoneDefenseStatus { get; } +``` + +Assignment: + +```csharp +ZoneDefenseStatus = zoneDefenseStatus + ?? throw new ArgumentNullException(nameof(zoneDefenseStatus)); +``` + +Register it in the document catalog: + +```csharp +_documents = CultMesh.Documents( + ... + ZoneDefenseStatus, + ... +); +``` + +Convenience methods are fine when they preserve the typed shape: + +```csharp +public Task + LatestZoneDefenseStatusAsync() +{ + return ZoneDefenseStatus.LatestAsync(); +} + +public CultMeshReactiveDocument + ReactiveZoneDefenseStatus(CultMeshReactiveDocumentOptions? options = null) +{ + return ZoneDefenseStatus.Reactive(options); +} +``` + +Avoid introducing a `AetheriaRuntimeZoneDefenseStatusSession` unless it composes +multiple documents and genuinely owns aggregate behavior. A single reactive +document should remain a single reactive document. + +## Step 7: Add A Typed Unity Read + +In Unity, hold the reactive typed document for the component lifetime. + +```csharp +using GameCult.Aetheria.State.Verse; +using GameCult.Mesh; +using UnityEngine; + +public sealed class ZoneDefenseHud : MonoBehaviour +{ + private string _clientStatePath = ""; + private CultMeshReactiveDocument + _zoneDefenseStatus; + + private void Update() + { + var status = ResolveZoneDefenseStatus()?.Current; + if (status == null) + return; + + RenderAlertLevel(status.AlertLevel); + RenderHostiles(status.IncomingHostileCount); + RenderShield(status.BaseShieldRatio); + } + + private CultMeshReactiveDocument + ResolveZoneDefenseStatus() + { + if (_zoneDefenseStatus != null) + return _zoneDefenseStatus; + + try + { + _zoneDefenseStatus = ResolveClient() + .State + .ReactiveZoneDefenseStatus(); + } + catch (Exception ex) + { + Debug.LogWarning( + $"Failed to bind Aetheria zone defense status: {ex.Message}"); + } + + return _zoneDefenseStatus; + } + + private AetheriaClient ResolveClient() + { + var stateBoot = AetheriaRuntimeStateBoot.Inspect( + AetheriaUnityRuntimePaths.GameDataDirectory); + if (!string.Equals(_clientStatePath, stateBoot.StateFilePath, + StringComparison.Ordinal)) + { + _clientStatePath = stateBoot.StateFilePath; + ClearStateDocuments(); + } + + return AetheriaUnityRuntimeClientProvider.ResolveClient( + stateBoot, + "unity-zone-defense-hud"); + } + + private void ClearStateDocuments() + { + _zoneDefenseStatus?.Dispose(); + _zoneDefenseStatus = null; + } + + private void OnDestroy() + { + ClearStateDocuments(); + } +} +``` + +Unity rules: + +- Store `CultMeshReactiveDocument` fields, not sessions, for single docs. +- Dispose reactive documents. +- Recreate them when the client state path changes. +- Use `.Current` in `Update()` or render methods. +- Prefer `LatestAsync()` during async setup; avoid blocking reads on hot paths. +- Do not read `AetheriaRuntimeDaemonFrameDocument` unless the class is an + internal render adapter or diagnostic. +- Do not project daemon state in Unity UI code. + +## Step 8: Add A Typed Operation For Interaction + +If Unity needs to interact with the state, add a typed daemon command path. + +First extend command kinds and operation ids: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs +``` + +```csharp +public enum AetheriaRuntimeDaemonCommandKinds +{ + ... + SetZoneAlertLevel = 42 +} +``` + +Add payload slots to `AetheriaRuntimeDaemonCommandDocument`: + +```csharp +[Key(N)] +public string ZoneAlertLevel { get; set; } = ""; +``` + +Then add a typed operation method: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaControl.cs +``` + +Example public shape: + +```csharp +public AetheriaRuntimeDaemonCommandEnvelope SetZoneAlertLevel( + string alertLevel) +{ + return Submit(command => + { + command.Kind = AetheriaRuntimeDaemonCommandKinds.SetZoneAlertLevel; + command.ZoneAlertLevel = alertLevel ?? ""; + }); +} +``` + +Unity should call the typed operation: + +```csharp +Client.Control.SetZoneAlertLevel("red"); +``` + +Unity should not call: + +```csharp +Submit("set_zone_alert_level", new Dictionary { ... }); +``` + +## Step 9: Apply The Operation In The Daemon + +Apply commands in: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +``` + +The apply path should: + +- validate authority/session/frame assumptions; +- validate command payload; +- mutate the run/checkpoint/intents; +- return applied or rejected command ids; +- leave the next tick to publish the derived document. + +Sketch: + +```csharp +case AetheriaRuntimeDaemonCommandKinds.SetZoneAlertLevel: + if (!TrySetZoneAlertLevel(run, command.ZoneAlertLevel, out var diagnostic)) + { + Reject(command, diagnostic); + break; + } + + Accept(command); + break; +``` + +Prefer command effects that update daemon-owned run state or intent state. Do +not mutate Unity objects. Do not make the Unity caller update its own UI state +optimistically unless that optimism goes through the managed reactive document +prediction/reconciliation path. + +## Step 10: Publish Hot Render Data Through SoA When Needed + +If the new feature is needed for thousands of entities every frame, do not add +large arrays to a HUD-style document. Publish columns in: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs +``` + +Then consume through: + +```text +Assets/Scripts/Gameplay/AetheriaDaemonObserver.cs +Assets/Scripts/Gameplay/AetheriaDaemonRenderNativeView.cs +Assets/Scripts/Zone Display/ZoneRenderer.cs +``` + +Use SoA for data like: + +- entity transforms; +- render group; +- visibility; +- physics body columns; +- LOD; +- mass/radius/inverse mass; +- per-entity heat or shield values if the renderer needs them in bulk. + +Use ordinary typed documents for data like: + +- selected object; +- current docking; +- station services; +- HUD summary; +- scenario state; +- command boundary; +- player settings. + +## Step 11: Add Tests + +At minimum, add or extend tests in: + +```text +Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +``` + +Cover: + +- schema document construction; +- projection from a representative daemon frame; +- `AetheriaClientState.Document()` or named handle access; +- `Reactive()` current value behavior; +- command creation; +- command application; +- rejected command behavior if validation matters. + +For a projected document, a test should prove: + +```csharp +var status = client.State.Latest(); +Assert.AreEqual(expected, status.AlertLevel); +``` + +If the document is TS-visible, regenerate and verify RTS bindings: + +```powershell +cd .\Aetheria.Rts.Web +npm run generate:rts-bindings +npm run check:rts-bindings +``` + +## Step 12: Add Verifier Coverage + +Add migration fences to: + +```text +Aetheria.State.Verify/Program.cs +``` + +Verifier coverage should assert the desired ergonomic shape and forbid the old +path: + +```csharp +if (!zoneDefenseHud.Contains( + "CultMeshReactiveDocument", + StringComparison.Ordinal) || + !zoneDefenseHud.Contains(".ReactiveZoneDefenseStatus()", + StringComparison.Ordinal)) +{ + throw new InvalidOperationException( + "ZoneDefenseHud must read zone defense through a managed reactive typed document."); +} + +if (zoneDefenseHud.Contains("AetheriaRuntimeZoneDefenseStatusSession", + StringComparison.Ordinal) || + zoneDefenseHud.Contains("AetheriaRuntimeRtsProjection.ProjectZoneDefenseStatus", + StringComparison.Ordinal) || + zoneDefenseHud.Contains("AetheriaRuntimeDaemonFrameDocument", + StringComparison.Ordinal)) +{ + throw new InvalidOperationException( + "ZoneDefenseHud still reconstructs zone defense state instead of reading the typed CultMesh document."); +} +``` + +The verifier should remember architecture, not implementation trivia. Guard +against: + +- Unity reading raw frames for ordinary state; +- Unity calling projection helpers; +- new single-document session wrappers; +- public string command/payload APIs; +- reintroduced local save files for daemon-owned state; +- renderer-local facade indexes outside render adapter internals. + +## Step 13: Run The Gates + +Minimum lane for a C# runtime/Unity state feature: + +```powershell +dotnet build .\Aetheria.Shared.Unity.csproj --no-restore --nologo -v:quiet +dotnet run --project .\Aetheria.State.Verify\Aetheria.State.Verify.csproj --no-restore +``` + +If daemon authority or command behavior changed: + +```powershell +dotnet run --project .\Aetheria.State.AuthoritySmoke\Aetheria.State.AuthoritySmoke.csproj --no-restore +``` + +If document slots consumed by RTS changed: + +```powershell +cd .\Aetheria.Rts.Web +npm run generate:rts-bindings +npm run check:rts-bindings +``` + +If SoA/native view changed: + +```powershell +dotnet build .\GameCult.Aetheria.State.Unity.csproj --no-restore -v:minimal +dotnet build .\Aetheria.State.Unity.Smoke\Aetheria.State.Unity.Smoke.csproj --no-restore -v:minimal +``` + +The verifier often updates `GameData/aetheria-world.cc`. Restore it unless the +state seed was intentionally regenerated: + +```powershell +git restore -- GameData/aetheria-world.cc +``` + +## Ergonomic CultMesh Checklist + +Use this checklist before opening a PR. + +- The feature has a typed document or typed operation. +- The document has a stable schema id. +- The document type is registered. +- The daemon owns mutation. +- Projection is daemon/runtime code, not Unity UI code. +- Unity reads with `CultMeshReactiveDocument` for single-document state. +- Unity uses SoA/native views for hot render/physics state. +- Unity submits interaction through typed `AetheriaControl` or operation client + methods. +- No Unity caller manually joins daemon frame rows, record keys, schema slots, + and facade indexes to get one value. +- No new public string command names or payload dictionaries. +- No new `AetheriaRuntime*Session` wrapper exists just to expose `.Current`. +- Tests cover projection/read/write behavior. +- Verifier blocks the old path. +- Generated bindings are refreshed when public document slots change. + +## Anti-Patterns + +Do not do this: + +```csharp +var frame = client.State.LatestDaemonFrame(); +var row = frame.Zones[0].Entities.First(entity => entity.EntityKey == key); +var unityEntity = observedIndex.TryResolveEntityByRecordKey(row.EntityKey, out var e) + ? e + : null; +``` + +Do this: + +```csharp +using var selected = client.State + .Details + .ReactiveSelectedObject(entityIndex); +Render(selected.Current); +``` + +Do not do this: + +```csharp +using var session = client.State.ObserveCatalog(); +var item = session.Current.FindItem(itemKey); +``` + +For a single document, do this: + +```csharp +using var catalog = client.State.ReactiveCatalog(); +var item = catalog.Current?.FindItem(itemKey); +``` + +Do not do this: + +```csharp +Submit("set-alert-level", new Dictionary +{ + ["level"] = "red" +}); +``` + +Do this: + +```csharp +client.Control.SetZoneAlertLevel("red"); +``` + +Do not do this: + +```csharp +var status = AetheriaRuntimeRtsProjection.ProjectZoneDefenseStatus(frame); +``` + +in Unity presentation code. + +Do this: + +```csharp +using var status = client.State.ReactiveZoneDefenseStatus(); +``` + +## Where To Put Things + +Typed documents and schema constants: + +```text +Packages/org.gamecult.aetheria.state/Runtime/ +``` + +Registry: + +```text +Aetheria.State/AetheriaDocumentRegistry.cs +``` + +Projection functions: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeRtsProjection.cs +``` + +CultMesh document publication: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs +``` + +Client accessors: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaClientState.cs +``` + +Operations: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonDocuments.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperationClient.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaControl.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonOperations.cs +``` + +Daemon tick/publications: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonTickRunner.cs +``` + +SoA/native render state: + +```text +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaDocuments.cs +Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSoaFramePublisher.cs +Assets/Scripts/Gameplay/AetheriaDaemonRenderNativeView.cs +``` + +Unity callers: + +```text +Assets/Scripts/ +``` + +Tests: + +```text +Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +Aetheria.State.AuthoritySmoke/ +Aetheria.State.Unity.Smoke/ +``` + +Verifier: + +```text +Aetheria.State.Verify/Program.cs +``` + +## Final Mental Model + +CultMesh should make remote/stateful gameplay feel local without making clients +pretend they own authority they do not have. + +For reads, the caller names the state and receives a typed value: + +```csharp +using var status = client.State.ReactiveZoneDefenseStatus(); +Render(status.Current); +``` + +For writes, the caller names the domain action and receives an operation +receipt/command envelope: + +```csharp +var receipt = client.Control.SetZoneAlertLevel("red"); +``` + +For hot rendering, the caller asks for a native view: + +```csharp +var view = observer.LastObservedState?.SoaIndex; +``` + +Everything between those calls and the daemon is CultMesh's job: routing, +replication, cache hydration, projection updates, schema identity, local versus +network access, and eventually prediction/reconciliation. Aetheria code should +read like Aetheria, not like a protocol walk. diff --git a/docs/developer-navigation.md b/docs/developer-navigation.md index 771dfcf58..d6a4474d2 100644 --- a/docs/developer-navigation.md +++ b/docs/developer-navigation.md @@ -28,7 +28,10 @@ Read these in this order: Surface inventory and edit queue for Unity/Electron/client boundaries. 5. `docs/aetheria-verse-client-contract.md` Intended client contract: use typed Verse state, not daemon internals. -6. `Aetheria.State/docs/verse-authority-implementation-plan.md` +6. `docs/cultmesh-feature-implementation-guide.md` + Step-by-step guide for adding daemon-owned typed state, publishing it through + CultMesh, and consuming/interacting with it from Unity. +7. `Aetheria.State/docs/verse-authority-implementation-plan.md` Authority and Starbridge staged implementation map. Longer background/reference docs: @@ -500,4 +503,3 @@ command document -> daemon apply -> projected result ``` If a path does not fit that shape, it is probably transitional debt or a bug. -