Establish shared Hangar deployment spine
This commit is contained in:
parent
54895e28fe
commit
e9d1e04897
11 changed files with 674 additions and 7 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ public static class AetheriaDocumentRegistry
|
|||
typeof(AetheriaCorporation),
|
||||
typeof(AetheriaNameFile),
|
||||
typeof(AetheriaPlayerProfile),
|
||||
typeof(AetheriaHangarState),
|
||||
typeof(AetheriaRuntimeSession),
|
||||
typeof(AetheriaMigrationLedger),
|
||||
typeof(AetheriaLegacyCatalogQuarantine),
|
||||
|
|
|
|||
194
Aetheria.State/AetheriaHangar.cs
Normal file
194
Aetheria.State/AetheriaHangar.cs
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; } = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d95d5e88e2194e88a2af84af6c5cd893
|
||||
|
|
@ -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('.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 4c79a2f47dbf437c91d6cb4d4310f9c4
|
||||
|
|
@ -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
80
docs/hangar-launcher.md
Normal 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.
|
||||
Loading…
Reference in a new issue