Launch Terminus through daemon-owned Hangar

This commit is contained in:
Meta Vens 2026-08-08 08:45:14 +01:00
commit 152db9e744
12 changed files with 891 additions and 85 deletions

View file

@ -79,6 +79,11 @@ else if (args.Contains("--combat-lock", StringComparer.Ordinal))
checks.RunCombatLock();
Console.WriteLine("Daemon look-direction weapon-lock smoke passed.");
}
else if (args.Contains("--hangar", StringComparer.Ordinal))
{
checks.RunHangar();
Console.WriteLine("Daemon Hangar deployment and continuation smoke passed.");
}
else
{
checks.Run();
@ -93,6 +98,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
public void RunCombatLock() => RunCheck(InstantWeaponRequestSurvivesLockAcquisition);
public void RunHangar() => RunCheck(HangarLaunchUsesConfiguredLoadoutAndContinuesSavedRun);
public void RunLoadout()
{
DaemonNativeDockyardMaterializesTypedCatalog();
@ -136,6 +143,83 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
RunCheck(PatrolCombatPreemptsAndResumesTheSameCircuit);
}
private static void HangarLaunchUsesConfiguredLoadoutAndContinuesSavedRun()
{
var runtimeCatalog = TutorialPopulationCatalog(out _);
runtimeCatalog.Items.Single(item => string.Equals(item.HullType, "Station", StringComparison.Ordinal)).ItemKey =
AetheriaDaemonNativeCatalog.DockyardHullItemKey;
var root = Path.Combine(Path.GetTempPath(), $"aetheria-hangar-{Guid.NewGuid():N}");
var statePath = Path.Combine(root, "aetheria.cc");
Directory.CreateDirectory(root);
try
{
string runKey;
string deploymentId;
string removedItemKey;
using (var node = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult())
{
AetheriaDaemonHangarCoordinator.EnsureAsync(node, runtimeCatalog, "2026-08-08T00:00:00Z")
.GetAwaiter().GetResult();
var hangar = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().GetAwaiter().GetResult()!;
var ship = hangar.Ships.Single();
var template = node.MutableDocument<AetheriaLoadoutTemplate>(new CultRecordKey(ship.LoadoutTemplateKey))
.ReadAsync().GetAwaiter().GetResult()!;
removedItemKey = template.RootEntity.Equipment[0].Item.ItemKey;
var mutation = AetheriaHangar.RemoveAsync(node, ship.ShipId, 0, hangar.Revision, "2026-08-08T00:00:01Z")
.GetAwaiter().GetResult();
Require(mutation.Accepted, "Hangar loadout customization must commit through the canonical Hangar owner");
hangar = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().GetAwaiter().GetResult()!;
template = node.MutableDocument<AetheriaLoadoutTemplate>(new CultRecordKey(ship.LoadoutTemplateKey))
.ReadAsync().GetAwaiter().GetResult()!;
var surface = AetheriaRuntimeHangarSurfaceBuilder.Build(
hangar, ship.ShipId, AetheriaGameModes.Terminus, "2026-08-08T00:00:01Z", 1, template, runtimeCatalog);
Require(surface.Surface.Id == AetheriaRuntimeHangarCommands.SurfaceId &&
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.EquipItem) &&
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.RemoveItem) &&
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.Launch) &&
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.Continue),
"the daemon-owned Hangar surface must advertise loadout, launch, and continuation operations");
var receipt = AetheriaDaemonHangarCoordinator.LaunchTerminusAsync(
node, runtimeCatalog, "launch-configured", ship.ShipId, mutation.HangarRevision, "2026-08-08T00:00:02Z")
.GetAwaiter().GetResult();
Require(receipt.Accepted, "a valid configured Hangar deployment must launch Terminus");
deploymentId = receipt.DeploymentId;
var settings = node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
.ReadAsync().GetAwaiter().GetResult()!;
runKey = settings.ActiveRunKey;
var run = node.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey))
.ReadAsync().GetAwaiter().GetResult()!;
var player = node.MutableDocument<AetheriaEntitySnapshot>(new CultRecordKey(run.CurrentEntityKey))
.ReadAsync().GetAwaiter().GetResult()!;
Require(run.GameMode == AetheriaGameModes.Terminus &&
run.GenerationSeed == AetheriaDaemonRunFactory.StableSeed(deploymentId),
"the accepted Hangar deployment must own the new Terminus run identity");
Require(player.Equipment.All(slot => !string.Equals(slot.ItemKey, removedItemKey, StringComparison.Ordinal)),
"Terminus must instantiate the configured Hangar loadout, not regenerate the starter fit");
}
using (var reopened = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult())
{
var canContinue = AetheriaDaemonHangarCoordinator.CanContinueTerminusAsync(
reopened, AetheriaDaemonHangarCoordinator.StarterShipId, deploymentId)
.GetAwaiter().GetResult();
var settings = reopened.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
.ReadAsync().GetAwaiter().GetResult();
var run = reopened.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey))
.ReadAsync().GetAwaiter().GetResult();
Require(canContinue && settings?.ActiveRunKey == runKey && run != null,
"Continue must reopen the saved Terminus checkpoint without creating another run");
}
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static void TutorialRunPersistsCanonicalWorldTruth()
{
var catalog = TutorialPopulationCatalog(out _);

View file

@ -0,0 +1,196 @@
using Aetheria.State;
using Aetheria.State.Documents;
using GameCult.Aetheria.State.Verse;
using GameCult.Caching;
public static class AetheriaDaemonHangarCoordinator
{
public const string LocalPlayerKey = "player:local";
public const string StarterShipId = "ship:local:vanguard";
public const string StarterLoadoutName = "Vanguard One";
public static async Task EnsureAsync(
AetheriaStateNode node,
AetheriaRuntimeCatalogSnapshot catalog,
string now)
{
var existing = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().ConfigureAwait(false);
if (existing != null)
return;
var factionKey = (catalog.Corporations ?? [])
.Select(value => value.CorporationKey)
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
?? throw new InvalidDataException("Hangar creation requires one typed corporation.");
var generator = new AetheriaDaemonLoadoutGenerator(
catalog,
AetheriaDaemonZoneGenerator.GenerationSeed,
0,
new Dictionary<string, int>(StringComparer.Ordinal) { [factionKey] = 0 },
new Dictionary<int, IReadOnlyList<int>> { [0] = [] },
isPrelude: true);
var generated = generator.Build("ship", factionKey);
var loadoutCommit = new AetheriaRuntimeLoadoutTemplateCommit
{
Name = StarterLoadoutName,
OwnerPlayerKey = LocalPlayerKey,
RootEntity = ToRuntimeLoadout(generated, factionKey)
};
var loadout = AetheriaRuntimeStateMapper.ToLoadoutTemplate(loadoutCommit, now);
var loadoutKey = AetheriaRuntimeStateMapper.LoadoutKey(loadout.Name);
var installed = generated.Equipment
.Concat(generated.CargoBays)
.Concat(generated.DockingBays)
.Select(value => value.ItemKey)
.ToHashSet(StringComparer.Ordinal);
var inventory = catalog.EquipmentItems
.Where(value => !string.IsNullOrWhiteSpace(value.ItemKey) && !installed.Contains(value.ItemKey))
.OrderBy(value => value.Price)
.ThenBy(value => value.ItemKey, StringComparer.Ordinal)
.Take(24)
.Select(value => new AetheriaHangarItemStack { ItemKey = value.ItemKey, Quantity = 1 })
.ToArray();
await node.MutableDocument<AetheriaLoadoutTemplate>(loadoutKey).ReplaceAsync(loadout).ConfigureAwait(false);
await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey).ReplaceAsync(new AetheriaHangarState
{
HangarId = "local",
PlayerKey = LocalPlayerKey,
Revision = 1,
Ships =
[
new AetheriaHangarShip
{
ShipId = StarterShipId,
HullItemKey = generated.HullItemKey,
LoadoutTemplateKey = loadoutKey.ToString(),
Status = AetheriaHangarShipStatuses.Available
}
],
Inventory = inventory,
LoadoutTemplateKeys = [loadoutKey.ToString()],
UpdatedAtUtc = now
}).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
}
public static async Task<AetheriaDeploymentReceipt> LaunchTerminusAsync(
AetheriaStateNode node,
AetheriaRuntimeCatalogSnapshot catalog,
string requestId,
string shipId,
long expectedRevision,
string now)
{
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().ConfigureAwait(false)
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
var ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
var receipt = await AetheriaHangar.AdmitAsync(node, new AetheriaDeploymentRequest
{
RequestId = requestId,
PlayerKey = hangar.PlayerKey,
Mode = AetheriaGameModes.Terminus,
ShipId = shipId,
LoadoutTemplateKey = ship?.LoadoutTemplateKey ?? "",
ExpectedHangarRevision = expectedRevision,
ModePolicyId = AetheriaModePolicies.TerminusLocal
}, now).ConfigureAwait(false);
if (!receipt.Accepted)
return receipt;
await AetheriaDaemonZoneGenerator.WritePlayableRunAsync(
node,
catalog,
now,
AetheriaDaemonTerminusScenarios.Standard,
receipt).ConfigureAwait(false);
return receipt;
}
public static async Task<bool> CanContinueTerminusAsync(
AetheriaStateNode node,
string shipId,
string deploymentId)
{
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().ConfigureAwait(false);
var ship = (hangar?.Ships ?? []).SingleOrDefault(value =>
string.Equals(value.ShipId, shipId, StringComparison.Ordinal) &&
string.Equals(value.ActiveDeploymentId, deploymentId, StringComparison.Ordinal) &&
string.Equals(value.Status, AetheriaHangarShipStatuses.Deployed, StringComparison.Ordinal));
if (ship == null)
return false;
var deployment = (hangar!.Deployments ?? []).SingleOrDefault(value =>
value.Accepted &&
string.Equals(value.DeploymentId, deploymentId, StringComparison.Ordinal) &&
string.Equals(value.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal));
if (deployment == null)
return false;
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
.ReadAsync().ConfigureAwait(false);
return !string.IsNullOrWhiteSpace(settings?.ActiveRunKey);
}
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(
AetheriaDaemonLoadout source,
string factionKey) => new()
{
Name = StarterLoadoutName,
Kind = "ship",
FactionKey = factionKey,
Hull = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = source.HullItemKey,
Quality = 1,
Durability = 1,
Quantity = 1,
Enabled = true
},
Equipment = source.Equipment.Select(ToRuntimeSlot).ToArray(),
CargoBays = source.CargoBays.Select(ToRuntimeSlot).ToArray(),
DockingBays = source.DockingBays.Select(ToRuntimeSlot).ToArray(),
CargoContents = source.CargoBays.Select((_, index) => new AetheriaRuntimeCargoBayLoadoutCommit
{
Items = index == 0 ? source.Cargo.Select(ToRuntimeSlot).ToArray() : []
}).ToArray(),
DockingBayContents = source.DockingBays.Select(_ => new AetheriaRuntimeCargoBayLoadoutCommit()).ToArray(),
DockingBayAssignments = source.DockingBays.Select(_ => -1).ToArray(),
WeaponGroups = source.WeaponGroups.Select(group => (IReadOnlyList<int>)group.ToArray()).ToArray()
};
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaEntityItemSlot slot) => new()
{
X = slot.Position?.X ?? 0,
Y = slot.Position?.Y ?? 0,
Rotation = slot.Rotation,
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = slot.ItemKey,
Quality = slot.Quality,
Durability = slot.Durability,
Quantity = slot.Quantity,
Enabled = slot.Enabled,
OverrideShutdown = slot.OverrideShutdown,
Temperature = slot.Temperature
}
};
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaLoadoutItemSlot slot) => new()
{
X = slot.Position?.X ?? 0,
Y = slot.Position?.Y ?? 0,
Rotation = slot.Rotation,
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = slot.Item?.ItemKey ?? "",
Quality = slot.Item?.Quality ?? 1,
Durability = slot.Item?.Durability ?? 1,
Quantity = slot.Item?.Quantity ?? 1,
Enabled = slot.Item?.Enabled ?? true,
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
Temperature = slot.Item?.Temperature ?? 0
}
};
}

