Establish shared Hangar deployment spine

This commit is contained in:
Meta Vens 2026-08-07 15:52:38 +01:00
commit e9d1e04897
11 changed files with 674 additions and 7 deletions

View file

@ -312,6 +312,90 @@ await using (var node = await AetheriaStateNode.OpenAsync(statePath, "aetheria-s
}
});
await node.MutableDocument<AetheriaHangarState>(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<AetheriaHangarState>(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<AetheriaLoadoutTemplate>(loadoutKey).ReadAsync(),
now);
if (staleDeployment.Accepted || staleDeployment.Diagnostic != "hangar revision mismatch")
throw new InvalidOperationException("Stale Hangar deployment bypassed revision admission.");
await node.MutableDocument<AetheriaEntitySnapshot>(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<AetheriaRuntimeSession>(AetheriaStateNode.RuntimeSessionKey("smoke-runtime")).ReadAsync();
var playerSettings = await reopened.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey).ReadAsync();
var loadout = await reopened.MutableDocument<AetheriaLoadoutTemplate>(loadoutKey).ReadAsync();
var hangar = await reopened.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey).ReadAsync();
var runState = await reopened.MutableDocument<AetheriaRunState>(runKey).ReadAsync();
var zoneState = await reopened.MutableDocument<AetheriaZoneState>(zoneKey).ReadAsync();
var entitySnapshot = await reopened.MutableDocument<AetheriaEntitySnapshot>(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<AetheriaRuntimeSurfaceComponent> Flatten(AetheriaRuntimeSurfaceComponent component)
{
yield return component;
foreach (var child in component.Children ?? Array.Empty<AetheriaRuntimeSurfaceComponent>())
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);

View file

@ -28,6 +28,7 @@ public static class AetheriaDocumentRegistry
typeof(AetheriaCorporation),
typeof(AetheriaNameFile),
typeof(AetheriaPlayerProfile),
typeof(AetheriaHangarState),
typeof(AetheriaRuntimeSession),
typeof(AetheriaMigrationLedger),
typeof(AetheriaLegacyCatalogQuarantine),

View file

@ -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<AetheriaDeploymentReceipt> 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<AetheriaHangarState>(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<AetheriaLoadoutTemplate>(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
};
}

View file

@ -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");

View file

@ -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;

View file

@ -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<AetheriaHangarShip>();
[Key(5)] public AetheriaHangarItemStack[] Inventory { get; set; } = Array.Empty<AetheriaHangarItemStack>();
[Key(6)] public AetheriaHangarCurrency[] Currencies { get; set; } = Array.Empty<AetheriaHangarCurrency>();
[Key(7)] public string[] UnlockKeys { get; set; } = Array.Empty<string>();
[Key(8)] public string[] LoadoutTemplateKeys { get; set; } = Array.Empty<string>();
[Key(9)] public AetheriaDeploymentReceipt[] Deployments { get; set; } = Array.Empty<AetheriaDeploymentReceipt>();
[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; } = "";
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d95d5e88e2194e88a2af84af6c5cd893

View file

@ -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<AetheriaHangarShip>();
var selected = ships.FirstOrDefault(ship => string.Equals(ship.ShipId, selectedShipId, StringComparison.Ordinal))
?? ships.FirstOrDefault();
var mode = AetheriaGameModes.IsKnown(selectedMode) ? selectedMode : AetheriaGameModes.Terminus;
var canLaunch = selected != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal);
var 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<AetheriaRuntimeSurfaceComponent>())
}, "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<AetheriaRuntimeSurfaceStyleToken>()),
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<AetheriaRuntimeSurfaceComponent> 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<AetheriaRuntimeSurfaceComponent>());
private static AetheriaRuntimeSurfaceComponent Text(string id, string value) =>
Component(id, "text", Props(("value", value)), Array.Empty<AetheriaRuntimeSurfaceComponent>());
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<AetheriaRuntimeSurfaceComponent>());
private static AetheriaRuntimeSurfaceCommandTemplate Command(string command, string label) =>
new AetheriaRuntimeSurfaceCommandTemplate(command, label, AetheriaRuntimeSurfaceCommandTemplate.CultMeshTransport);
private static AetheriaRuntimeSurfaceComponent Component(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<AetheriaRuntimeSurfaceComponent> children,
IReadOnlyDictionary<string, string>? layout = null,
IReadOnlyDictionary<string, string>? style = null) =>
new AetheriaRuntimeSurfaceComponent(id, kind, props, children, Array.Empty<GameCult.Mesh.CultMeshStateBindingDescriptor>(), Array.Empty<AetheriaRuntimeEmbeddedDocumentSlot>(), layout, style);
private static IReadOnlyDictionary<string, string> Props(params (string Key, string Value)[] values) =>
values.ToDictionary(value => value.Key, value => value.Value ?? "", StringComparer.Ordinal);
private static IReadOnlyDictionary<string, string> Layout(params (string Key, string Value)[] values) => Props(values);
private static IReadOnlyDictionary<string, string> 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('.');
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4c79a2f47dbf437c91d6cb4d4310f9c4

View file

@ -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,

80
docs/hangar-launcher.md Normal file
View file

@ -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.