Publish runtime sessions as typed state
This commit is contained in:
parent
e0a39b2fce
commit
5c19d515f0
10 changed files with 145 additions and 28 deletions
Binary file not shown.
|
|
@ -17,6 +17,14 @@ var evePendingBefore = CountPendingEveCommands(statePath);
|
|||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
|
||||
await using var node = await AetheriaStateNode.OpenAsync(statePath, runtimeId);
|
||||
await node.PutRuntimeSessionAsync(new AetheriaRuntimeSession
|
||||
{
|
||||
RuntimeId = runtimeId,
|
||||
Role = "pending-drain",
|
||||
StartedAtUtc = now,
|
||||
LastSeenAtUtc = now,
|
||||
Status = "running"
|
||||
});
|
||||
var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(node, deleteApplied);
|
||||
var eveReport = await AetheriaEveCommandBridge.ApplyPendingAsync(node, deleteApplied);
|
||||
var commitStatus = new AetheriaRuntimeCommitDrainStatus
|
||||
|
|
@ -49,7 +57,16 @@ var eveStatus = new AetheriaEveCommandDrainStatus
|
|||
};
|
||||
await node.PutRuntimeCommitDrainStatusAsync(commitStatus);
|
||||
await node.PutEveCommandDrainStatusAsync(eveStatus);
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus));
|
||||
var completedSession = new AetheriaRuntimeSession
|
||||
{
|
||||
RuntimeId = runtimeId,
|
||||
Role = "pending-drain",
|
||||
StartedAtUtc = now,
|
||||
LastSeenAtUtc = DateTimeOffset.UtcNow.ToString("O"),
|
||||
Status = "completed"
|
||||
};
|
||||
await node.PutRuntimeSessionAsync(completedSession);
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, completedSession));
|
||||
await node.PutProviderAdvertisementAsync(AetheriaProviderAdvertisementProjector.Build(statePath, now));
|
||||
await node.FlushAsync();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ using GameCult.Aetheria.State.Unity;
|
|||
using GameCult.Eve.Surface;
|
||||
|
||||
var root = args.Length > 0 ? args[0] : Directory.GetCurrentDirectory();
|
||||
var statePath = AetheriaStatePaths.ResolveDefaultStatePath(root);
|
||||
var stateDirectory = Path.Combine(Path.GetTempPath(), "aetheria-state-smoke", Guid.NewGuid().ToString("N"));
|
||||
var statePath = Path.Combine(stateDirectory, "aetheria-world.cc");
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
var itemLegacyId = "smoke:aether-drive";
|
||||
var factionLegacyId = "smoke:faction";
|
||||
|
|
@ -106,6 +107,17 @@ 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.PutRuntimeSessionAsync(new AetheriaRuntimeSession
|
||||
{
|
||||
RuntimeId = "smoke-runtime",
|
||||
Role = "state-smoke",
|
||||
StartedAtUtc = now,
|
||||
LastSeenAtUtc = now,
|
||||
Status = "running"
|
||||
});
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(
|
||||
drainStatus,
|
||||
runtimeSession: await node.GetRuntimeSessionAsync("smoke-runtime")));
|
||||
|
||||
AetheriaRuntimeEveCommandLog.QueueCommand(
|
||||
statePath,
|
||||
|
|
@ -142,7 +154,10 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
|
|||
Status = eveCommandReport.RejectedCommands > 0 ? "rejected" : "ok"
|
||||
};
|
||||
await node.PutEveCommandDrainStatusAsync(eveCommandStatus);
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(drainStatus, eveCommandStatus));
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(
|
||||
drainStatus,
|
||||
eveCommandStatus,
|
||||
await node.GetRuntimeSessionAsync("smoke-runtime")));
|
||||
|
||||
await node.PutLoadoutTemplateAsync(loadoutKey, new AetheriaLoadoutTemplate
|
||||
{
|
||||
|
|
@ -298,6 +313,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
|
|||
var eveCommandStatus = await reopened.GetEveCommandDrainStatusAsync();
|
||||
var operationsSurface = await reopened.GetOperationsSurfaceAsync();
|
||||
var advertisement = await reopened.GetProviderAdvertisementAsync();
|
||||
var runtimeSession = await reopened.GetRuntimeSessionAsync("smoke-runtime");
|
||||
var playerSettings = await reopened.GetPlayerSettingsAsync();
|
||||
var loadout = await reopened.GetLoadoutTemplateAsync(loadoutKey);
|
||||
var runState = await reopened.GetRunStateAsync(runKey);
|
||||
|
|
@ -342,7 +358,8 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
|
|||
eveCommandStatus.AppliedCatalogRefreshes != 1 ||
|
||||
!eveCommandStatus.LastRejectedReason.Contains("not advertised", StringComparison.Ordinal) ||
|
||||
operationsSurface?.Surface.Id != AetheriaOperationsSurfaceProjector.SurfaceId ||
|
||||
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.eveCommandDrain"))
|
||||
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.eveCommandDrain") ||
|
||||
!operationsSurface.Surface.Root.Children.Any(child => child.Id == "aetheria.operations.runtimeSession"))
|
||||
{
|
||||
throw new InvalidOperationException("Runtime commit/Eve command drain status or operations surface did not survive flush/reopen.");
|
||||
}
|
||||
|
|
@ -351,11 +368,19 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether
|
|||
advertisement.Surfaces.Length < 2 ||
|
||||
!advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaCatalogSurfaceProjector.SurfaceId) ||
|
||||
!advertisement.Surfaces.Any(surface => surface.SurfaceId == AetheriaOperationsSurfaceProjector.SurfaceId) ||
|
||||
!advertisement.Schemas.Contains("aetheria.runtime_session.v1") ||
|
||||
!advertisement.Schemas.Contains(AetheriaEveCommandBridge.CommandSchema))
|
||||
{
|
||||
throw new InvalidOperationException("Aetheria Eve provider advertisement did not survive flush/reopen.");
|
||||
}
|
||||
|
||||
if (runtimeSession?.RuntimeId != "smoke-runtime" ||
|
||||
runtimeSession.Role != "state-smoke" ||
|
||||
runtimeSession.Status != "running")
|
||||
{
|
||||
throw new InvalidOperationException("Runtime session document did not survive flush/reopen.");
|
||||
}
|
||||
|
||||
if (playerSettings?.ActiveRunKey != runKey.ToString() ||
|
||||
playerSettings.PlayerName != "Smoke Pilot" ||
|
||||
!playerSettings.TutorialPassed ||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ public static class AetheriaOperationsSurfaceProjector
|
|||
public static EveSurfaceState Build(
|
||||
AetheriaRuntimeCommitDrainStatus drainStatus,
|
||||
AetheriaEveCommandDrainStatus? eveCommandStatus = null,
|
||||
AetheriaRuntimeSession? runtimeSession = null,
|
||||
long version = 1)
|
||||
{
|
||||
var updatedAtUtc = LatestTimestamp(drainStatus.LastPollAtUtc, eveCommandStatus?.LastPollAtUtc);
|
||||
var updatedAtUtc = LatestTimestamp(
|
||||
drainStatus.LastPollAtUtc,
|
||||
eveCommandStatus?.LastPollAtUtc,
|
||||
runtimeSession?.LastSeenAtUtc);
|
||||
return new EveSurfaceState
|
||||
{
|
||||
ProviderId = "aetheria",
|
||||
|
|
@ -62,7 +66,18 @@ public static class AetheriaOperationsSurfaceProjector
|
|||
("lastAccepted", eveCommandStatus?.LastAcceptedAtUtc ?? ""),
|
||||
("lastRejected", eveCommandStatus?.LastRejectedCommand ?? ""),
|
||||
("rejectedReason", eveCommandStatus?.LastRejectedReason ?? ""),
|
||||
("error", eveCommandStatus?.LastError ?? ""))))
|
||||
("error", eveCommandStatus?.LastError ?? ""))),
|
||||
Node(
|
||||
"aetheria.operations.runtimeSession",
|
||||
"card",
|
||||
[("title", "Runtime Session")],
|
||||
Metric("runtimeSession.status", "Status", runtimeSession?.Status ?? "missing"),
|
||||
Metric("runtimeSession.role", "Role", runtimeSession?.Role ?? ""),
|
||||
Row(
|
||||
"runtimeSession.last",
|
||||
("runtime", runtimeSession?.RuntimeId ?? ""),
|
||||
("started", runtimeSession?.StartedAtUtc ?? ""),
|
||||
("lastSeen", runtimeSession?.LastSeenAtUtc ?? ""))))
|
||||
},
|
||||
Commands =
|
||||
[
|
||||
|
|
@ -76,14 +91,19 @@ public static class AetheriaOperationsSurfaceProjector
|
|||
};
|
||||
}
|
||||
|
||||
private static string LatestTimestamp(string first, string? second)
|
||||
private static string LatestTimestamp(params string?[] timestamps)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(second))
|
||||
return first;
|
||||
if (string.IsNullOrWhiteSpace(first))
|
||||
return second;
|
||||
var latest = "";
|
||||
foreach (var timestamp in timestamps)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(timestamp) &&
|
||||
(string.IsNullOrWhiteSpace(latest) || string.CompareOrdinal(timestamp, latest) > 0))
|
||||
{
|
||||
latest = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return string.CompareOrdinal(second, first) > 0 ? second : first;
|
||||
return latest;
|
||||
}
|
||||
|
||||
private static EveSurfaceComponent Metric(string id, string label, string value)
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ public static class AetheriaProviderAdvertisementProjector
|
|||
"aetheria.run_state.v1",
|
||||
"aetheria.zone_state.v1",
|
||||
"aetheria.entity_snapshot.v1",
|
||||
"aetheria.runtime_session.v1",
|
||||
"aetheria.runtime_commit_drain_status.v1",
|
||||
"aetheria.eve_command_drain_status.v1",
|
||||
"gamecult.eve.surface.v1",
|
||||
|
|
|
|||
|
|
@ -195,6 +195,17 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable
|
|||
new CultRecordKey(AetheriaProviderAdvertisementProjector.AdvertisementKey));
|
||||
}
|
||||
|
||||
public Task<CultRecordHandle<AetheriaRuntimeSession>> PutRuntimeSessionAsync(AetheriaRuntimeSession session)
|
||||
{
|
||||
return Database.PutAsync(new CultRecordKey($"runtime:{session.RuntimeId}:aetheria.runtime_session.v1"), session);
|
||||
}
|
||||
|
||||
public Task<AetheriaRuntimeSession?> GetRuntimeSessionAsync(string runtimeId)
|
||||
{
|
||||
return Database.GetAsync<AetheriaRuntimeSession>(
|
||||
new CultRecordKey($"runtime:{runtimeId}:aetheria.runtime_session.v1"));
|
||||
}
|
||||
|
||||
public Task<CultRecordHandle<AetheriaPlayerSettings>> PutPlayerSettingsAsync(AetheriaPlayerSettings settings)
|
||||
{
|
||||
return Database.PutAsync(new CultRecordKey("global:aetheria.player_settings.v1"), settings);
|
||||
|
|
|
|||
|
|
@ -20,4 +20,7 @@ public sealed class AetheriaRuntimeSession
|
|||
|
||||
[Key(3)]
|
||||
public string LastSeenAtUtc { get; set; } = "";
|
||||
|
||||
[Key(4)]
|
||||
public string Status { get; set; } = "running";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ are migration inputs only.
|
|||
|
||||
The typed runtime state model is split into player settings, loadout templates,
|
||||
run state, zone state, entity snapshots, item slots, weapon groups, action-bar
|
||||
bindings, and stat grids. Runtime checkpoints include current action-bar
|
||||
bindings as stable typed targets: input control path, binding kind, item
|
||||
definition reference, equipment index, behavior index, or weapon-group index.
|
||||
They also include current faction relationship rows as typed corporation
|
||||
references plus relationship tokens and numeric standings.
|
||||
bindings, stat grids, and runtime session documents. Runtime checkpoints
|
||||
include current action-bar bindings as stable typed targets: input control
|
||||
path, binding kind, item definition reference, equipment index, behavior index,
|
||||
or weapon-group index. They also include current faction relationship rows as
|
||||
typed corporation references plus relationship tokens and numeric standings.
|
||||
Player settings include player name, tutorial flag,
|
||||
story-file hash cursors, gameplay formatting, graphics preferences, input
|
||||
binding overrides, and action-bar inputs. Do not preserve `SavedGame`, Unity
|
||||
|
|
@ -39,6 +39,11 @@ Unity settings, loadout templates, and run checkpoints now queue typed
|
|||
into canonical typed state through `AetheriaRuntimeCommitLogApplier`; the
|
||||
Unity-side command log is command-only and cannot become durable truth by
|
||||
itself.
|
||||
`AetheriaRuntimeSession` is the typed Verse signal for the running state host
|
||||
or one-shot pending drain. `Economy.Server` owns the long-running
|
||||
`cultmesh-state-host` heartbeat and publishes `starting`, `running`, and
|
||||
`stopping` states through `Aetheria.State`; drain status remains operation
|
||||
telemetry, not daemon-session ownership.
|
||||
Unity Eve surfaces also emit typed `gamecult.eve.command.v1` documents under
|
||||
`GameData/aetheria-world.cc.eve.pending`. `AetheriaEveCommandBridge` is the
|
||||
provider-owned acceptance organ for those commands: it validates the provider,
|
||||
|
|
@ -108,9 +113,10 @@ field from key 3, name-file and boss-hull legacy IDs, influence distance,
|
|||
allegiance count, full allegiance edges, and music bank IDs. Corporation
|
||||
documents are `aetheria.corporation.v2` records because runtime generation
|
||||
needs actual allegiance weights, not only display counts. Runtime checkpoints
|
||||
now persist session faction relationships into typed run state, but broader
|
||||
runtime object graphs, typed behavior factory construction, and simulation
|
||||
state remain legacy until dedicated typed runtime documents exist.
|
||||
now persist session faction relationships into typed run state, and runtime
|
||||
sessions publish typed state-host liveness, but broader runtime object graphs,
|
||||
typed behavior factory construction, and simulation state remain legacy until
|
||||
dedicated typed runtime documents exist.
|
||||
Typed name-file documents are `aetheria.name_file.v2` records. They carry the
|
||||
legacy ID, display name, count, compact sample names for surfaces, and the full
|
||||
name array needed to move `Galaxy`/Markov name generation off legacy
|
||||
|
|
@ -154,10 +160,12 @@ corporation records.
|
|||
|
||||
`Aetheria.State.Smoke` writes and reopens full typed player settings plus a
|
||||
typed loadout template in addition to world, catalog, run state, zone state, and
|
||||
an entity snapshot. The run smoke proves a typed run can reference a typed zone,
|
||||
the zone can reference typed entity snapshots, and entity snapshots can preserve
|
||||
equipment slots, weapon groups, and stat grids without reviving
|
||||
`PlayerSettings.msgpack`, `.loadout`, or `.zone` serialization.
|
||||
an entity snapshot. It also writes and reopens `AetheriaRuntimeSession` and
|
||||
checks that the Eve provider advertises the runtime-session schema. The run
|
||||
smoke proves a typed run can reference a typed zone, the zone can reference
|
||||
typed entity snapshots, and entity snapshots can preserve equipment slots,
|
||||
weapon groups, and stat grids without reviving `PlayerSettings.msgpack`,
|
||||
`.loadout`, or `.zone` serialization.
|
||||
|
||||
`Aetheria.State.Verify` opens a materialized state file and checks that the
|
||||
typed migration ledger matches the actual item, corporation, and name-file
|
||||
|
|
|
|||
|
|
@ -17,9 +17,12 @@ internal static class Program
|
|||
runtimeId: RuntimeId,
|
||||
startServer: true).ConfigureAwait(false);
|
||||
|
||||
var startedAtUtc = DateTimeOffset.UtcNow.ToString("O");
|
||||
await PublishRuntimeSessionAsync(node, startedAtUtc, "starting").ConfigureAwait(false);
|
||||
await EnsureWorldDocumentAsync(node).ConfigureAwait(false);
|
||||
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
|
||||
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
|
||||
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
|
||||
if (HasFlag(args, "--apply-pending-once"))
|
||||
|
|
@ -29,8 +32,9 @@ internal static class Program
|
|||
}
|
||||
|
||||
Console.WriteLine("Aetheria CultMesh state host is running. Press Ctrl+C to stop.");
|
||||
await RunUntilShutdownAsync(node, PendingInterval(args)).ConfigureAwait(false);
|
||||
await RunUntilShutdownAsync(node, startedAtUtc, PendingInterval(args)).ConfigureAwait(false);
|
||||
|
||||
await PublishRuntimeSessionAsync(node, startedAtUtc, "stopping").ConfigureAwait(false);
|
||||
Console.WriteLine("Aetheria CultMesh state host stopping.");
|
||||
}
|
||||
|
||||
|
|
@ -212,13 +216,31 @@ internal static class Program
|
|||
Status = "idle"
|
||||
};
|
||||
var eveStatus = await node.GetEveCommandDrainStatusAsync().ConfigureAwait(false);
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus))
|
||||
var runtimeSession = await node.GetRuntimeSessionAsync(RuntimeId).ConfigureAwait(false);
|
||||
await node.PutOperationsSurfaceAsync(AetheriaOperationsSurfaceProjector.Build(commitStatus, eveStatus, runtimeSession))
|
||||
.ConfigureAwait(false);
|
||||
await node.PutProviderAdvertisementAsync(
|
||||
AetheriaProviderAdvertisementProjector.Build(node.StatePath, updatedAtUtc)).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task PublishRuntimeSessionAsync(
|
||||
AetheriaStateNode node,
|
||||
string startedAtUtc,
|
||||
string status)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
await node.PutRuntimeSessionAsync(new AetheriaRuntimeSession
|
||||
{
|
||||
RuntimeId = RuntimeId,
|
||||
Role = "cultmesh-state-host",
|
||||
StartedAtUtc = startedAtUtc,
|
||||
LastSeenAtUtc = now,
|
||||
Status = status
|
||||
}).ConfigureAwait(false);
|
||||
await PublishOperationsStateAsync(node, now).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static int CountPendingRuntimeCommits(string statePath)
|
||||
{
|
||||
var pendingDirectory = statePath + ".pending";
|
||||
|
|
@ -235,7 +257,10 @@ internal static class Program
|
|||
: 0;
|
||||
}
|
||||
|
||||
private static async Task RunUntilShutdownAsync(AetheriaStateNode node, TimeSpan pendingInterval)
|
||||
private static async Task RunUntilShutdownAsync(
|
||||
AetheriaStateNode node,
|
||||
string startedAtUtc,
|
||||
TimeSpan pendingInterval)
|
||||
{
|
||||
var stopped = new TaskCompletionSource<object?>();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
|
|
@ -253,6 +278,7 @@ internal static class Program
|
|||
|
||||
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
|
||||
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
|
||||
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -725,6 +725,10 @@ First Aetheria surfaces to publish:
|
|||
- Done: extend run checkpoint commits and smoke coverage to carry typed
|
||||
faction relationship rows into `AetheriaRunState` as corporation
|
||||
references, relationship tokens, and numeric standings.
|
||||
- Done: make `AetheriaRuntimeSession` a live CultMesh document with state
|
||||
node ports, provider advertisement, server heartbeat publication, and
|
||||
smoke coverage; daemon liveness is no longer only console output or
|
||||
incidental drain status.
|
||||
- Remaining: add typed documents/mappers for runtime object graphs,
|
||||
typed behavior factory construction, simulation state, and any catalog
|
||||
fields not covered by the stable scalar/fingerprint/payload pass.
|
||||
|
|
@ -1116,7 +1120,9 @@ First Aetheria surfaces to publish:
|
|||
- CultCache smoke proves write, flush, reopen, query, and typed reference
|
||||
resolution, including full player settings as the `PlayerSettings.msgpack`
|
||||
replacement, loadout templates as the `.loadout` replacement, and a run ->
|
||||
zone -> entity snapshot graph as the `.zone` replacement.
|
||||
zone -> entity snapshot graph as the `.zone` replacement. It also proves
|
||||
`aetheria.runtime_session.v1` is advertised and survives reopen as the
|
||||
typed daemon-session signal.
|
||||
- CultMesh smoke proves node start, typed put/get, subscription, flush, and
|
||||
reopen.
|
||||
- Eve surface smoke proves provider-owned surface documents are generated from
|
||||
|
|
|
|||
Loading…
Reference in a new issue