View file

@ -32,10 +32,18 @@ internal static class AetheriaDaemonZoneGenerator
AetheriaStateNode node,
AetheriaRuntimeCatalogSnapshot catalog,
string now,
string scenario)
string scenario,
AetheriaDeploymentReceipt? deployment = null)
{
scenario = AetheriaDaemonTerminusScenarios.Parse(scenario);
var runId = RunIdFor(scenario);
if (deployment != null && (!deployment.Accepted || !string.Equals(deployment.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal)))
throw new InvalidOperationException("A product Terminus run requires an accepted Terminus deployment.");
var runId = deployment == null
? RunIdFor(scenario)
: $"terminus-{StableToken(deployment.DeploymentId)}";
var generationSeed = deployment == null
? GenerationSeed
: AetheriaDaemonRunFactory.StableSeed(deployment.DeploymentId);
var runKey = new CultRecordKey($"global:aetheria.run_state.{runId}.v1");
var zoneKey = new CultRecordKey($"global:aetheria.zone_state.{runId}.0.v1");
var corporationKeys = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
@ -54,7 +62,7 @@ internal static class AetheriaDaemonZoneGenerator
["raider"] = corporationKeys.ElementAtOrDefault(1) ?? corporationKeys.ElementAtOrDefault(0) ?? "",
["neutral"] = corporationKeys.ElementAtOrDefault(2) ?? corporationKeys.ElementAtOrDefault(0) ?? ""
};
var rootRandom = new CultMath.Random(GenerationSeed);
var rootRandom = new CultMath.Random(generationSeed);
var adjacency = new Dictionary<int, IReadOnlyList<int>> { [0] = Array.Empty<int>() };
var loadouts = availabilityFactions.Values
.Where(value => !string.IsNullOrWhiteSpace(value))
@ -69,9 +77,11 @@ internal static class AetheriaDaemonZoneGenerator
homeZones,
adjacency),
StringComparer.Ordinal);
var entities = GenerateEntities(loadouts, availabilityFactions, catalog, scenario);
var entities = GenerateEntities(loadouts, availabilityFactions, catalog, scenario, runId);
if (deployment != null)
ApplyDeployment(entities[1], deployment.Loadout);
var entityKeys = Enumerable.Range(0, entities.Length)
.Select(index => EntityKey(scenario, 0, index))
.Select(index => EntityKeyForRun(runId, 0, index))
.ToArray();
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
@ -92,7 +102,7 @@ internal static class AetheriaDaemonZoneGenerator
CurrentZoneIndex = 0,
DiscoveredZoneIndices = [0],
ZoneKeys = [zoneKey.ToString()],
GenerationSeed = GenerationSeed,
GenerationSeed = generationSeed,
CurrentEntityKey = entityKeys[1],
LifecyclePhase = AetheriaRuntimeRunLifecycle.Active,
TerminalFrameId = -1,
@ -141,6 +151,9 @@ internal static class AetheriaDaemonZoneGenerator
return $"global:aetheria.run_state.{RunIdFor(scenario)}.zone.{zoneIndex}.entity.{entityIndex}.v1";
}
private static string EntityKeyForRun(string runId, int zoneIndex, int entityIndex) =>
$"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1";
private static AetheriaOrbitSnapshot[] GenerateOrbits()
{
var bodies = BodyPlans();
@ -241,10 +254,11 @@ internal static class AetheriaDaemonZoneGenerator
IReadOnlyDictionary<string, AetheriaDaemonLoadoutGenerator> loadouts,
IReadOnlyDictionary<string, string> availabilityFactions,
AetheriaRuntimeCatalogSnapshot catalog,
string scenario)
string scenario,
string runId)
{
var keys = Enumerable.Range(0, 12)
.Select(index => EntityKey(scenario, 0, index))
.Select(index => EntityKeyForRun(runId, 0, index))
.ToArray();
var entities = new[]
@ -340,6 +354,63 @@ internal static class AetheriaDaemonZoneGenerator
FillCargoBays(entities[1], salvage.ItemKey, catalog);
}
private static void ApplyDeployment(
AetheriaEntitySnapshot entity,
AetheriaRuntimeEntityLoadoutCommit loadout)
{
entity.Name = string.IsNullOrWhiteSpace(loadout.Name) ? entity.Name : loadout.Name;
entity.Kind = string.IsNullOrWhiteSpace(loadout.Kind) ? entity.Kind : loadout.Kind;
entity.FactionKey = string.IsNullOrWhiteSpace(loadout.FactionKey) ? entity.FactionKey : loadout.FactionKey;
entity.HullItemKey = loadout.Hull?.ItemKey ?? entity.HullItemKey;
entity.Equipment = (loadout.Equipment ?? []).Select(ToEntitySlot).ToArray();
entity.CargoBays = (loadout.CargoBays ?? []).Select(ToEntitySlot).ToArray();
entity.DockingBays = (loadout.DockingBays ?? []).Select(ToEntitySlot).ToArray();
entity.CargoContents = (loadout.CargoContents ?? []).Select(ToCargoBay).ToArray();
entity.DockingBayContents = (loadout.DockingBayContents ?? []).Select(ToCargoBay).ToArray();
entity.DockingBayAssignments = (loadout.DockingBayAssignments ?? []).ToArray();
entity.WeaponGroups = (loadout.WeaponGroups ?? [])
.Select(group => new AetheriaWeaponGroupSnapshot
{
EquipmentIndices = (group ?? []).ToArray()
})
.ToArray();
}
private static AetheriaEntityItemSlot ToEntitySlot(AetheriaRuntimeLoadoutItemSlotCommit slot) => new()
{
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
Rotation = slot.Rotation ?? "None",
ItemKey = slot.Item?.ItemKey ?? "",
Quality = slot.Item?.Quality ?? 1,
Durability = slot.Item?.Durability ?? 1,
Quantity = slot.Item?.Quantity ?? 1,
Enabled = slot.Item?.Enabled ?? true,
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
Temperature = slot.Item?.Temperature ?? 0
};
private static AetheriaCargoBayLoadout ToCargoBay(AetheriaRuntimeCargoBayLoadoutCommit bay) => new()
{
Items = (bay.Items ?? []).Select(slot => new AetheriaLoadoutItemSlot
{
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
Rotation = slot.Rotation ?? "None",
Item = new AetheriaLoadoutItem
{
ItemKey = slot.Item?.ItemKey ?? "",
Quality = slot.Item?.Quality ?? 1,
Durability = slot.Item?.Durability ?? 1,
Quantity = slot.Item?.Quantity ?? 1,
Enabled = slot.Item?.Enabled ?? true,
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
Temperature = slot.Item?.Temperature ?? 0
}
}).ToArray()
};
private static string StableToken(string value) =>
new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '-').ToArray()).Trim('-');
private static void FillCargoBays(
AetheriaEntitySnapshot entity,
string itemKey,

View file

@ -72,6 +72,8 @@ if (persistedRuntimeCatalog == null ||
nativeCatalogChanged)
await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false);
TraceStartup("runtime-catalog");
await AetheriaDaemonHangarCoordinator.EnsureAsync(node, node.RuntimeCatalog().Latest(), startedAtUtc).ConfigureAwait(false);
TraceStartup("hangar");
var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false);
TraceStartup("verse-host");
await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false);
@ -106,6 +108,7 @@ using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher(
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
bodyDemand);
var stopped = new TaskCompletionSource<object?>();
var hangarActivationRequested = false;
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
@ -121,12 +124,9 @@ while (!stopped.Task.IsCompleted)
while (!stopped.Task.IsCompleted)
{
ThrowIfClientHostFaulted(cultMeshClientHost);
await AcceptCoreEveInvocationsAsync(node, options, latestFrame).ConfigureAwait(false);
hangarActivationRequested |= await AcceptCoreEveInvocationsAsync(node, options, latestFrame).ConfigureAwait(false);
await AcceptEveCommandsAsync(node, options, ingressState).ConfigureAwait(false);
if (await ApplyRequestedNewGameSessionAsync(node, options).ConfigureAwait(false))
latestFrame = null;
if (playableWorldDemand.IsActive &&
if (hangarActivationRequested && playableWorldDemand.IsActive &&
(HasPlayableRun(latestFrame?.Run) ||
HasPlayableRun(await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false))))
break;
@ -344,11 +344,6 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
TracePhase("core-ingress");
await AcceptEveCommandsAsync(node, options, ingressState).ConfigureAwait(false);
TracePhase("provider-ingress");
if (await ApplyRequestedNewGameSessionAsync(node, options).ConfigureAwait(false))
{
currentFrame = null;
ingressState.ControlPlaneInitialized = false;
}
if (!ingressState.ControlPlaneInitialized || buildPublications)
await RefreshControlPlaneInputsAsync(node, ingressState).ConfigureAwait(false);
@ -2054,6 +2049,9 @@ static async Task PublishClientGameplayDocumentsAsync(
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.DaemonGameSurface)
.ReplaceAsync(portableGameSurface)
.ConfigureAwait(false);
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.HangarSurface)
.ReplaceAsync(ReidentifySurface(portableGameSurface, AetheriaRuntimeHangarCommands.SurfaceId, "Terminus"))
.ConfigureAwait(false);
await PublishDaemonSectorMapSurfaceAsync(node, result.Frame, inputCatalog).ConfigureAwait(false);
}
TraceClientDocumentPhase("game-topology");
@ -2175,7 +2173,15 @@ static EveProviderAdvertisementDocument BuildCoreProviderAdvertisement(
"cultmesh-record",
"active",
"graph",
mapInteraction)
mapInteraction),
new EveAdvertisedSurface(
AetheriaRuntimeHangarCommands.SurfaceId,
EveSurfaceDocument.SchemaId,
AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(),
"cultmesh-record",
"active",
"interactive-world",
interaction)
},
Array.Empty<EveAdvertisedCommand>(),
new[] { AetheriaRuntimeDaemonSoaFramePublisher.ProducerId });
@ -2413,11 +2419,12 @@ static AetheriaRuntimeSurfaceComponent SurfaceNode(
children);
}
static async Task AcceptCoreEveInvocationsAsync(
static async Task<bool> AcceptCoreEveInvocationsAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
AetheriaRuntimeDaemonFrameDocument? currentFrame)
{
var activatedSession = false;
var accounted = new HashSet<string>(
currentFrame?.AccountedCommandIds ?? Array.Empty<string>(),
StringComparer.Ordinal);
@ -2436,6 +2443,13 @@ static async Task AcceptCoreEveInvocationsAsync(
if (accounted.Contains(request.CommandId) || receipted.Contains(request.CommandId) || submitted.Contains(request.CommandId))
continue;
if (string.Equals(request.SurfaceId, AetheriaRuntimeHangarCommands.SurfaceId, StringComparison.Ordinal))
{
activatedSession |= await AcceptHangarInvocationAsync(node, options, request).ConfigureAwait(false);
receipted.Add(request.CommandId);
continue;
}
if (AetheriaRuntimeDaemonOperationsClient.TryCreateSurfaceCommandDocument(
request,
currentFrame,
@ -2467,8 +2481,165 @@ static async Task AcceptCoreEveInvocationsAsync(
await node.Database.PutAsync(AetheriaRuntimeVerseRecordKeys.EveReceiptForCommand(denied.CommandId), denied)
.ConfigureAwait(false);
}
return activatedSession;
}
static EveSurfaceDocument ReidentifySurface(EveSurfaceDocument source, string surfaceId, string title) =>
new(
source.Type,
source.Schema,
source.ProviderId,
source.ProviderKind,
title,
source.Version,
source.UpdatedAtUtc,
new EveSurfaceTree(surfaceId, source.Surface.Root, source.Surface.Styles),
source.Commands);
static async Task<bool> AcceptHangarInvocationAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
EveSurfaceCommandRequest request)
{
var now = DateTimeOffset.UtcNow.ToString("O");
var command = request.Command ?? "";
var accepted = false;
var diagnostic = "";
var activatesSession = false;
try
{
var shipId = Payload(request, "shipId");
var expectedRevision = PayloadLong(request, "expectedHangarRevision", -1);
switch (command)
{
case AetheriaRuntimeHangarCommands.SelectShip:
case AetheriaRuntimeHangarCommands.SelectTerminus:
case AetheriaRuntimeHangarCommands.EditLoadout:
accepted = true;
break;
case AetheriaRuntimeHangarCommands.SelectStarbridge:
case AetheriaRuntimeHangarCommands.SelectArena:
diagnostic = "This mode has no session bootstrap yet.";
break;
case AetheriaRuntimeHangarCommands.EquipItem:
{
var result = await AetheriaHangar.EquipAsync(
node,
shipId,
Payload(request, "itemKey"),
expectedRevision,
node.RuntimeCatalog().Latest(),
now).ConfigureAwait(false);
accepted = result.Accepted;
diagnostic = result.Diagnostic;
if (accepted) await PublishStateSurfacesAsync(node, options, now).ConfigureAwait(false);
break;
}
case AetheriaRuntimeHangarCommands.RemoveItem:
{
var result = await AetheriaHangar.RemoveAsync(
node,
shipId,
(int)PayloadLong(request, "equipmentIndex", -1),
expectedRevision,
now).ConfigureAwait(false);
accepted = result.Accepted;
diagnostic = result.Diagnostic;
if (accepted) await PublishStateSurfacesAsync(node, options, now).ConfigureAwait(false);
break;
}
case AetheriaRuntimeHangarCommands.Launch:
{
var receipt = await AetheriaDaemonHangarCoordinator.LaunchTerminusAsync(
node,
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
request.CommandId,
shipId,
expectedRevision,
now).ConfigureAwait(false);
accepted = receipt.Accepted;
diagnostic = receipt.Diagnostic;
if (accepted)
{
await ActivateTerminusSessionAsync(node, options, request.CommandId, now).ConfigureAwait(false);
activatesSession = true;
}
break;
}
case AetheriaRuntimeHangarCommands.Continue:
{
accepted = await AetheriaDaemonHangarCoordinator.CanContinueTerminusAsync(
node,
shipId,
Payload(request, "deploymentId")).ConfigureAwait(false);
diagnostic = accepted ? "" : "No resumable Terminus deployment exists for this ship.";
if (accepted)
{
await ActivateTerminusSessionAsync(node, options, request.CommandId, now).ConfigureAwait(false);
activatesSession = true;
}
break;
}
default:
diagnostic = "Command is not advertised by the Hangar surface.";
break;
}
}
catch (Exception error)
{
accepted = false;
diagnostic = error.Message;
}
var receiptDocument = new EveCommandReceiptDocument(
$"receipt:{request.CommandId}:{(accepted ? "applied" : "denied")}",
request.CommandId,
command,
accepted ? "applied" : "denied",
"Aetheria Hangar",
options.DaemonId,
request.ProviderId,
request.SurfaceId,
diagnostic,
now,
0);
await node.Database.PutAsync(AetheriaRuntimeVerseRecordKeys.EveReceiptForCommand(request.CommandId), receiptDocument)
.ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
return activatesSession;
}
static async Task ActivateTerminusSessionAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
string commandId,
string now)
{
var run = await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false)
?? throw new InvalidOperationException("Terminus activation requires a canonical run checkpoint.");
await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
.ReplaceAsync(new AetheriaGameSessionState
{
Mode = AetheriaGameSessionState.TerminusMode,
SessionId = options.SessionId,
RunId = run.RunId,
ControlledEntityKey = run.CurrentEntityKey,
EntrySurfaceId = AetheriaRuntimeHangarCommands.SurfaceId,
SimulationRate = 1,
EffectiveSimulationRate = 1,
LastStartCommandId = commandId,
UpdatedAtUtc = now
}).ConfigureAwait(false);
}
static string Payload(EveSurfaceCommandRequest request, string key) =>
request.PayloadFields.TryGetValue(key, out var value) ? value ?? "" : "";
static long PayloadLong(EveSurfaceCommandRequest request, string key, long fallback) =>
long.TryParse(Payload(request, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
static async Task AcceptEveCommandsAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options,
@ -2544,6 +2715,13 @@ static async Task PublishStateSurfacesAsync(
var playerSettingsUpdatedAt = string.IsNullOrWhiteSpace(playerSettings.LastUpdatedAtUtc)
? updatedAtUtc
: playerSettings.LastUpdatedAtUtc;
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
.ReadAsync().ConfigureAwait(false);
AetheriaLoadoutTemplate? hangarLoadout = null;
var selectedHangarShip = hangar?.Ships?.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(selectedHangarShip?.LoadoutTemplateKey))
hangarLoadout = await node.MutableDocument<AetheriaLoadoutTemplate>(new(selectedHangarShip.LoadoutTemplateKey))
.ReadAsync().ConfigureAwait(false);
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.OperationsSurfaceKey)
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildOperationsSurface(eveStatus, verseHost, runtimeSession))
@ -2554,6 +2732,18 @@ static async Task PublishStateSurfacesAsync(
await node.MutableDocument<EveProviderAdvertisementDocument>(AetheriaStateNode.ProviderAdvertisementSurfaceKey)
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildProviderAdvertisement(verseHost, node.StatePath, updatedAtUtc))
.ConfigureAwait(false);
if (hangar != null)
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.HangarSurface)
.ReplaceAsync(AetheriaRuntimeSurfaceDocuments.ToPortableSurface(
AetheriaRuntimeHangarSurfaceBuilder.Build(
hangar,
selectedHangarShip?.ShipId ?? "",
AetheriaGameModes.Terminus,
updatedAtUtc,
Math.Max(1, hangar.Revision),
hangarLoadout,
node.RuntimeCatalog().Latest())))
.ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
}
@ -2581,7 +2771,8 @@ static async Task PublishOdinSurfaceAnnouncementsAsync(
("aetheria.inventory.panel", "Inventory Panel", AetheriaRuntimeVerseRecordKeys.InventoryPanelSurface),
("aetheria.inventory.panel.dropdown", "Inventory Dropdown", AetheriaRuntimeVerseRecordKeys.InventoryDropdownSurface),
(AetheriaRuntimeSectorMapSurfaceBuilder.SurfaceId, "Sector Map", AetheriaRuntimeVerseRecordKeys.MapMenuSurface),
("aetheria.trade.menu", "Trade Menu", AetheriaRuntimeVerseRecordKeys.TradeMenuSurface)
("aetheria.trade.menu", "Trade Menu", AetheriaRuntimeVerseRecordKeys.TradeMenuSurface),
(AetheriaRuntimeHangarCommands.SurfaceId, "Aetheria Hangar", AetheriaRuntimeVerseRecordKeys.HangarSurface)
};
var documents = new List<CultNetDocumentPutRawMessage>();
@ -3115,43 +3306,6 @@ static async Task EnsureGameSessionAsync(
await node.FlushAsync().ConfigureAwait(false);
}
static async Task<bool> ApplyRequestedNewGameSessionAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options)
{
var menu = await node.MutableDocument<AetheriaMainMenuState>(AetheriaStateNode.MainMenuStateKey)
.ReadAsync().ConfigureAwait(false);
if (menu == null ||
!string.Equals(menu.LastCommand, AetheriaRuntimeMainMenuCommands.NewGame, StringComparison.Ordinal) ||
string.IsNullOrWhiteSpace(menu.LastCommandId))
return false;
var session = await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
.ReadAsync().ConfigureAwait(false) ?? new AetheriaGameSessionState();
if (string.Equals(session.LastStartCommandId, menu.LastCommandId, StringComparison.Ordinal))
return false;
var now = DateTimeOffset.UtcNow.ToString("O");
var written = await AetheriaDaemonRunFactory.WriteAsync(
node,
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
now,
menu.LastCommandId).ConfigureAwait(false);
session.Mode = written.SessionMode;
session.SessionId = options.SessionId;
session.RunId = written.RunId;
session.ControlledEntityKey = written.CurrentEntityKey;
session.EntrySurfaceId = AetheriaRuntimeDaemonGameSurfaceBuilder.PilotSurfaceId;
session.SimulationRate = 1;
session.EffectiveSimulationRate = 1;
session.LastStartCommandId = menu.LastCommandId;
session.UpdatedAtUtc = now;
await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
.ReplaceAsync(session).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
return true;
}
static async Task EnsureVerseAuthorityPolicyAsync(
AetheriaStateNode node,
AetheriaDaemonHostOptions options)
@ -3797,7 +3951,8 @@ internal sealed class AetheriaPlayableWorldDemandState
(demand.RecordKeys.Any(key =>
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), StringComparison.Ordinal) ||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameSurface.ToString(), StringComparison.Ordinal) ||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(), StringComparison.Ordinal)) ||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(), StringComparison.Ordinal) ||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(), StringComparison.Ordinal)) ||
demand.SchemaIds.Contains(AetheriaRuntimeDaemonSchemas.Frame, StringComparer.Ordinal) ||
demand.BodyIds.Contains(AetheriaRuntimeDaemonSoaFramePublisher.BodyId, StringComparer.Ordinal));
}

