diff --git a/.voidbot/state/aetheria.cc b/.voidbot/state/aetheria.cc index d81b74d05..d48e63357 100644 Binary files a/.voidbot/state/aetheria.cc and b/.voidbot/state/aetheria.cc differ diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index 1d4d15a83..db52081a8 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -103,6 +103,7 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s }; await node.PutRuntimeCommitDrainStatusAsync(drainStatus); await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(drainStatus)); + await node.PutProviderAdvertisementAsync(AetheriaProviderAdvertisementProjector.Build(statePath, now)); await node.PutLoadoutTemplateAsync(loadoutKey, new AetheriaLoadoutTemplate { @@ -256,6 +257,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether var catalogSurface = await reopened.GetCatalogSurfaceAsync(); var drainStatus = await reopened.GetRuntimeCommitDrainStatusAsync(); var operationsSurface = await reopened.GetOperationsSurfaceAsync(); + var advertisement = await reopened.GetProviderAdvertisementAsync(); var playerSettings = await reopened.GetPlayerSettingsAsync(); var loadout = await reopened.GetLoadoutTemplateAsync(loadoutKey); var runState = await reopened.GetRunStateAsync(runKey); @@ -300,6 +302,14 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether throw new InvalidOperationException("Runtime commit drain status or operations surface did not survive flush/reopen."); } + if (advertisement?.ProviderId != AetheriaProviderAdvertisementProjector.ProviderId || + advertisement.Surfaces.Length < 2 || + !advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaCatalogSurfaceProjector.SurfaceId) || + !advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaOperationsSurfaceProjector.SurfaceId)) + { + throw new InvalidOperationException("Aetheria Eve provider advertisement did not survive flush/reopen."); + } + if (playerSettings?.ActiveRunKey != runKey.ToString() || playerSettings.PlayerName != "Smoke Pilot" || !playerSettings.TutorialPassed || diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index c4fda9c2f..cb4095e23 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -38,7 +38,8 @@ public static class AetheriaDocumentRegistry CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), CultNetDocumentBinding.ForDocument(registry), - CultNetDocumentBinding.ForDocument(registry) + CultNetDocumentBinding.ForDocument(registry), + CultNetDocumentBinding.ForDocument(registry) }); } @@ -58,6 +59,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaZoneState), typeof(AetheriaEntitySnapshot), typeof(AetheriaRuntimeCommitDrainStatus), - typeof(EveSurfaceState) + typeof(EveSurfaceState), + typeof(EveProviderAdvertisementState) ]; } diff --git a/Aetheria.State/AetheriaProviderAdvertisementProjector.cs b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs new file mode 100644 index 000000000..66f60ce10 --- /dev/null +++ b/Aetheria.State/AetheriaProviderAdvertisementProjector.cs @@ -0,0 +1,81 @@ +using Aetheria.State.Documents; + +namespace Aetheria.State; + +public static class AetheriaProviderAdvertisementProjector +{ + public const string AdvertisementKey = "eve:provider:aetheria"; + public const string ProviderId = "aetheria"; + + public static EveProviderAdvertisementState Build(string statePath, string updatedAtUtc) + { + return new EveProviderAdvertisementState + { + ProviderId = ProviderId, + ServiceId = "aetheria.runtime", + VerseId = "aetheria.local", + RootVerse = "asgard", + CanonicalService = "asgard.aetheria", + LocatedService = "asgard.local.aetheria", + CultMeshAddress = "asgard.local.aetheria/eve", + Title = "Aetheria", + Kind = "game.runtime", + UpdatedAtUtc = updatedAtUtc, + Freshness = new EveProviderFreshness + { + State = "fresh", + LastSeenAtUtc = updatedAtUtc, + MaxAgeMs = 15000 + }, + Schemas = + [ + "aetheria.world_state.v1", + "aetheria.item_definition.v1", + "aetheria.corporation.v2", + "aetheria.name_file.v2", + "aetheria.player_settings.v1", + "aetheria.loadout_template.v1", + "aetheria.run_state.v1", + "aetheria.zone_state.v1", + "aetheria.entity_snapshot.v1", + "aetheria.runtime_commit_drain_status.v1", + "gamecult.eve.surface.v1" + ], + Witnesses = + [ + new EveProviderWitness + { + Kind = "cultcache", + Ref = statePath, + Summary = "Aetheria typed CultCache state file" + } + ], + Surfaces = + [ + new EveProviderSurfaceRef + { + SurfaceId = AetheriaCatalogSurfaceProjector.SurfaceId, + Key = AetheriaCatalogSurfaceProjector.SurfaceKey + }, + new EveProviderSurfaceRef + { + SurfaceId = AetheriaOperationsSurfaceProjector.SurfaceId, + Key = AetheriaOperationsSurfaceProjector.SurfaceKey + } + ], + Commands = + [ + new EveProviderCommandRef + { + Command = "aetheria.catalog.refresh", + Summary = "Refresh catalog projection" + }, + new EveProviderCommandRef + { + Command = "aetheria.operations.refresh", + Summary = "Refresh operations projection" + } + ] + }; + } +} diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 1944b168b..a1e3f07a8 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -183,6 +183,18 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable return Database.GetAsync(new CultRecordKey(AetheriaOperationsSurfaceProjector.SurfaceKey)); } + public Task> PutProviderAdvertisementAsync( + EveProviderAdvertisementState advertisement) + { + return Database.PutAsync(new CultRecordKey(AetheriaProviderAdvertisementProjector.AdvertisementKey), advertisement); + } + + public Task GetProviderAdvertisementAsync() + { + return Database.GetAsync( + new CultRecordKey(AetheriaProviderAdvertisementProjector.AdvertisementKey)); + } + public Task> PutPlayerSettingsAsync(AetheriaPlayerSettings settings) { return Database.PutAsync(new CultRecordKey("global:aetheria.player_settings.v1"), settings); diff --git a/Aetheria.State/Documents/EveProviderAdvertisementState.cs b/Aetheria.State/Documents/EveProviderAdvertisementState.cs new file mode 100644 index 000000000..f42cd1d1f --- /dev/null +++ b/Aetheria.State/Documents/EveProviderAdvertisementState.cs @@ -0,0 +1,118 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("gamecult.eve.provider_advertisement", "gamecult.eve.provider_advertisement.v1")] +[CultGlobal] +[MessagePackObject] +public sealed class EveProviderAdvertisementState +{ + [Key(0)] + [CultIndex("schema")] + public string Schema { get; set; } = "gamecult.eve.provider_advertisement.v1"; + + [Key(1)] + [CultName] + public string ProviderId { get; set; } = ""; + + [Key(2)] + public string ServiceId { get; set; } = ""; + + [Key(3)] + public string VerseId { get; set; } = ""; + + [Key(4)] + public string RootVerse { get; set; } = "asgard"; + + [Key(5)] + public string CanonicalService { get; set; } = ""; + + [Key(6)] + public string LocatedService { get; set; } = ""; + + [Key(7)] + public string CultMeshAddress { get; set; } = ""; + + [Key(8)] + public string Title { get; set; } = ""; + + [Key(9)] + public string Kind { get; set; } = "game.runtime"; + + [Key(10)] + public string UpdatedAtUtc { get; set; } = ""; + + [Key(11)] + public EveProviderFreshness Freshness { get; set; } = new(); + + [Key(12)] + public string[] Schemas { get; set; } = []; + + [Key(13)] + public EveProviderWitness[] Witnesses { get; set; } = []; + + [Key(14)] + public EveProviderSurfaceRef[] Surfaces { get; set; } = []; + + [Key(15)] + public EveProviderCommandRef[] Commands { get; set; } = []; +} + +[MessagePackObject] +public sealed class EveProviderFreshness +{ + [Key(0)] + public string State { get; set; } = "fresh"; + + [Key(1)] + public string LastSeenAtUtc { get; set; } = ""; + + [Key(2)] + public int MaxAgeMs { get; set; } = 15000; +} + +[MessagePackObject] +public sealed class EveProviderWitness +{ + [Key(0)] + public string Kind { get; set; } = ""; + + [Key(1)] + public string Ref { get; set; } = ""; + + [Key(2)] + public string Summary { get; set; } = ""; +} + +[MessagePackObject] +public sealed class EveProviderSurfaceRef +{ + [Key(0)] + public string Schema { get; set; } = "gamecult.eve.surface.v1"; + + [Key(1)] + public string SurfaceId { get; set; } = ""; + + [Key(2)] + public string Key { get; set; } = ""; + + [Key(3)] + public string Transport { get; set; } = "cultmesh"; + + [Key(4)] + public string Status { get; set; } = "available"; +} + +[MessagePackObject] +public sealed class EveProviderCommandRef +{ + [Key(0)] + public string Command { get; set; } = ""; + + [Key(1)] + public string Transport { get; set; } = "cultmesh"; + + [Key(2)] + public string Summary { get; set; } = ""; +} diff --git a/Aetheria.State/README.md b/Aetheria.State/README.md index 250fc2b33..75c542128 100644 --- a/Aetheria.State/README.md +++ b/Aetheria.State/README.md @@ -140,7 +140,9 @@ dotnet run --project .\Economy.Server\Economy.Server.csproj -- --apply-pending-o The server publishes `AetheriaRuntimeCommitDrainStatus` and the `aetheria.operations` Eve surface after each drain attempt, so pending depth, applied counts, failures, and timestamps are typed state rather than console-only -status. +status. It also publishes `gamecult.eve.provider_advertisement.v1` for the +`aetheria` provider, advertising the catalog and operations surfaces plus the +typed schemas witnessed by the local `.cc` state file. `Aetheria.State.ApplyPending` is the smaller local operator applicator for queued Unity runtime commits when the server host is not being used: diff --git a/Economy.Server/Program.cs b/Economy.Server/Program.cs index 3f9a51bcd..5802e05f7 100644 --- a/Economy.Server/Program.cs +++ b/Economy.Server/Program.cs @@ -130,6 +130,8 @@ internal static class Program { await node.PutRuntimeCommitDrainStatusAsync(status).ConfigureAwait(false); await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(status)).ConfigureAwait(false); + await node.PutProviderAdvertisementAsync( + AetheriaProviderAdvertisementProjector.Build(node.StatePath, status.LastPollAtUtc)).ConfigureAwait(false); await node.FlushAsync().ConfigureAwait(false); } diff --git a/docs/aetheria-perfect-machine-map.md b/docs/aetheria-perfect-machine-map.md index 7544db418..518497ba1 100644 --- a/docs/aetheria-perfect-machine-map.md +++ b/docs/aetheria-perfect-machine-map.md @@ -178,6 +178,11 @@ legacy catalog cache, and legacy UI paths should be migration-only or deleted. - `AetheriaRuntimeCommitDrainStatus` and the `aetheria.operations` Eve surface publish pending-drain health, pending depth, applied counts, failures, and timestamps as typed state. Console logs are notification-only. +- `AetheriaProviderAdvertisementProjector` publishes + `gamecult.eve.provider_advertisement.v1` for the `aetheria` provider, + advertising the catalog and operations surfaces, command boundaries, schemas, + and `.cc` witness path. This is the discovery map for Odin/Eve, not a health + page. - `AetheriaCatalogSurfaceProjector` now emits the first provider-owned Eve surface from typed catalog state. The importer materializes a `gamecult.eve.surface.v1` catalog operator document at @@ -542,6 +547,8 @@ First Aetheria surfaces to publish: and daemon polling, with `--apply-pending-once` for bounded operation. - Done: publish pending-drain health as typed CultCache state and an Eve operations surface. + - Done: publish an Eve provider advertisement so Odin/Eve can discover + Aetheria surfaces and command boundaries through typed state. - Done: stop legacy catalog pull/read paths from writing entries back to their source backing store. - Done: delete legacy catalog backing-store write/realtime APIs.