449 lines
18 KiB
C#
449 lines
18 KiB
C#
using System.Text.Json;
|
|
using Aetheria.State;
|
|
using Aetheria.State.Documents;
|
|
|
|
namespace Aetheria.Server;
|
|
|
|
internal static class Program
|
|
{
|
|
private const string RuntimeId = "aetheria-economy-server";
|
|
|
|
private static async Task Main(string[] args)
|
|
{
|
|
var statePath = ResolveStatePath(args);
|
|
Console.WriteLine($"Aetheria CultMesh state host starting: {statePath}");
|
|
|
|
await using var node = await AetheriaStateNode.OpenAsync(
|
|
statePath,
|
|
runtimeId: RuntimeId,
|
|
startServer: true).ConfigureAwait(false);
|
|
using var verseDiscovery = new AetheriaVerseDiscoveryHost(node);
|
|
|
|
await EnsureVerseHostSettingsAsync(node).ConfigureAwait(false);
|
|
await RefreshVerseDiscoveryAsync(node, verseDiscovery).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 RefreshVerseDiscoveryAsync(node, verseDiscovery).ConfigureAwait(false);
|
|
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
|
|
await node.FlushAsync().ConfigureAwait(false);
|
|
|
|
if (HasFlag(args, "--apply-pending-once"))
|
|
{
|
|
Console.WriteLine("Aetheria pending runtime and Eve command drains completed.");
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine("Aetheria CultMesh state host is running. Press Ctrl+C to stop.");
|
|
await RunUntilShutdownAsync(node, verseDiscovery, startedAtUtc, PendingInterval(args)).ConfigureAwait(false);
|
|
|
|
await PublishRuntimeSessionAsync(node, startedAtUtc, "stopping").ConfigureAwait(false);
|
|
Console.WriteLine("Aetheria CultMesh state host stopping.");
|
|
}
|
|
|
|
private static string ResolveStatePath(IReadOnlyList<string> args)
|
|
{
|
|
for (var i = 0; i < args.Count - 1; i++)
|
|
{
|
|
if (string.Equals(args[i], "--state", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Path.GetFullPath(args[i + 1]);
|
|
}
|
|
}
|
|
|
|
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
|
|
return AetheriaStatePaths.ResolveDefaultStatePath(repoRoot);
|
|
}
|
|
|
|
private static async Task EnsureWorldDocumentAsync(AetheriaStateNode node)
|
|
{
|
|
var existing = await node.GetWorldAsync().ConfigureAwait(false);
|
|
if (existing != null)
|
|
{
|
|
existing.UpdatedAtUtc = DateTimeOffset.UtcNow.ToString("O");
|
|
await node.PutWorldAsync(existing).ConfigureAwait(false);
|
|
return;
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow.ToString("O");
|
|
await node.PutWorldAsync(new AetheriaWorldState
|
|
{
|
|
Name = "Aetheria",
|
|
WorldId = "aetheria",
|
|
SchemaEpoch = 1,
|
|
CreatedAtUtc = now,
|
|
UpdatedAtUtc = now
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task ApplyPendingRuntimeCommitsAsync(AetheriaStateNode node)
|
|
{
|
|
var pendingBefore = CountPendingRuntimeCommits(node.StatePath);
|
|
var now = DateTimeOffset.UtcNow.ToString("O");
|
|
|
|
try
|
|
{
|
|
var report = await AetheriaRuntimeCommitLogApplier.ApplyPendingAsync(node).ConfigureAwait(false);
|
|
var status = new AetheriaRuntimeCommitDrainStatus
|
|
{
|
|
RuntimeId = RuntimeId,
|
|
StatePath = node.StatePath,
|
|
LastPollAtUtc = now,
|
|
LastAppliedAtUtc = report.AppliedPaths.Length > 0 ? now : "",
|
|
PendingBeforeApply = pendingBefore,
|
|
CommandsApplied = report.AppliedPaths.Length,
|
|
AppliedPlayerSettings = report.AppliedPlayerSettings,
|
|
AppliedLoadoutTemplates = report.AppliedLoadoutTemplates,
|
|
AppliedRunCheckpoints = report.AppliedRunCheckpoints,
|
|
ConsecutiveFailures = 0,
|
|
Status = "ok"
|
|
};
|
|
await PublishDrainStatusAsync(node, status).ConfigureAwait(false);
|
|
|
|
if (report.AppliedPaths.Length == 0)
|
|
{
|
|
Console.WriteLine("No pending Aetheria runtime commits.");
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine(
|
|
"Applied pending Aetheria runtime commits: " +
|
|
$"settings={report.AppliedPlayerSettings}, " +
|
|
$"loadouts={report.AppliedLoadoutTemplates}, " +
|
|
$"runs={report.AppliedRunCheckpoints}, " +
|
|
$"files={report.AppliedPaths.Length}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var existing = await node.GetRuntimeCommitDrainStatusAsync().ConfigureAwait(false);
|
|
var status = new AetheriaRuntimeCommitDrainStatus
|
|
{
|
|
RuntimeId = RuntimeId,
|
|
StatePath = node.StatePath,
|
|
LastPollAtUtc = now,
|
|
LastAppliedAtUtc = existing?.LastAppliedAtUtc ?? "",
|
|
PendingBeforeApply = pendingBefore,
|
|
ConsecutiveFailures = (existing?.ConsecutiveFailures ?? 0) + 1,
|
|
LastError = ex.ToString(),
|
|
Status = "error"
|
|
};
|
|
await PublishDrainStatusAsync(node, status).ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task PublishDrainStatusAsync(
|
|
AetheriaStateNode node,
|
|
AetheriaRuntimeCommitDrainStatus status)
|
|
{
|
|
await node.PutRuntimeCommitDrainStatusAsync(status).ConfigureAwait(false);
|
|
await PublishOperationsStateAsync(node, status.LastPollAtUtc).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task ApplyPendingEveCommandsAsync(AetheriaStateNode node)
|
|
{
|
|
var pendingBefore = CountPendingEveCommands(node.StatePath);
|
|
var now = DateTimeOffset.UtcNow.ToString("O");
|
|
|
|
try
|
|
{
|
|
var report = await AetheriaEveCommandBridge.ApplyPendingAsync(node).ConfigureAwait(false);
|
|
var status = new AetheriaEveCommandDrainStatus
|
|
{
|
|
RuntimeId = RuntimeId,
|
|
StatePath = node.StatePath,
|
|
LastPollAtUtc = now,
|
|
LastAcceptedAtUtc = report.AcceptedPaths.Length > 0 ? now : "",
|
|
PendingBeforeApply = pendingBefore,
|
|
CommandsAccepted = report.AcceptedPaths.Length,
|
|
CommandsRejected = report.RejectedCommands,
|
|
AppliedCatalogRefreshes = report.AppliedCatalogRefreshes,
|
|
AppliedOperationsRefreshes = report.AppliedOperationsRefreshes,
|
|
AppliedPlayerSettingsCommands = report.AppliedPlayerSettingsCommands,
|
|
AppliedVerseHostCommands = report.AppliedVerseHostCommands,
|
|
LastRejectedCommand = report.LastRejectedCommand,
|
|
LastRejectedReason = report.LastRejectedReason,
|
|
ConsecutiveFailures = 0,
|
|
Status = report.RejectedCommands > 0 ? "rejected" : "ok"
|
|
};
|
|
await PublishEveCommandStatusAsync(node, status).ConfigureAwait(false);
|
|
|
|
if (report.AcceptedPaths.Length == 0 && report.RejectedCommands == 0)
|
|
{
|
|
Console.WriteLine("No pending Aetheria Eve commands.");
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine(
|
|
"Drained pending Aetheria Eve commands: " +
|
|
$"accepted={report.AcceptedPaths.Length}, " +
|
|
$"rejected={report.RejectedCommands}, " +
|
|
$"catalogRefreshes={report.AppliedCatalogRefreshes}, " +
|
|
$"operationsRefreshes={report.AppliedOperationsRefreshes}, " +
|
|
$"playerSettings={report.AppliedPlayerSettingsCommands}, " +
|
|
$"verseHost={report.AppliedVerseHostCommands}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var existing = await node.GetEveCommandDrainStatusAsync().ConfigureAwait(false);
|
|
var status = new AetheriaEveCommandDrainStatus
|
|
{
|
|
RuntimeId = RuntimeId,
|
|
StatePath = node.StatePath,
|
|
LastPollAtUtc = now,
|
|
LastAcceptedAtUtc = existing?.LastAcceptedAtUtc ?? "",
|
|
PendingBeforeApply = pendingBefore,
|
|
ConsecutiveFailures = (existing?.ConsecutiveFailures ?? 0) + 1,
|
|
LastError = ex.ToString(),
|
|
Status = "error"
|
|
};
|
|
await PublishEveCommandStatusAsync(node, status).ConfigureAwait(false);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task PublishEveCommandStatusAsync(
|
|
AetheriaStateNode node,
|
|
AetheriaEveCommandDrainStatus status)
|
|
{
|
|
await node.PutEveCommandDrainStatusAsync(status).ConfigureAwait(false);
|
|
await PublishOperationsStateAsync(node, status.LastPollAtUtc).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task PublishOperationsStateAsync(
|
|
AetheriaStateNode node,
|
|
string updatedAtUtc)
|
|
{
|
|
var commitStatus = await node.GetRuntimeCommitDrainStatusAsync().ConfigureAwait(false) ??
|
|
new AetheriaRuntimeCommitDrainStatus
|
|
{
|
|
RuntimeId = RuntimeId,
|
|
StatePath = node.StatePath,
|
|
LastPollAtUtc = updatedAtUtc,
|
|
Status = "idle"
|
|
};
|
|
var eveStatus = await node.GetEveCommandDrainStatusAsync().ConfigureAwait(false);
|
|
var verseHostSettings = await node.GetVerseHostSettingsAsync().ConfigureAwait(false) ??
|
|
new AetheriaVerseHostSettings();
|
|
var runtimeSession = await node.GetRuntimeSessionAsync(RuntimeId).ConfigureAwait(false);
|
|
await node.PutOperationsSurfaceAsync(
|
|
AetheriaOperationsSurfaceProjector.Build(
|
|
commitStatus,
|
|
eveStatus,
|
|
verseHostSettings,
|
|
runtimeSession))
|
|
.ConfigureAwait(false);
|
|
await PublishPlayerSettingsSurfaceAsync(node, updatedAtUtc).ConfigureAwait(false);
|
|
await node.PutProviderAdvertisementAsync(
|
|
AetheriaProviderAdvertisementProjector.Build(verseHostSettings, node.StatePath, updatedAtUtc))
|
|
.ConfigureAwait(false);
|
|
await node.FlushAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task PublishPlayerSettingsSurfaceAsync(
|
|
AetheriaStateNode node,
|
|
string updatedAtUtc)
|
|
{
|
|
var settings = await node.GetPlayerSettingsAsync().ConfigureAwait(false) ?? new AetheriaPlayerSettings();
|
|
var publishedAtUtc = string.IsNullOrWhiteSpace(settings.LastUpdatedAtUtc)
|
|
? updatedAtUtc
|
|
: settings.LastUpdatedAtUtc;
|
|
await node.PutPlayerSettingsSurfaceAsync(
|
|
AetheriaPlayerSettingsSurfaceProjector.Build(settings, publishedAtUtc)).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";
|
|
return Directory.Exists(pendingDirectory)
|
|
? Directory.EnumerateFiles(pendingDirectory, "*.cc").Count()
|
|
: 0;
|
|
}
|
|
|
|
private static int CountPendingEveCommands(string statePath)
|
|
{
|
|
var pendingDirectory = AetheriaEveCommandBridge.GetPendingDirectory(statePath);
|
|
return Directory.Exists(pendingDirectory)
|
|
? Directory.EnumerateFiles(pendingDirectory, "*.cc").Count()
|
|
: 0;
|
|
}
|
|
|
|
private static async Task RunUntilShutdownAsync(
|
|
AetheriaStateNode node,
|
|
AetheriaVerseDiscoveryHost verseDiscovery,
|
|
string startedAtUtc,
|
|
TimeSpan pendingInterval)
|
|
{
|
|
var stopped = new TaskCompletionSource<object?>();
|
|
Console.CancelKeyPress += (_, eventArgs) =>
|
|
{
|
|
eventArgs.Cancel = true;
|
|
stopped.TrySetResult(null);
|
|
};
|
|
|
|
while (!stopped.Task.IsCompleted)
|
|
{
|
|
var delay = Task.Delay(pendingInterval);
|
|
var completed = await Task.WhenAny(stopped.Task, delay).ConfigureAwait(false);
|
|
if (completed == stopped.Task)
|
|
break;
|
|
|
|
await ApplyPendingRuntimeCommitsAsync(node).ConfigureAwait(false);
|
|
await ApplyPendingEveCommandsAsync(node).ConfigureAwait(false);
|
|
await RefreshVerseDiscoveryAsync(node, verseDiscovery).ConfigureAwait(false);
|
|
await PublishRuntimeSessionAsync(node, startedAtUtc, "running").ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static async Task RefreshVerseDiscoveryAsync(
|
|
AetheriaStateNode node,
|
|
AetheriaVerseDiscoveryHost verseDiscovery)
|
|
{
|
|
var settings = await node.GetVerseHostSettingsAsync().ConfigureAwait(false);
|
|
verseDiscovery.Update(settings);
|
|
}
|
|
|
|
private static TimeSpan PendingInterval(IReadOnlyList<string> args)
|
|
{
|
|
for (var i = 0; i < args.Count - 1; i++)
|
|
{
|
|
if (string.Equals(args[i], "--pending-interval-ms", StringComparison.OrdinalIgnoreCase) &&
|
|
int.TryParse(args[i + 1], out var milliseconds) &&
|
|
milliseconds > 0)
|
|
{
|
|
return TimeSpan.FromMilliseconds(milliseconds);
|
|
}
|
|
}
|
|
|
|
return TimeSpan.FromSeconds(5);
|
|
}
|
|
|
|
private static bool HasFlag(IReadOnlyList<string> args, string flag)
|
|
{
|
|
return args.Any(arg => string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private static async Task EnsureVerseHostSettingsAsync(AetheriaStateNode node)
|
|
{
|
|
var existing = await node.GetVerseHostSettingsAsync().ConfigureAwait(false);
|
|
var candidate = AetheriaVerseHostSettingsNormalizer.Normalize(existing);
|
|
ApplyVerseHostOverrides(candidate, LoadVerseHostOverrides());
|
|
|
|
if (existing == null ||
|
|
string.IsNullOrWhiteSpace(existing.LastUpdatedAtUtc) ||
|
|
!AetheriaVerseHostSettingsNormalizer.Equivalent(existing, candidate))
|
|
{
|
|
candidate.LastUpdatedAtUtc = DateTimeOffset.UtcNow.ToString("O");
|
|
await node.PutVerseHostSettingsAsync(candidate).ConfigureAwait(false);
|
|
await node.FlushAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
private static VerseHostOverrides LoadVerseHostOverrides()
|
|
{
|
|
var candidatePaths = new[]
|
|
{
|
|
Path.Combine(AppContext.BaseDirectory, "appsettings.json"),
|
|
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Economy.Server", "appsettings.json"))
|
|
};
|
|
|
|
var settingsPath = candidatePaths.FirstOrDefault(File.Exists);
|
|
if (string.IsNullOrWhiteSpace(settingsPath))
|
|
{
|
|
return new VerseHostOverrides();
|
|
}
|
|
|
|
using var document = JsonDocument.Parse(File.ReadAllText(settingsPath));
|
|
if (!TryGetPropertyIgnoreCase(document.RootElement, "Aetheria", out var aetheriaSection) ||
|
|
!TryGetPropertyIgnoreCase(aetheriaSection, "VerseHost", out var verseHostSection))
|
|
{
|
|
return new VerseHostOverrides();
|
|
}
|
|
|
|
return new VerseHostOverrides
|
|
{
|
|
ServiceId = ReadOptionalString(verseHostSection, "ServiceId"),
|
|
VerseId = ReadOptionalString(verseHostSection, "VerseId"),
|
|
RootVerse = ReadOptionalString(verseHostSection, "RootVerse"),
|
|
CanonicalService = ReadOptionalString(verseHostSection, "CanonicalService"),
|
|
LocatedService = ReadOptionalString(verseHostSection, "LocatedService"),
|
|
CultMeshAddress = ReadOptionalString(verseHostSection, "CultMeshAddress"),
|
|
Title = ReadOptionalString(verseHostSection, "Title"),
|
|
Visibility = ReadOptionalString(verseHostSection, "Visibility")
|
|
};
|
|
}
|
|
|
|
private static void ApplyVerseHostOverrides(AetheriaVerseHostSettings settings, VerseHostOverrides overrides)
|
|
{
|
|
settings.ServiceId = ChooseOverride(overrides.ServiceId, settings.ServiceId);
|
|
settings.VerseId = ChooseOverride(overrides.VerseId, settings.VerseId);
|
|
settings.RootVerse = ChooseOverride(overrides.RootVerse, settings.RootVerse);
|
|
settings.CanonicalService = ChooseOverride(overrides.CanonicalService, settings.CanonicalService);
|
|
settings.LocatedService = ChooseOverride(overrides.LocatedService, settings.LocatedService);
|
|
settings.CultMeshAddress = ChooseOverride(overrides.CultMeshAddress, settings.CultMeshAddress);
|
|
settings.Title = ChooseOverride(overrides.Title, settings.Title);
|
|
settings.Visibility = ChooseOverride(overrides.Visibility, settings.Visibility);
|
|
}
|
|
|
|
private static string ChooseOverride(string? candidate, string fallback)
|
|
{
|
|
return string.IsNullOrWhiteSpace(candidate) ? fallback : candidate.Trim();
|
|
}
|
|
|
|
private static string ReadOptionalString(JsonElement element, string propertyName)
|
|
{
|
|
if (!TryGetPropertyIgnoreCase(element, propertyName, out var value) ||
|
|
value.ValueKind != JsonValueKind.String)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return value.GetString()?.Trim() ?? "";
|
|
}
|
|
|
|
private static bool TryGetPropertyIgnoreCase(JsonElement element, string propertyName, out JsonElement value)
|
|
{
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
value = property.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = default;
|
|
return false;
|
|
}
|
|
|
|
private sealed class VerseHostOverrides
|
|
{
|
|
public string ServiceId { get; init; } = "";
|
|
public string VerseId { get; init; } = "";
|
|
public string RootVerse { get; init; } = "";
|
|
public string CanonicalService { get; init; } = "";
|
|
public string LocatedService { get; init; } = "";
|
|
public string CultMeshAddress { get; init; } = "";
|
|
public string Title { get; init; } = "";
|
|
public string Visibility { get; init; } = "";
|
|
}
|
|
}
|