View file

@ -72,6 +72,163 @@ public static class AetheriaHangar
return receipt;
}
public static async Task<AetheriaHangarMutationResult> EquipAsync(
AetheriaStateNode node,
string shipId,
string itemKey,
long expectedRevision,
AetheriaRuntimeCatalogSnapshot catalog,
string now)
{
await AdmissionGate.WaitAsync().ConfigureAwait(false);
try
{
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
if (rejection != null) return new(false, rejection, hangar.Revision);
var stack = (hangar.Inventory ?? []).SingleOrDefault(value =>
string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal) && value.Quantity > 0);
if (stack == null) return new(false, "item is not available in Hangar inventory", hangar.Revision);
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
var destination = ToRuntimeEntity(template.RootEntity);
var source = new AetheriaRuntimeEntitySnapshotCommit
{
Equipment =
[
new AetheriaRuntimeLoadoutItemSlotCommit
{
Item = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = itemKey,
Quantity = 1,
Quality = 1,
Durability = 1,
Enabled = true
}
}
]
};
if (!AetheriaRuntimeRefitTransactions.TryEquip(
source,
AetheriaRuntimeRefitSourceKinds.Equipment,
0,
0,
0,
destination,
itemKey,
0,
0,
false,
catalog,
out rejection))
return new(false, rejection, hangar.Revision);
var updatedHangar = Clone(hangar);
var updatedStack = updatedHangar.Inventory.Single(value => string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal));
updatedStack.Quantity--;
updatedHangar.Inventory = updatedHangar.Inventory.Where(value => value.Quantity > 0).ToArray();
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
updatedHangar.UpdatedAtUtc = now;
var updatedTemplate = AetheriaRuntimeStateMapper.ToLoadoutTemplate(
new AetheriaRuntimeLoadoutTemplateCommit
{
Name = template.Name,
OwnerPlayerKey = template.OwnerPlayerKey,
RootEntity = ToRuntimeLoadout(destination)
},
now);
updatedTemplate.CreatedAtUtc = template.CreatedAtUtc;
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
return new(true, "", updatedHangar.Revision);
}
finally
{
AdmissionGate.Release();
}
}
public static async Task<AetheriaHangarMutationResult> RemoveAsync(
AetheriaStateNode node,
string shipId,
int equipmentIndex,
long expectedRevision,
string now)
{
await AdmissionGate.WaitAsync().ConfigureAwait(false);
try
{
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
if (rejection != null) return new(false, rejection, hangar.Revision);
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
var equipment = (template.RootEntity.Equipment ?? []).ToList();
if (equipmentIndex < 0 || equipmentIndex >= equipment.Count)
return new(false, "equipment index is outside the configured loadout", hangar.Revision);
var removed = equipment[equipmentIndex];
if (string.IsNullOrWhiteSpace(removed.Item?.ItemKey))
return new(false, "configured equipment item is missing", hangar.Revision);
equipment.RemoveAt(equipmentIndex);
var updatedTemplate = CloneTemplate(template);
updatedTemplate.RootEntity.Equipment = equipment.ToArray();
updatedTemplate.RootEntity.WeaponGroups = (updatedTemplate.RootEntity.WeaponGroups ?? [])
.Select(group => (group ?? [])
.Where(index => index != equipmentIndex)
.Select(index => index > equipmentIndex ? index - 1 : index)
.ToArray())
.ToArray();
updatedTemplate.UpdatedAtUtc = now;
var updatedHangar = Clone(hangar);
var stack = updatedHangar.Inventory.FirstOrDefault(value =>
string.Equals(value.ItemKey, removed.Item.ItemKey, StringComparison.Ordinal));
if (stack == null)
updatedHangar.Inventory = updatedHangar.Inventory.Append(new AetheriaHangarItemStack
{
ItemKey = removed.Item.ItemKey,
Quantity = 1
}).ToArray();
else
stack.Quantity++;
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
updatedHangar.UpdatedAtUtc = now;
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
await node.FlushAsync().ConfigureAwait(false);
return new(true, "", updatedHangar.Revision);
}
finally
{
AdmissionGate.Release();
}
}
private static string? ValidateRefit(
AetheriaHangarState hangar,
string shipId,
long expectedRevision,
out AetheriaHangarShip? ship)
{
ship = null;
if (expectedRevision != hangar.Revision) return "hangar revision mismatch";
ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
if (ship == null) return "ship is not owned by Hangar";
if (!string.Equals(ship.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal))
return "deployed ship cannot be refit from the Hangar";
if (string.IsNullOrWhiteSpace(ship.LoadoutTemplateKey)) return "ship has no configured loadout";
return null;
}
private static string? Validate(
AetheriaHangarState hangar,
AetheriaDeploymentRequest request,
@ -191,4 +348,87 @@ public static class AetheriaHangar
OverrideShutdown = source.OverrideShutdown,
Temperature = source.Temperature
};
private static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(AetheriaEntityLoadout source)
{
var loadout = ToRuntimeLoadout(source);
return new AetheriaRuntimeEntitySnapshotCommit
{
Name = loadout.Name,
Kind = loadout.Kind,
FactionKey = loadout.FactionKey,
HullItemKey = loadout.Hull.ItemKey,
Equipment = loadout.Equipment,
CargoBays = loadout.CargoBays,
DockingBays = loadout.DockingBays,
CargoContents = loadout.CargoContents,
DockingBayContents = loadout.DockingBayContents,
DockingBayAssignments = loadout.DockingBayAssignments,
WeaponGroups = loadout.WeaponGroups
};
}
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaRuntimeEntitySnapshotCommit source) => new()
{
Name = source.Name,
Kind = source.Kind,
FactionKey = source.FactionKey,
Hull = new AetheriaRuntimeLoadoutItemCommit
{
ItemKey = source.HullItemKey,
Quantity = 1,
Quality = 1,
Durability = 1,
Enabled = true
},
Equipment = source.Equipment ?? [],
CargoBays = source.CargoBays ?? [],
DockingBays = source.DockingBays ?? [],
CargoContents = source.CargoContents ?? [],
DockingBayContents = source.DockingBayContents ?? [],
DockingBayAssignments = source.DockingBayAssignments ?? [],
WeaponGroups = source.WeaponGroups ?? []
};
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaEntityLoadout source) => new()
{
Name = source.Name,
Kind = source.Kind,
FactionKey = source.FactionKey,
Hull = Clone(source.Hull),
Equipment = (source.Equipment ?? []).Select(Clone).ToArray(),
CargoBays = (source.CargoBays ?? []).Select(Clone).ToArray(),
DockingBays = (source.DockingBays ?? []).Select(Clone).ToArray(),
CargoContents = (source.CargoContents ?? []).Select(Clone).ToArray(),
DockingBayContents = (source.DockingBayContents ?? []).Select(Clone).ToArray(),
DockingBayAssignments = (source.DockingBayAssignments ?? []).ToArray(),
WeaponGroups = (source.WeaponGroups ?? []).Select(group => (IReadOnlyList<int>)(group ?? []).ToArray()).ToArray(),
Children = (source.Children ?? []).Select(ToRuntimeLoadout).ToArray()
};
private static AetheriaLoadoutTemplate CloneTemplate(AetheriaLoadoutTemplate source)
{
var clone = AetheriaRuntimeStateMapper.ToLoadoutTemplate(new AetheriaRuntimeLoadoutTemplateCommit
{
Name = source.Name,
OwnerPlayerKey = source.OwnerPlayerKey,
RootEntity = ToRuntimeLoadout(source.RootEntity)
}, source.UpdatedAtUtc);
clone.CreatedAtUtc = source.CreatedAtUtc;
return clone;
}
}
public sealed class AetheriaHangarMutationResult
{
public AetheriaHangarMutationResult(bool accepted, string diagnostic, long hangarRevision)
{
Accepted = accepted;
Diagnostic = diagnostic;
HangarRevision = hangarRevision;
}
public bool Accepted { get; }
public string Diagnostic { get; }
public long HangarRevision { get; }
}

