From 152db9e744f7ceb404bdf12fc7993813e19bb321 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 8 Aug 2026 08:45:14 +0100 Subject: [PATCH] Launch Terminus through daemon-owned Hangar --- Aetheria.State.Daemon.Smoke/Program.cs | 84 ++++++ .../AetheriaDaemonHangarCoordinator.cs | 196 +++++++++++++ .../AetheriaDaemonZoneGenerator.cs | 87 +++++- Aetheria.State.Daemon/Program.cs | 257 ++++++++++++++---- Aetheria.State/AetheriaHangar.cs | 240 ++++++++++++++++ .../AetheriaRuntimeEveSurfaceCatalog.cs | 1 + .../AetheriaRuntimeHangarSurfaceBuilder.cs | 47 +++- .../Runtime/AetheriaRuntimeVerseClient.cs | 3 + docs/game-modes-and-progression.md | 31 ++- docs/unity-daemon-development.md | 19 +- scripts/run-aetheria-unity.ps1 | 3 +- scripts/verify-aetheria-unity-client.ps1 | 8 + 12 files changed, 891 insertions(+), 85 deletions(-) create mode 100644 Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 64c2d2442..1f5db9942 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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(AetheriaStateNode.HangarKey) + .ReadAsync().GetAwaiter().GetResult()!; + var ship = hangar.Ships.Single(); + var template = node.MutableDocument(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(AetheriaStateNode.HangarKey) + .ReadAsync().GetAwaiter().GetResult()!; + template = node.MutableDocument(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(AetheriaStateNode.PlayerSettingsKey) + .ReadAsync().GetAwaiter().GetResult()!; + runKey = settings.ActiveRunKey; + var run = node.MutableDocument(new CultRecordKey(runKey)) + .ReadAsync().GetAwaiter().GetResult()!; + var player = node.MutableDocument(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(AetheriaStateNode.PlayerSettingsKey) + .ReadAsync().GetAwaiter().GetResult(); + var run = reopened.MutableDocument(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 _); diff --git a/Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs b/Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs new file mode 100644 index 000000000..603b313cf --- /dev/null +++ b/Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs @@ -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(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(StringComparer.Ordinal) { [factionKey] = 0 }, + new Dictionary> { [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(loadoutKey).ReplaceAsync(loadout).ConfigureAwait(false); + await node.MutableDocument(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 LaunchTerminusAsync( + AetheriaStateNode node, + AetheriaRuntimeCatalogSnapshot catalog, + string requestId, + string shipId, + long expectedRevision, + string now) + { + var hangar = await node.MutableDocument(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 CanContinueTerminusAsync( + AetheriaStateNode node, + string shipId, + string deploymentId) + { + var hangar = await node.MutableDocument(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(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)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 + } + }; +} diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index 0d96cbf70..e53be573a 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -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()) @@ -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> { [0] = Array.Empty() }; 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(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 loadouts, IReadOnlyDictionary 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, diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index 338ef3025..ac4fc3696 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -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(); +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 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(AetheriaRuntimeVerseRecordKeys.DaemonGameSurface) .ReplaceAsync(portableGameSurface) .ConfigureAwait(false); + await node.MutableDocument(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(), new[] { AetheriaRuntimeDaemonSoaFramePublisher.ProducerId }); @@ -2413,11 +2419,12 @@ static AetheriaRuntimeSurfaceComponent SurfaceNode( children); } -static async Task AcceptCoreEveInvocationsAsync( +static async Task AcceptCoreEveInvocationsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options, AetheriaRuntimeDaemonFrameDocument? currentFrame) { + var activatedSession = false; var accounted = new HashSet( currentFrame?.AccountedCommandIds ?? Array.Empty(), 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 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(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(AetheriaStateNode.HangarKey) + .ReadAsync().ConfigureAwait(false); + AetheriaLoadoutTemplate? hangarLoadout = null; + var selectedHangarShip = hangar?.Ships?.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(selectedHangarShip?.LoadoutTemplateKey)) + hangarLoadout = await node.MutableDocument(new(selectedHangarShip.LoadoutTemplateKey)) + .ReadAsync().ConfigureAwait(false); await node.MutableDocument(AetheriaStateNode.OperationsSurfaceKey) .ReplaceAsync(AetheriaEveSurfaceDocuments.BuildOperationsSurface(eveStatus, verseHost, runtimeSession)) @@ -2554,6 +2732,18 @@ static async Task PublishStateSurfacesAsync( await node.MutableDocument(AetheriaStateNode.ProviderAdvertisementSurfaceKey) .ReplaceAsync(AetheriaEveSurfaceDocuments.BuildProviderAdvertisement(verseHost, node.StatePath, updatedAtUtc)) .ConfigureAwait(false); + if (hangar != null) + await node.MutableDocument(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(); @@ -3115,43 +3306,6 @@ static async Task EnsureGameSessionAsync( await node.FlushAsync().ConfigureAwait(false); } -static async Task ApplyRequestedNewGameSessionAsync( - AetheriaStateNode node, - AetheriaDaemonHostOptions options) -{ - var menu = await node.MutableDocument(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(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(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)); } diff --git a/Aetheria.State/AetheriaHangar.cs b/Aetheria.State/AetheriaHangar.cs index ed7eec55b..582b03385 100644 --- a/Aetheria.State/AetheriaHangar.cs +++ b/Aetheria.State/AetheriaHangar.cs @@ -72,6 +72,163 @@ public static class AetheriaHangar return receipt; } + public static async Task EquipAsync( + AetheriaStateNode node, + string shipId, + string itemKey, + long expectedRevision, + AetheriaRuntimeCatalogSnapshot catalog, + string now) + { + await AdmissionGate.WaitAsync().ConfigureAwait(false); + try + { + var pointer = node.MutableDocument(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(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 RemoveAsync( + AetheriaStateNode node, + string shipId, + int equipmentIndex, + long expectedRevision, + string now) + { + await AdmissionGate.WaitAsync().ConfigureAwait(false); + try + { + var pointer = node.MutableDocument(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(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)(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; } } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs index adef5c240..76cff899b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeEveSurfaceCatalog.cs @@ -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"), diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs index 7457a3f50..c7575dd3b 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs @@ -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(); 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(); + var inventory = hangar.Inventory ?? Array.Empty(); 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 children, string area) => Component(id, "panel", Props(("title", title)), children, Layout(("gridArea", area)), Style(("background", "#11181c"), ("border", "1px solid #60737a"), ("padding", "12"))); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs index 6e831e9e4..0fffd8bbb 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeVerseClient.cs @@ -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"); diff --git a/docs/game-modes-and-progression.md b/docs/game-modes-and-progression.md index 8f9a56f9d..6f2407622 100644 --- a/docs/game-modes-and-progression.md +++ b/docs/game-modes-and-progression.md @@ -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 diff --git a/docs/unity-daemon-development.md b/docs/unity-daemon-development.md index 69cee7ffb..453a6df22 100644 --- a/docs/unity-daemon-development.md +++ b/docs/unity-daemon-development.md @@ -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 diff --git a/scripts/run-aetheria-unity.ps1 b/scripts/run-aetheria-unity.ps1 index 5732d27ea..8cc3817c4 100644 --- a/scripts/run-aetheria-unity.ps1 +++ b/scripts/run-aetheria-unity.ps1 @@ -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." diff --git a/scripts/verify-aetheria-unity-client.ps1 b/scripts/verify-aetheria-unity-client.ps1 index 48413d70c..ba502d314 100644 --- a/scripts/verify-aetheria-unity-client.ps1 +++ b/scripts/verify-aetheria-unity-client.ps1 @@ -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."