diff --git a/Aetheria.State.Smoke/Program.cs b/Aetheria.State.Smoke/Program.cs index 7db9ff584..9990e9f05 100644 --- a/Aetheria.State.Smoke/Program.cs +++ b/Aetheria.State.Smoke/Program.cs @@ -312,6 +312,90 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s } }); + await node.MutableDocument(AetheriaStateNode.HangarKey).ReplaceAsync(new AetheriaHangarState + { + HangarId = "smoke", + PlayerKey = "player:smoke", + Revision = 7, + Ships = + [ + new AetheriaHangarShip + { + ShipId = "ship:smoke:aether-runner", + HullItemKey = itemKey.ToString(), + LoadoutTemplateKey = loadoutKey.ToString() + }, + new AetheriaHangarShip + { + ShipId = "ship:smoke:starbridge", + HullItemKey = itemKey.ToString(), + LoadoutTemplateKey = loadoutKey.ToString() + }, + new AetheriaHangarShip + { + ShipId = "ship:smoke:arena", + HullItemKey = itemKey.ToString(), + LoadoutTemplateKey = loadoutKey.ToString() + } + ], + LoadoutTemplateKeys = [loadoutKey.ToString()], + UpdatedAtUtc = now + }); + var deploymentRequest = new AetheriaDeploymentRequest + { + RequestId = "request:smoke:terminus", + PlayerKey = "player:smoke", + Mode = AetheriaGameModes.Terminus, + ShipId = "ship:smoke:aether-runner", + LoadoutTemplateKey = loadoutKey.ToString(), + ExpectedHangarRevision = 7, + ModePolicyId = AetheriaModePolicies.TerminusLocal + }; + var deployment = await AetheriaHangar.AdmitAsync(node, deploymentRequest, now); + var duplicateDeployment = await AetheriaHangar.AdmitAsync(node, deploymentRequest, now); + if (!deployment.Accepted || + deployment.DeploymentId != duplicateDeployment.DeploymentId || + deployment.HangarRevision != 8) + throw new InvalidOperationException("Hangar deployment admission was not accepted exactly once."); + + foreach (var (mode, shipId, revision, policy) in new[] + { + (AetheriaGameModes.Starbridge, "ship:smoke:starbridge", 8L, AetheriaModePolicies.StarbridgeMixed), + (AetheriaGameModes.Arena, "ship:smoke:arena", 9L, AetheriaModePolicies.ArenaServer) + }) + { + var modeDeployment = await AetheriaHangar.AdmitAsync(node, new AetheriaDeploymentRequest + { + RequestId = $"request:smoke:{mode}", + PlayerKey = "player:smoke", + Mode = mode, + ShipId = shipId, + LoadoutTemplateKey = loadoutKey.ToString(), + ExpectedHangarRevision = revision, + ModePolicyId = policy + }, now); + if (!modeDeployment.Accepted || modeDeployment.Mode != mode) + throw new InvalidOperationException($"Hangar rejected the shared {mode} deployment boundary."); + } + + var staleDeployment = AetheriaHangar.Admit( + await node.MutableDocument(AetheriaStateNode.HangarKey).ReadAsync() + ?? throw new InvalidOperationException("Hangar disappeared during admission smoke."), + new AetheriaDeploymentRequest + { + RequestId = "request:smoke:stale", + PlayerKey = "player:smoke", + Mode = AetheriaGameModes.Arena, + ShipId = "ship:smoke:aether-runner", + LoadoutTemplateKey = loadoutKey.ToString(), + ExpectedHangarRevision = 7, + ModePolicyId = AetheriaModePolicies.ArenaServer + }, + await node.MutableDocument(loadoutKey).ReadAsync(), + now); + if (staleDeployment.Accepted || staleDeployment.Diagnostic != "hangar revision mismatch") + throw new InvalidOperationException("Stale Hangar deployment bypassed revision admission."); + await node.MutableDocument(entityKey).ReplaceAsync(new AetheriaEntitySnapshot { Name = "Smoke Aether Runner", @@ -767,6 +851,7 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether var runtimeSession = await reopened.MutableDocument(AetheriaStateNode.RuntimeSessionKey("smoke-runtime")).ReadAsync(); var playerSettings = await reopened.MutableDocument(AetheriaStateNode.PlayerSettingsKey).ReadAsync(); var loadout = await reopened.MutableDocument(loadoutKey).ReadAsync(); + var hangar = await reopened.MutableDocument(AetheriaStateNode.HangarKey).ReadAsync(); var runState = await reopened.MutableDocument(runKey).ReadAsync(); var zoneState = await reopened.MutableDocument(zoneKey).ReadAsync(); var entitySnapshot = await reopened.MutableDocument(entityKey).ReadAsync(); @@ -776,6 +861,27 @@ await using (var reopened = await AetheriaStateNode.OpenAsync(statePath, "aether throw new InvalidOperationException("World state did not survive flush/reopen."); } + if (hangar?.Revision != 10 || + hangar.Deployments.Length != 3 || + hangar.Deployments.Select(value => value.Mode).Distinct().Count() != 3 || + hangar.Deployments.Any(value => !value.Accepted) || + hangar.Ships.Any(value => value.Status != AetheriaHangarShipStatuses.Deployed)) + throw new InvalidOperationException("Canonical Hangar deployment did not survive flush/reopen."); + + var hangarSurface = AetheriaRuntimeHangarSurfaceBuilder.Build( + hangar, + "ship:smoke:aether-runner", + AetheriaGameModes.Arena, + now); + var hangarComponents = Flatten(hangarSurface.Surface.Root).ToArray(); + if (hangarSurface.Surface.Id != AetheriaRuntimeHangarCommands.SurfaceId || + !hangarSurface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.Launch) || + !hangarComponents.Any(component => component.Id == "aetheria.hangar.preview.slot") || + !hangarComponents.Any(component => component.Props.TryGetValue("targetSurfaceId", out var target) && + target == AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId) || + hangarComponents.Count(component => component.Id.StartsWith("aetheria.hangar.bay.", StringComparison.Ordinal)) != 3) + throw new InvalidOperationException("Hangar surface did not expose preview, ship bays, loadout editor, and launch boundary."); + if (item?.Name != "Smoke Aether Drive") { throw new InvalidOperationException("Item definition did not survive flush/reopen."); @@ -1332,6 +1438,14 @@ static byte[] ReadLiveBody(CultMeshNetworkBodyStore bodies, CultMeshBodyDescript return body; } +static IEnumerable Flatten(AetheriaRuntimeSurfaceComponent component) +{ + yield return component; + foreach (var child in component.Children ?? Array.Empty()) + foreach (var descendant in Flatten(child)) + yield return descendant; +} + static int ReadInt32(EveEntitySoaViewDocument view, ICultMeshBodyReadLease lease, string semantic, int row) { var column = view.Columns.Single(candidate => candidate.Semantic == semantic); diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index 5ac01d9a2..7ae312b68 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -28,6 +28,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaCorporation), typeof(AetheriaNameFile), typeof(AetheriaPlayerProfile), + typeof(AetheriaHangarState), typeof(AetheriaRuntimeSession), typeof(AetheriaMigrationLedger), typeof(AetheriaLegacyCatalogQuarantine), diff --git a/Aetheria.State/AetheriaHangar.cs b/Aetheria.State/AetheriaHangar.cs new file mode 100644 index 000000000..ed7eec55b --- /dev/null +++ b/Aetheria.State/AetheriaHangar.cs @@ -0,0 +1,194 @@ +using Aetheria.State.Documents; +using GameCult.Aetheria.State.Verse; + +namespace Aetheria.State; + +public static class AetheriaHangar +{ + private static readonly SemaphoreSlim AdmissionGate = new(1, 1); + + public static async Task AdmitAsync( + AetheriaStateNode node, + AetheriaDeploymentRequest request, + string now) + { + if (node == null) throw new ArgumentNullException(nameof(node)); + if (request == null) throw new ArgumentNullException(nameof(request)); + + 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 loadout = string.IsNullOrWhiteSpace(request.LoadoutTemplateKey) + ? null + : await node.MutableDocument(new(request.LoadoutTemplateKey)) + .ReadAsync().ConfigureAwait(false); + var updated = Clone(hangar); + var receipt = Admit(updated, request, loadout, now); + if (!receipt.Accepted) + return receipt; + + await pointer.ReplaceAsync(updated).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); + return receipt; + } + finally + { + AdmissionGate.Release(); + } + } + + public static AetheriaDeploymentReceipt Admit( + AetheriaHangarState hangar, + AetheriaDeploymentRequest request, + AetheriaLoadoutTemplate? loadout, + string now) + { + if (hangar == null) throw new ArgumentNullException(nameof(hangar)); + if (request == null) throw new ArgumentNullException(nameof(request)); + + var prior = (hangar.Deployments ?? []).FirstOrDefault(value => + string.Equals(value.RequestId, request.RequestId, StringComparison.Ordinal)); + if (prior != null) + return prior; + + var rejection = Validate(hangar, request, loadout); + if (rejection != null) + return Receipt(hangar, request, loadout, now, false, rejection, hangar.Revision); + + var ship = hangar.Ships.Single(value => + string.Equals(value.ShipId, request.ShipId, StringComparison.Ordinal)); + var revision = checked(hangar.Revision + 1); + var receipt = Receipt(hangar, request, loadout, now, true, "", revision); + + ship.Status = AetheriaHangarShipStatuses.Deployed; + ship.ActiveDeploymentId = receipt.DeploymentId; + ship.LoadoutTemplateKey = request.LoadoutTemplateKey; + hangar.Revision = revision; + hangar.UpdatedAtUtc = now; + hangar.Deployments = (hangar.Deployments ?? []).Append(receipt).ToArray(); + return receipt; + } + + private static string? Validate( + AetheriaHangarState hangar, + AetheriaDeploymentRequest request, + AetheriaLoadoutTemplate? loadout) + { + if (string.IsNullOrWhiteSpace(request.RequestId)) return "deployment request id is required"; + if (!AetheriaGameModes.IsKnown(request.Mode)) return "unknown game mode"; + if (!string.Equals(hangar.PlayerKey, request.PlayerKey, StringComparison.Ordinal)) return "player does not own hangar"; + if (request.ExpectedHangarRevision != hangar.Revision) return "hangar revision mismatch"; + if (!string.Equals(request.ModePolicyId, AetheriaModePolicies.ForMode(request.Mode), StringComparison.Ordinal)) + return "mode policy does not match game mode"; + + var ships = (hangar.Ships ?? []).Where(value => + string.Equals(value.ShipId, request.ShipId, StringComparison.Ordinal)).ToArray(); + if (ships.Length != 1) return "deployment ship is not uniquely owned by hangar"; + if (!string.Equals(ships[0].Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal)) + return "deployment ship is not available"; + if (!(hangar.LoadoutTemplateKeys ?? []).Contains(request.LoadoutTemplateKey, StringComparer.Ordinal)) + return "loadout template is not owned by hangar"; + if (loadout == null) return "loadout template is missing"; + if (!string.Equals(loadout.OwnerPlayerKey, request.PlayerKey, StringComparison.Ordinal)) + return "player does not own loadout template"; + if (!string.Equals(loadout.RootEntity.Hull.ItemKey, ships[0].HullItemKey, StringComparison.Ordinal)) + return "loadout hull does not match deployed ship"; + return null; + } + + private static AetheriaDeploymentReceipt Receipt( + AetheriaHangarState hangar, + AetheriaDeploymentRequest request, + AetheriaLoadoutTemplate? loadout, + string now, + bool accepted, + string diagnostic, + long revision) => new() + { + DeploymentId = accepted ? $"deployment:{hangar.HangarId}:{revision}" : "", + RequestId = request.RequestId, + Accepted = accepted, + Diagnostic = diagnostic, + PlayerKey = request.PlayerKey, + Mode = request.Mode, + ShipId = request.ShipId, + LoadoutTemplateKey = request.LoadoutTemplateKey, + HangarRevision = revision, + ModePolicyId = request.ModePolicyId, + Loadout = accepted ? Clone(loadout!.RootEntity) : new AetheriaRuntimeEntityLoadoutCommit(), + CommittedAtUtc = now + }; + + private static AetheriaRuntimeEntityLoadoutCommit Clone(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 => (group ?? []).ToArray()).ToArray(), + Children = (source.Children ?? []).Select(Clone).ToArray() + }; + + private static AetheriaHangarState Clone(AetheriaHangarState source) => new() + { + Name = source.Name, + HangarId = source.HangarId, + PlayerKey = source.PlayerKey, + Revision = source.Revision, + Ships = (source.Ships ?? []).Select(ship => new AetheriaHangarShip + { + ShipId = ship.ShipId, + HullItemKey = ship.HullItemKey, + LoadoutTemplateKey = ship.LoadoutTemplateKey, + Status = ship.Status, + ActiveDeploymentId = ship.ActiveDeploymentId + }).ToArray(), + Inventory = (source.Inventory ?? []).Select(item => new AetheriaHangarItemStack + { + ItemKey = item.ItemKey, + Quantity = item.Quantity + }).ToArray(), + Currencies = (source.Currencies ?? []).Select(currency => new AetheriaHangarCurrency + { + CurrencyKey = currency.CurrencyKey, + Quantity = currency.Quantity + }).ToArray(), + UnlockKeys = (source.UnlockKeys ?? []).ToArray(), + LoadoutTemplateKeys = (source.LoadoutTemplateKeys ?? []).ToArray(), + Deployments = (source.Deployments ?? []).ToArray(), + UpdatedAtUtc = source.UpdatedAtUtc + }; + + private static AetheriaRuntimeLoadoutItemSlotCommit Clone(AetheriaLoadoutItemSlot source) => new() + { + X = source.Position.X, + Y = source.Position.Y, + Rotation = source.Rotation, + Item = Clone(source.Item) + }; + + private static AetheriaRuntimeCargoBayLoadoutCommit Clone(AetheriaCargoBayLoadout source) => new() + { + Items = (source.Items ?? []).Select(Clone).ToArray() + }; + + private static AetheriaRuntimeLoadoutItemCommit Clone(AetheriaLoadoutItem source) => new() + { + ItemKey = source.ItemKey, + Quality = source.Quality, + Durability = source.Durability, + Quantity = source.Quantity, + Enabled = source.Enabled, + OverrideShutdown = source.OverrideShutdown, + Temperature = source.Temperature + }; +} diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 51ff5687d..d31eac525 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -143,6 +143,7 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable RuntimeCatalogKey.ToString(), TradeValuePolicyKey.ToString(), PlayerSettingsKey.ToString(), + HangarKey.ToString(), VerseHostSettingsKey.ToString(), GameSessionStateKey.ToString(), MainMenuStateKey.ToString(), @@ -415,6 +416,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public static CultRecordKey PlayerSettingsKey { get; } = new("global:aetheria.player_settings.v1"); + public static CultRecordKey HangarKey { get; } = + new("global:gamecult.aetheria.hangar.v1"); + public static CultRecordKey VerseHostSettingsKey { get; } = new("global:aetheria.verse_host_settings.v1"); diff --git a/Aetheria.State/Documents/AetheriaGameSessionState.cs b/Aetheria.State/Documents/AetheriaGameSessionState.cs index 2feaa8fdd..f648c535e 100644 --- a/Aetheria.State/Documents/AetheriaGameSessionState.cs +++ b/Aetheria.State/Documents/AetheriaGameSessionState.cs @@ -1,4 +1,5 @@ using GameCult.Caching; +using GameCult.Aetheria.State.Verse; using MessagePack; namespace Aetheria.State.Documents; @@ -8,7 +9,9 @@ namespace Aetheria.State.Documents; [MessagePackObject] public sealed class AetheriaGameSessionState { - public const string TerminusMode = "terminus"; + public const string TerminusMode = AetheriaGameModes.Terminus; + public const string StarbridgeMode = AetheriaGameModes.Starbridge; + public const string ArenaMode = AetheriaGameModes.Arena; public const string AetheriaMode = "aetheria"; [Key(0)] public string Mode { get; set; } = TerminusMode; diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs new file mode 100644 index 000000000..38324c492 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using GameCult.Caching; +using MessagePack; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaGameModes + { + public const string Terminus = "terminus"; + public const string Starbridge = "starbridge"; + public const string Arena = "arena"; + + public static bool IsKnown(string? mode) => + string.Equals(mode, Terminus, StringComparison.Ordinal) || + string.Equals(mode, Starbridge, StringComparison.Ordinal) || + string.Equals(mode, Arena, StringComparison.Ordinal); + } + + public static class AetheriaModePolicies + { + public const string TerminusLocal = "aetheria.mode.terminus.local.v1"; + public const string StarbridgeMixed = "aetheria.mode.starbridge.mixed.v1"; + public const string ArenaServer = "aetheria.mode.arena.server.v1"; + + public static string ForMode(string? mode) + { + if (string.Equals(mode, AetheriaGameModes.Terminus, StringComparison.Ordinal)) return TerminusLocal; + if (string.Equals(mode, AetheriaGameModes.Starbridge, StringComparison.Ordinal)) return StarbridgeMixed; + if (string.Equals(mode, AetheriaGameModes.Arena, StringComparison.Ordinal)) return ArenaServer; + return ""; + } + } + + public static class AetheriaHangarShipStatuses + { + public const string Available = "available"; + public const string Deployed = "deployed"; + public const string Lost = "lost"; + } + + [CultDocument("gamecult.aetheria.hangar", "gamecult.aetheria.hangar.v1")] + [CultGlobal] + [MessagePackObject] + public sealed class AetheriaHangarState + { + [Key(0), CultName] public string Name { get; set; } = "Aetheria Hangar"; + [Key(1)] public string HangarId { get; set; } = "local"; + [Key(2)] public string PlayerKey { get; set; } = ""; + [Key(3)] public long Revision { get; set; } + [Key(4)] public AetheriaHangarShip[] Ships { get; set; } = Array.Empty(); + [Key(5)] public AetheriaHangarItemStack[] Inventory { get; set; } = Array.Empty(); + [Key(6)] public AetheriaHangarCurrency[] Currencies { get; set; } = Array.Empty(); + [Key(7)] public string[] UnlockKeys { get; set; } = Array.Empty(); + [Key(8)] public string[] LoadoutTemplateKeys { get; set; } = Array.Empty(); + [Key(9)] public AetheriaDeploymentReceipt[] Deployments { get; set; } = Array.Empty(); + [Key(10)] public string UpdatedAtUtc { get; set; } = ""; + } + + [MessagePackObject] + public sealed class AetheriaHangarShip + { + [Key(0)] public string ShipId { get; set; } = ""; + [Key(1)] public string HullItemKey { get; set; } = ""; + [Key(2)] public string LoadoutTemplateKey { get; set; } = ""; + [Key(3)] public string Status { get; set; } = AetheriaHangarShipStatuses.Available; + [Key(4)] public string ActiveDeploymentId { get; set; } = ""; + } + + [MessagePackObject] + public sealed class AetheriaHangarItemStack + { + [Key(0)] public string ItemKey { get; set; } = ""; + [Key(1)] public long Quantity { get; set; } + } + + [MessagePackObject] + public sealed class AetheriaHangarCurrency + { + [Key(0)] public string CurrencyKey { get; set; } = ""; + [Key(1)] public long Quantity { get; set; } + } + + [MessagePackObject] + public sealed class AetheriaDeploymentRequest + { + [Key(0)] public string RequestId { get; set; } = ""; + [Key(1)] public string PlayerKey { get; set; } = ""; + [Key(2)] public string Mode { get; set; } = AetheriaGameModes.Terminus; + [Key(3)] public string ShipId { get; set; } = ""; + [Key(4)] public string LoadoutTemplateKey { get; set; } = ""; + [Key(5)] public long ExpectedHangarRevision { get; set; } + [Key(6)] public string ModePolicyId { get; set; } = ""; + } + + [MessagePackObject] + public sealed class AetheriaDeploymentReceipt + { + [Key(0)] public string DeploymentId { get; set; } = ""; + [Key(1)] public string RequestId { get; set; } = ""; + [Key(2)] public bool Accepted { get; set; } + [Key(3)] public string Diagnostic { get; set; } = ""; + [Key(4)] public string PlayerKey { get; set; } = ""; + [Key(5)] public string Mode { get; set; } = ""; + [Key(6)] public string ShipId { get; set; } = ""; + [Key(7)] public string LoadoutTemplateKey { get; set; } = ""; + [Key(8)] public long HangarRevision { get; set; } + [Key(9)] public string ModePolicyId { get; set; } = ""; + [Key(10)] public AetheriaRuntimeEntityLoadoutCommit Loadout { get; set; } = new AetheriaRuntimeEntityLoadoutCommit(); + [Key(11)] public string CommittedAtUtc { get; set; } = ""; + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs.meta new file mode 100644 index 000000000..e605b2623 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarDocuments.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d95d5e88e2194e88a2af84af6c5cd893 diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs new file mode 100644 index 000000000..7457a3f50 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +#nullable enable + +namespace GameCult.Aetheria.State.Verse +{ + public static class AetheriaRuntimeHangarCommands + { + public const string SurfaceId = "aetheria.hangar"; + public const string SelectShip = "aetheria.hangar.select_ship"; + public const string SelectTerminus = "aetheria.hangar.select_mode.terminus"; + 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 Launch = "aetheria.hangar.launch"; + } + + public static class AetheriaRuntimeHangarSurfaceBuilder + { + public static AetheriaRuntimeSurfaceDocument Build( + AetheriaHangarState hangar, + string selectedShipId, + string selectedMode, + string updatedAtUtc, + long version = 1) + { + 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 root = Component( + "aetheria.hangar.root", + "surface", + Props(("title", "HANGAR"), ("selectedMode", mode), ("hangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture))), + new[] + { + Panel("aetheria.hangar.ship_summary", "SHIP", selected == null + ? new[] { Text("aetheria.hangar.ship.none", "No ship selected") } + : new[] + { + Metric("aetheria.hangar.ship.id", "Ship", selected.ShipId), + Metric("aetheria.hangar.ship.hull", "Hull", selected.HullItemKey), + Metric("aetheria.hangar.ship.status", "Status", selected.Status), + Metric("aetheria.hangar.ship.loadout", "Loadout", selected.LoadoutTemplateKey) + }, "summary"), + Panel("aetheria.hangar.preview", "SHIP PREVIEW", new[] + { + Component("aetheria.hangar.preview.slot", "asset.preview", Props( + ("assetRole", "ship.preview"), + ("subjectKey", selected?.ShipId ?? "")), Array.Empty()) + }, "preview"), + Panel("aetheria.hangar.fit", "FIT SUMMARY", new[] + { + Metric("aetheria.hangar.fit.hull", "Hull", selected?.HullItemKey ?? "-"), + Metric("aetheria.hangar.fit.template", "Template", selected?.LoadoutTemplateKey ?? "-"), + Metric("aetheria.hangar.fit.policy", "Authority", AetheriaModePolicies.ForMode(mode)), + Button("aetheria.hangar.fit.edit", "EDIT LOADOUT", AetheriaRuntimeHangarCommands.EditLoadout, + ("targetSurfaceId", AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId)) + }, "fit"), + Component("aetheria.hangar.bays", "row", Props(("label", "OWNED SHIPS")), + ships.Select(ship => Button( + "aetheria.hangar.bay." + StableToken(ship.ShipId), + ship.ShipId, + AetheriaRuntimeHangarCommands.SelectShip, + ("shipId", ship.ShipId), + ("selected", string.Equals(ship.ShipId, selected?.ShipId, StringComparison.Ordinal) ? "true" : "false"), + ("status", ship.Status))).ToArray(), + Layout(("gridArea", "bays"), ("display", "flex"), ("overflowX", "auto"), ("gap", "8"))), + Component("aetheria.hangar.launcher", "row", Props(), new[] + { + Button("aetheria.hangar.mode.terminus", "TERMINUS", AetheriaRuntimeHangarCommands.SelectTerminus, + ("selected", (mode == AetheriaGameModes.Terminus).ToString().ToLowerInvariant())), + Button("aetheria.hangar.mode.starbridge", "STARBRIDGE", AetheriaRuntimeHangarCommands.SelectStarbridge, + ("selected", (mode == AetheriaGameModes.Starbridge).ToString().ToLowerInvariant())), + Button("aetheria.hangar.mode.arena", "ARENA", AetheriaRuntimeHangarCommands.SelectArena, + ("selected", (mode == AetheriaGameModes.Arena).ToString().ToLowerInvariant())), + Button("aetheria.hangar.launch", "LAUNCH", AetheriaRuntimeHangarCommands.Launch, + ("enabled", canLaunch.ToString().ToLowerInvariant()), + ("shipId", selected?.ShipId ?? ""), + ("mode", mode), + ("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture))) + }, 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\""), + ("gap", "10"), + ("height", "100%")), + Style(("background", "#070b0d"), ("color", "#d8eef2"))); + + return new AetheriaRuntimeSurfaceDocument( + "aetheria", + "game.hangar", + "Aetheria Hangar", + version, + updatedAtUtc ?? "", + new AetheriaRuntimeSurfaceTree(AetheriaRuntimeHangarCommands.SurfaceId, root, Array.Empty()), + new[] + { + Command(AetheriaRuntimeHangarCommands.SelectShip, "Select Ship"), + Command(AetheriaRuntimeHangarCommands.SelectTerminus, "Select Terminus"), + Command(AetheriaRuntimeHangarCommands.SelectStarbridge, "Select Starbridge"), + Command(AetheriaRuntimeHangarCommands.SelectArena, "Select Arena"), + Command(AetheriaRuntimeHangarCommands.EditLoadout, "Edit Loadout"), + Command(AetheriaRuntimeHangarCommands.Launch, "Launch") + }); + } + + 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"))); + + private static AetheriaRuntimeSurfaceComponent Metric(string id, string label, string value) => + Component(id, "metric", Props(("label", label), ("value", value)), Array.Empty()); + + private static AetheriaRuntimeSurfaceComponent Text(string id, string value) => + Component(id, "text", Props(("value", value)), Array.Empty()); + + private static AetheriaRuntimeSurfaceComponent Button(string id, string label, string command, params (string Key, string Value)[] extra) => + Component(id, "button", Props(new[] { ("label", label), ("command", command) }.Concat(extra).ToArray()), Array.Empty()); + + private static AetheriaRuntimeSurfaceCommandTemplate Command(string command, string label) => + new AetheriaRuntimeSurfaceCommandTemplate(command, label, AetheriaRuntimeSurfaceCommandTemplate.CultMeshTransport); + + private static AetheriaRuntimeSurfaceComponent Component( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + IReadOnlyDictionary? layout = null, + IReadOnlyDictionary? style = null) => + new AetheriaRuntimeSurfaceComponent(id, kind, props, children, Array.Empty(), Array.Empty(), layout, style); + + private static IReadOnlyDictionary Props(params (string Key, string Value)[] values) => + values.ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal); + + private static IReadOnlyDictionary Layout(params (string Key, string Value)[] values) => Props(values); + private static IReadOnlyDictionary Style(params (string Key, string Value)[] values) => Props(values); + + private static string StableToken(string value) => + new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.').ToArray()).Trim('.'); + } +} diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs.meta b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs.meta new file mode 100644 index 000000000..3d8500a13 --- /dev/null +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeHangarSurfaceBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c79a2f47dbf437c91d6cb4d4310f9c4 diff --git a/docs/developer-navigation.md b/docs/developer-navigation.md index dfbc410a0..948b60863 100644 --- a/docs/developer-navigation.md +++ b/docs/developer-navigation.md @@ -26,19 +26,22 @@ Read these in this order: 3. `docs/game-modes-and-progression.md` Product-mode and cross-mode progression authority: Terminus, Starbridge, Arena, Hangar, deployment, and settlement. -4. `Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md` +4. `docs/hangar-launcher.md` + Shared launcher, deployment admission, ship-bay composition, and the cut + from generic New Game to mode bootstrap. +5. `Aetheria.State/docs/stage-7-thin-client-staged-implementation-plan.md` The staged migration plan. Use this when deciding what to build next. -5. `Aetheria.State/docs/stage-7-client-surface-inventory.md` +6. `Aetheria.State/docs/stage-7-client-surface-inventory.md` Surface inventory and edit queue for Unity/Electron/client boundaries. -6. `docs/aetheria-verse-client-contract.md` +7. `docs/aetheria-verse-client-contract.md` Intended client contract: use typed Verse state, not daemon internals. -7. `docs/cultmesh-feature-implementation-guide.md` +8. `docs/cultmesh-feature-implementation-guide.md` Step-by-step guide for adding canonical typed state once, letting daemon authority own mutation, and consuming/interacting with the same document from Unity and other clients. -8. `Aetheria.State/docs/verse-authority-implementation-plan.md` +9. `Aetheria.State/docs/verse-authority-implementation-plan.md` Authority and Starbridge staged implementation map. -9. `docs/cockpit-doctrine-combat.md` +10. `docs/cockpit-doctrine-combat.md` Target pilot experience: first-person cockpit, direct and delegated helm, doctrine-owned combat intent, cognition-owned precision execution, and the typed state required to make delegation legible. This defines experience, diff --git a/docs/hangar-launcher.md b/docs/hangar-launcher.md new file mode 100644 index 000000000..91d1801c1 --- /dev/null +++ b/docs/hangar-launcher.md @@ -0,0 +1,80 @@ +# Hangar Launcher + +Status: the shared Hangar, deployment admission, and portable Hangar surface +contracts exist. Mode session bootstrap still has to consume accepted deployment +receipts; the old generic New Game path remains live until that cut is complete. + +The Hangar is the launcher and durable preparation space for Terminus, +Starbridge, and Arena. Its composition borrows the useful information geometry +of the early MechWarrior Online MechLab without copying its visual identity. + +```text ++-----------------------------------------------------------------------+ +| HANGAR TERMINUS | STARBRIDGE | ARENA | LAUNCH | ++----------------------+-----------------------------+------------------+ +| selected ship | | fit summary | +| hull / status | authored ship preview | weapons/support | +| readiness | | mass/heat/power | +| deployment policy | | EDIT LOADOUT | ++----------------------+-----------------------------+------------------+ +| OWNED SHIPS: [bay] [bay] [bay] ... | ++-----------------------------------------------------------------------+ +``` + +The ship-bay strip selects one durable Hangar ship. Mode selection chooses one +typed authority/admission policy. `Launch` submits the selected ship, loadout +template, mode, policy id, and expected Hangar revision as one deployment +request. The Hangar owner validates and commits the immutable deployment before +any mode session may instantiate live state. + +`Edit Loadout` opens the existing spatial inventory/refit interaction shape. +The left/source inventory behaves like the trade menu's stock or cargo source; +the ship equipment and cargo grids are the target. Drag, rotate, equip, remove, +buy, sell, and transfer remain typed refit/trade operations. The Hangar screen +does not own a second fitting model. + +## Authority Map + +Owner: the canonical `gamecult.aetheria.hangar.v1` document owns durable ships, +inventory, currencies, unlocks, template references, revision, and accepted +deployment receipts. + +Inputs: authenticated player identity, a typed deployment request, the expected +Hangar revision, one owned ship, one owned loadout template, and the exact mode +policy id. + +Outputs: one immutable accepted deployment receipt embedded atomically in the +new Hangar revision, or a typed rejection that does not mutate the Hangar. + +Derived state: selected bay, selected mode, preview, fit metrics, affordability, +compatibility warnings, and launch readiness are UI projections. The preview +and metrics never become equipment or deployment owners. + +Forbidden writers: Unity, Electron, Eve lowerers, mode session checkpoints, +witness seeders, and mode-local inventory stores cannot mutate Hangar assets or +mint accepted deployments. A mode cannot launch from an uncommitted UI +selection. + +Shared paths: rendered clients, headless controllers, tests, reconnect, and AI +orchestration all submit the same deployment request and consume the same +receipt. Terminus, Starbridge, and Arena differ by policy and session owner, not +by Hangar schema. + +Cut line: replace `MainMenuNewGame -> AetheriaDaemonRunFactory.WriteAsync` with +`Hangar launch operation -> deployment admission -> mode session bootstrap`. +Delete generic New Game as a gameplay-state writer after all three mode +bootstraps consume deployment receipts. + +## Verification + +- duplicate request id returns the original accepted receipt; +- stale Hangar revision, wrong player, unavailable ship, unowned template, + mismatched hull, unknown mode, or wrong mode policy cannot mutate Hangar; +- accepted receipt contains a loadout snapshot, not a mutable reference to the + saved template; +- all three modes pass through the same admission primitive; +- flush/reopen preserves the Hangar revision, ship deployment state, and + receipts; +- the portable surface exposes owned bays, ship preview, fit summary, existing + inventory/refit entry, three mode selectors, and Launch; +- attaching a renderer does not change admission output.