View file

@ -120,6 +120,7 @@ namespace GameCult.Aetheria.State.Verse
Surface(AetheriaRuntimeMainMenuCommands.InputSettingsSurfaceId, "Main Menu Input Settings", AetheriaRuntimeVerseRecordKeys.MainMenuInputSettingsSurface.ToString(), "player", "Main menu input settings panel.", transport: "cultmesh-record"),
Surface(AetheriaRuntimeMainMenuCommands.PlayerSettingsSurfaceId, "Main Menu Player Settings", AetheriaRuntimeVerseRecordKeys.MainMenuPlayerSettingsSurface.ToString(), "player", "Main menu player settings panel.", transport: "cultmesh-record"),
Surface(AetheriaRuntimeMainMenuCommands.VerseSettingsSurfaceId, "Main Menu Verse Settings", AetheriaRuntimeVerseRecordKeys.MainMenuVerseSettingsSurface.ToString(), "player", "Main menu Verse settings panel.", transport: "cultmesh-record"),
Surface(AetheriaRuntimeHangarCommands.SurfaceId, "Aetheria Hangar", AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(), "player", "Daemon-owned ship fitting and mode deployment launcher.", providerId: "aetheria.daemon", providerKind: "game.daemon", transport: "cultmesh-record", surfaceKind: "interactive-world", worldInteraction: WorldInteraction("provider-authored-world-surface"), requiresPlugins: FieldPluginRequirements()),
Surface(AetheriaRuntimeInputSettingsCommands.SurfaceId, "Input Settings", "", "player", "Runtime input binding surface.", status: "client-contextual"),
Surface(AetheriaRuntimeVerseHostCommands.SurfaceId, "Verse Host Settings", "", "operator", "Verse host visibility and discovery surface.", status: "client-contextual"),
Surface(AetheriaRuntimeLoadoutTemplateCommands.SurfaceId, "Loadout Templates", "", "player", "Loadout template save surface.", status: "client-contextual"),

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Aetheria.State.Documents;
#nullable enable
@ -15,7 +16,10 @@ namespace GameCult.Aetheria.State.Verse
public const string SelectStarbridge = "aetheria.hangar.select_mode.starbridge";
public const string SelectArena = "aetheria.hangar.select_mode.arena";
public const string EditLoadout = "aetheria.hangar.edit_loadout";
public const string EquipItem = "aetheria.hangar.loadout.equip";
public const string RemoveItem = "aetheria.hangar.loadout.remove";
public const string Launch = "aetheria.hangar.launch";
public const string Continue = "aetheria.hangar.continue";
}
public static class AetheriaRuntimeHangarSurfaceBuilder
@ -25,14 +29,19 @@ namespace GameCult.Aetheria.State.Verse
string selectedShipId,
string selectedMode,
string updatedAtUtc,
long version = 1)
long version = 1,
AetheriaLoadoutTemplate? loadout = null,
AetheriaRuntimeCatalogSnapshot? catalog = null)
{
if (hangar == null) throw new ArgumentNullException(nameof(hangar));
var ships = hangar.Ships ?? Array.Empty<AetheriaHangarShip>();
var selected = ships.FirstOrDefault(ship => string.Equals(ship.ShipId, selectedShipId, StringComparison.Ordinal))
?? ships.FirstOrDefault();
var mode = AetheriaGameModes.IsKnown(selectedMode) ? selectedMode : AetheriaGameModes.Terminus;
var canLaunch = selected != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal);
var canLaunch = selected != null && loadout != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal);
var canContinue = selected != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Deployed, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(selected.ActiveDeploymentId);
var equipment = loadout?.RootEntity?.Equipment ?? Array.Empty<AetheriaLoadoutItemSlot>();
var inventory = hangar.Inventory ?? Array.Empty<AetheriaHangarItemStack>();
var root = Component(
"aetheria.hangar.root",
@ -63,6 +72,22 @@ namespace GameCult.Aetheria.State.Verse
Button("aetheria.hangar.fit.edit", "EDIT LOADOUT", AetheriaRuntimeHangarCommands.EditLoadout,
("targetSurfaceId", AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId))
}, "fit"),
Panel("aetheria.hangar.loadout", "LOADOUT", equipment.Select((slot, index) =>
Button(
$"aetheria.hangar.loadout.item.{index}",
$"{ItemName(catalog, slot.Item?.ItemKey)} [{slot.Position?.X ?? 0},{slot.Position?.Y ?? 0}] REMOVE",
AetheriaRuntimeHangarCommands.RemoveItem,
("shipId", selected?.ShipId ?? ""),
("equipmentIndex", index.ToString(CultureInfo.InvariantCulture)),
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))).ToArray(), "loadout"),
Panel("aetheria.hangar.inventory", "HANGAR INVENTORY", inventory.Select(stack =>
Button(
"aetheria.hangar.inventory." + StableToken(stack.ItemKey),
$"{ItemName(catalog, stack.ItemKey)} x{stack.Quantity} EQUIP",
AetheriaRuntimeHangarCommands.EquipItem,
("shipId", selected?.ShipId ?? ""),
("itemKey", stack.ItemKey),
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))).ToArray(), "inventory"),
Component("aetheria.hangar.bays", "row", Props(("label", "OWNED SHIPS")),
ships.Select(ship => Button(
"aetheria.hangar.bay." + StableToken(ship.ShipId),
@ -84,14 +109,18 @@ namespace GameCult.Aetheria.State.Verse
("enabled", canLaunch.ToString().ToLowerInvariant()),
("shipId", selected?.ShipId ?? ""),
("mode", mode),
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture))),
Button("aetheria.hangar.continue", "CONTINUE", AetheriaRuntimeHangarCommands.Continue,
("enabled", canContinue.ToString().ToLowerInvariant()),
("shipId", selected?.ShipId ?? ""),
("deploymentId", selected?.ActiveDeploymentId ?? ""))
}, Layout(("gridArea", "launch"), ("display", "flex"), ("justifyContent", "flex-end"), ("gap", "8")))
},
Layout(
("display", "grid"),
("gridTemplateColumns", "minmax(260px, 0.75fr) minmax(480px, 1.6fr) minmax(280px, 0.8fr)"),
("gridTemplateRows", "auto minmax(360px, 1fr) auto"),
("gridTemplateAreas", "\"launch launch launch\" \"summary preview fit\" \"bays bays bays\""),
("gridTemplateRows", "auto minmax(300px, 1fr) minmax(180px, 0.65fr) auto"),
("gridTemplateAreas", "\"launch launch launch\" \"summary preview fit\" \"inventory loadout loadout\" \"bays bays bays\""),
("gap", "10"),
("height", "100%")),
Style(("background", "#070b0d"), ("color", "#d8eef2")));
@ -110,10 +139,16 @@ namespace GameCult.Aetheria.State.Verse
Command(AetheriaRuntimeHangarCommands.SelectStarbridge, "Select Starbridge"),
Command(AetheriaRuntimeHangarCommands.SelectArena, "Select Arena"),
Command(AetheriaRuntimeHangarCommands.EditLoadout, "Edit Loadout"),
Command(AetheriaRuntimeHangarCommands.Launch, "Launch")
Command(AetheriaRuntimeHangarCommands.EquipItem, "Equip Item"),
Command(AetheriaRuntimeHangarCommands.RemoveItem, "Remove Item"),
Command(AetheriaRuntimeHangarCommands.Launch, "Launch"),
Command(AetheriaRuntimeHangarCommands.Continue, "Continue")
});
}
private static string ItemName(AetheriaRuntimeCatalogSnapshot? catalog, string? itemKey) =>
catalog?.FindItem(itemKey ?? "")?.Name ?? (string.IsNullOrWhiteSpace(itemKey) ? "Unknown" : itemKey);
private static AetheriaRuntimeSurfaceComponent Panel(string id, string title, IReadOnlyList<AetheriaRuntimeSurfaceComponent> children, string area) =>
Component(id, "panel", Props(("title", title)), children, Layout(("gridArea", area)), Style(("background", "#11181c"), ("border", "1px solid #60737a"), ("padding", "12")));

View file

@ -118,6 +118,9 @@ namespace GameCult.Aetheria.State.Verse
public static CultRecordKey TradeMenuSurface { get; } =
new CultRecordKey("eve:surface:aetheria.trade.menu");
public static CultRecordKey HangarSurface { get; } =
new CultRecordKey("eve:surface:aetheria.hangar");
public static CultRecordKey DaemonCommand(string commandId)
{
return new CultRecordKey($"daemon:commands:{StableToken(commandId)}:gamecult.aetheria.daemon_command.v1");

View file

@ -1,6 +1,6 @@
# Aetheria Game Modes And Progression
Status: adopted target design; shared Hangar state is not yet implemented
Status: adopted design; the first daemon-owned Hangar/Terminus deployment slice is implemented
## Product Modes
@ -177,22 +177,31 @@ a parallel gameplay path.
## Current Migration Seam
The daemon already owns typed run checkpoints, loadout templates, catalog keys,
combat, loot, docking, trade, and Terminus scenario generation. Starbridge has
typed session, station-stock, role, wave, and summary documents. These are
components of the design, not yet a durable shared Hangar owner.
The daemon now owns one typed local Hangar, its starter ship, stored equipment,
saved loadout template, Hangar revision, and immutable Terminus deployment
receipt. It publishes `aetheria.hangar` as the ordinary Unity entry surface.
The surface advertises equip, remove, launch, and continue operations. Launch
instantiates the current committed loadout into a newly identified Terminus
run; continue reopens the deployed run selected by the existing checkpoint.
Unity remains a generic Eve lowerer and never edits those documents directly.
This is the first vertical slice, not the completed cross-mode progression
machine. Settlement, currencies/unlocks, multiple owned ships, richer fitting
interaction, Starbridge admission, and Arena admission still need to enter the
same Hangar boundary. The old main-menu `New Game` writer is no longer a
product run-creation authority. Explicit Terminus proof profiles remain
verification-only inputs.
Arena does not yet have a named server/session/admission/score document family
or a preserved headless controller harness. Shared simulation smokes are useful
substrate, but they do not constitute the Arena mode. Starbridge's existing
documents also do not by themselves prove mixed-authority convergence.
The next state-architecture cut is to define the canonical Hangar, Deployment,
and Settlement documents; define one typed mode-selection boundary; and keep a
minimal Terminus, Starbridge, and Arena vertical slice alive against it. Until
that cut lands, documentation and verification must not describe existing run
checkpoints or proof scenarios as cross-mode progression authority, or generic
simulation smokes as Arena product proof.
The next state-architecture cut is to carry this Hangar and Deployment boundary
through Starbridge and Arena admission, then add exactly-once Settlement. Until
those cuts land, documentation and verification must not describe the local
starter Hangar as completed cross-mode progression, or generic simulation
smokes as Arena product proof.
## Required Proofs

View file

@ -24,10 +24,14 @@ leaving Play drops that client while the authoritative daemon remains alive and
ready for the next connection.
Starting the daemon does not create, load, or step a game world. It starts the
typed state boundary and CultMesh transport in `ready` mode. A playable-world
subscription loads an existing run. Typed mode selection chooses authority and
policy; a committed deployment/admission creates session state through the
applicable provider or Arena server. Only that activation boundary opens
typed state boundary and CultMesh transport in `ready` mode and publishes the
`aetheria.hangar` Eve surface. The Hangar exposes the canonical saved loadout
and typed equip/remove operations. **Launch** commits a Terminus deployment
from the current Hangar revision and creates its run; **Continue** selects the
existing deployed Terminus checkpoint without creating a replacement run.
Typed mode selection chooses authority and policy; a committed
deployment/admission creates session state through the applicable provider or
Arena server. Only that activation boundary opens
simulation persistence and starts the fixed simulation clock. When the last
playable-world subscription is withdrawn,
the daemon commits the final public frame and matching private Ymir checkpoint,
@ -53,9 +57,10 @@ being debugged.
1. Press **Build daemon** after changing daemon code. Use **Reimport state &
build** only when the isolated development state must be replaced.
2. Press **Start daemon** and wait for the window to report the live endpoint.
3. Enter and leave Unity Play Mode normally. The daemon remains running across
client sessions.
4. Edit and inspect the generic EveUnity-lowered world in Play Mode.
3. Enter Unity Play Mode. The generic client mounts `aetheria.hangar`.
4. Configure the saved loadout, then choose **Launch** for a new Terminus run
or **Continue** for the currently deployed saved run. The same mounted Eve
surface becomes the gameplay composition after activation.
5. Use Unity Pause to submit the advertised `simulation.pause` action to the
daemon. Unity unpause submits `simulation.rate.realtime`.
6. While paused, use **Advance one step** to commit exactly one fixed daemon

View file

@ -60,7 +60,6 @@ $daemon = Start-Process dotnet -ArgumentList @(
"--client-cultmesh-port", $Port,
"--tick-interval-ms", 20,
"--fixed-delta-ms", 20,
"--terminus-scenario", "standard",
"--no-odin-announcements"
) -PassThru -WindowStyle Hidden -RedirectStandardOutput $daemonLog -RedirectStandardError "$daemonLog.error"
Write-Host "Daemon PID: $($daemon.Id)"
@ -79,7 +78,7 @@ try {
}
if (-not $ready) { throw "Aetheria daemon did not publish its Eve endpoint." }
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port"
$env:EVEUNITY_SURFACE_ID = "aetheria.pilot"
$env:EVEUNITY_SURFACE_ID = "aetheria.hangar"
$client = Start-Process $clientExe -ArgumentList "-force-d3d11" -PassThru
Write-Host "Client PID: $($client.Id)"
Write-Host "Close the client window to stop the daemon."

View file

@ -114,4 +114,12 @@ foreach ($forbiddenInput in @("AetheriaRuntimeVerseSchemas", "AetheriaRuntimeVer
if ($bootstrapSource -match [regex]::Escape($forbiddenInput)) { throw "Client bootstrap depends on forbidden product input '$forbiddenInput'." }
}
$launcherSource = Get-Content (Join-Path (Split-Path $PSScriptRoot -Parent) "scripts/run-aetheria-unity.ps1") -Raw
if ($launcherSource -notmatch 'EVEUNITY_SURFACE_ID\s*=\s*"aetheria\.hangar"') {
throw "Released launcher must enter through the daemon-owned Hangar surface."
}
if ($launcherSource -match '--terminus-scenario') {
throw "Released launcher must not create product state through a Terminus proof-scenario flag."
}
Write-Host "Aetheria Unity client dependency and bootstrap verification passed."