Launch Terminus through daemon-owned Hangar
This commit is contained in:
parent
023a35944c
commit
152db9e744
12 changed files with 891 additions and 85 deletions
|
|
@ -79,6 +79,11 @@ else if (args.Contains("--combat-lock", StringComparer.Ordinal))
|
|||
checks.RunCombatLock();
|
||||
Console.WriteLine("Daemon look-direction weapon-lock smoke passed.");
|
||||
}
|
||||
else if (args.Contains("--hangar", StringComparer.Ordinal))
|
||||
{
|
||||
checks.RunHangar();
|
||||
Console.WriteLine("Daemon Hangar deployment and continuation smoke passed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
checks.Run();
|
||||
|
|
@ -93,6 +98,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
|
|||
|
||||
public void RunCombatLock() => RunCheck(InstantWeaponRequestSurvivesLockAcquisition);
|
||||
|
||||
public void RunHangar() => RunCheck(HangarLaunchUsesConfiguredLoadoutAndContinuesSavedRun);
|
||||
|
||||
public void RunLoadout()
|
||||
{
|
||||
DaemonNativeDockyardMaterializesTypedCatalog();
|
||||
|
|
@ -136,6 +143,83 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
|
|||
RunCheck(PatrolCombatPreemptsAndResumesTheSameCircuit);
|
||||
}
|
||||
|
||||
private static void HangarLaunchUsesConfiguredLoadoutAndContinuesSavedRun()
|
||||
{
|
||||
var runtimeCatalog = TutorialPopulationCatalog(out _);
|
||||
runtimeCatalog.Items.Single(item => string.Equals(item.HullType, "Station", StringComparison.Ordinal)).ItemKey =
|
||||
AetheriaDaemonNativeCatalog.DockyardHullItemKey;
|
||||
var root = Path.Combine(Path.GetTempPath(), $"aetheria-hangar-{Guid.NewGuid():N}");
|
||||
var statePath = Path.Combine(root, "aetheria.cc");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
string runKey;
|
||||
string deploymentId;
|
||||
string removedItemKey;
|
||||
using (var node = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult())
|
||||
{
|
||||
AetheriaDaemonHangarCoordinator.EnsureAsync(node, runtimeCatalog, "2026-08-08T00:00:00Z")
|
||||
.GetAwaiter().GetResult();
|
||||
var hangar = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
var ship = hangar.Ships.Single();
|
||||
var template = node.MutableDocument<AetheriaLoadoutTemplate>(new CultRecordKey(ship.LoadoutTemplateKey))
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
removedItemKey = template.RootEntity.Equipment[0].Item.ItemKey;
|
||||
var mutation = AetheriaHangar.RemoveAsync(node, ship.ShipId, 0, hangar.Revision, "2026-08-08T00:00:01Z")
|
||||
.GetAwaiter().GetResult();
|
||||
Require(mutation.Accepted, "Hangar loadout customization must commit through the canonical Hangar owner");
|
||||
hangar = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
template = node.MutableDocument<AetheriaLoadoutTemplate>(new CultRecordKey(ship.LoadoutTemplateKey))
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
var surface = AetheriaRuntimeHangarSurfaceBuilder.Build(
|
||||
hangar, ship.ShipId, AetheriaGameModes.Terminus, "2026-08-08T00:00:01Z", 1, template, runtimeCatalog);
|
||||
Require(surface.Surface.Id == AetheriaRuntimeHangarCommands.SurfaceId &&
|
||||
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.EquipItem) &&
|
||||
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.RemoveItem) &&
|
||||
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.Launch) &&
|
||||
surface.Commands.Any(command => command.Command == AetheriaRuntimeHangarCommands.Continue),
|
||||
"the daemon-owned Hangar surface must advertise loadout, launch, and continuation operations");
|
||||
|
||||
var receipt = AetheriaDaemonHangarCoordinator.LaunchTerminusAsync(
|
||||
node, runtimeCatalog, "launch-configured", ship.ShipId, mutation.HangarRevision, "2026-08-08T00:00:02Z")
|
||||
.GetAwaiter().GetResult();
|
||||
Require(receipt.Accepted, "a valid configured Hangar deployment must launch Terminus");
|
||||
deploymentId = receipt.DeploymentId;
|
||||
var settings = node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
runKey = settings.ActiveRunKey;
|
||||
var run = node.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey))
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
var player = node.MutableDocument<AetheriaEntitySnapshot>(new CultRecordKey(run.CurrentEntityKey))
|
||||
.ReadAsync().GetAwaiter().GetResult()!;
|
||||
Require(run.GameMode == AetheriaGameModes.Terminus &&
|
||||
run.GenerationSeed == AetheriaDaemonRunFactory.StableSeed(deploymentId),
|
||||
"the accepted Hangar deployment must own the new Terminus run identity");
|
||||
Require(player.Equipment.All(slot => !string.Equals(slot.ItemKey, removedItemKey, StringComparison.Ordinal)),
|
||||
"Terminus must instantiate the configured Hangar loadout, not regenerate the starter fit");
|
||||
}
|
||||
|
||||
using (var reopened = AetheriaStateNode.OpenAsync(statePath).GetAwaiter().GetResult())
|
||||
{
|
||||
var canContinue = AetheriaDaemonHangarCoordinator.CanContinueTerminusAsync(
|
||||
reopened, AetheriaDaemonHangarCoordinator.StarterShipId, deploymentId)
|
||||
.GetAwaiter().GetResult();
|
||||
var settings = reopened.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().GetAwaiter().GetResult();
|
||||
var run = reopened.MutableDocument<AetheriaRunState>(new CultRecordKey(runKey))
|
||||
.ReadAsync().GetAwaiter().GetResult();
|
||||
Require(canContinue && settings?.ActiveRunKey == runKey && run != null,
|
||||
"Continue must reopen the saved Terminus checkpoint without creating another run");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TutorialRunPersistsCanonicalWorldTruth()
|
||||
{
|
||||
var catalog = TutorialPopulationCatalog(out _);
|
||||
|
|
|
|||
196
Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs
Normal file
196
Aetheria.State.Daemon/AetheriaDaemonHangarCoordinator.cs
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
using Aetheria.State;
|
||||
using Aetheria.State.Documents;
|
||||
using GameCult.Aetheria.State.Verse;
|
||||
using GameCult.Caching;
|
||||
|
||||
public static class AetheriaDaemonHangarCoordinator
|
||||
{
|
||||
public const string LocalPlayerKey = "player:local";
|
||||
public const string StarterShipId = "ship:local:vanguard";
|
||||
public const string StarterLoadoutName = "Vanguard One";
|
||||
|
||||
public static async Task EnsureAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now)
|
||||
{
|
||||
var existing = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
if (existing != null)
|
||||
return;
|
||||
|
||||
var factionKey = (catalog.Corporations ?? [])
|
||||
.Select(value => value.CorporationKey)
|
||||
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
|
||||
?? throw new InvalidDataException("Hangar creation requires one typed corporation.");
|
||||
var generator = new AetheriaDaemonLoadoutGenerator(
|
||||
catalog,
|
||||
AetheriaDaemonZoneGenerator.GenerationSeed,
|
||||
0,
|
||||
new Dictionary<string, int>(StringComparer.Ordinal) { [factionKey] = 0 },
|
||||
new Dictionary<int, IReadOnlyList<int>> { [0] = [] },
|
||||
isPrelude: true);
|
||||
var generated = generator.Build("ship", factionKey);
|
||||
var loadoutCommit = new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = StarterLoadoutName,
|
||||
OwnerPlayerKey = LocalPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(generated, factionKey)
|
||||
};
|
||||
var loadout = AetheriaRuntimeStateMapper.ToLoadoutTemplate(loadoutCommit, now);
|
||||
var loadoutKey = AetheriaRuntimeStateMapper.LoadoutKey(loadout.Name);
|
||||
var installed = generated.Equipment
|
||||
.Concat(generated.CargoBays)
|
||||
.Concat(generated.DockingBays)
|
||||
.Select(value => value.ItemKey)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var inventory = catalog.EquipmentItems
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value.ItemKey) && !installed.Contains(value.ItemKey))
|
||||
.OrderBy(value => value.Price)
|
||||
.ThenBy(value => value.ItemKey, StringComparer.Ordinal)
|
||||
.Take(24)
|
||||
.Select(value => new AetheriaHangarItemStack { ItemKey = value.ItemKey, Quantity = 1 })
|
||||
.ToArray();
|
||||
|
||||
await node.MutableDocument<AetheriaLoadoutTemplate>(loadoutKey).ReplaceAsync(loadout).ConfigureAwait(false);
|
||||
await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey).ReplaceAsync(new AetheriaHangarState
|
||||
{
|
||||
HangarId = "local",
|
||||
PlayerKey = LocalPlayerKey,
|
||||
Revision = 1,
|
||||
Ships =
|
||||
[
|
||||
new AetheriaHangarShip
|
||||
{
|
||||
ShipId = StarterShipId,
|
||||
HullItemKey = generated.HullItemKey,
|
||||
LoadoutTemplateKey = loadoutKey.ToString(),
|
||||
Status = AetheriaHangarShipStatuses.Available
|
||||
}
|
||||
],
|
||||
Inventory = inventory,
|
||||
LoadoutTemplateKeys = [loadoutKey.ToString()],
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<AetheriaDeploymentReceipt> LaunchTerminusAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string requestId,
|
||||
string shipId,
|
||||
long expectedRevision,
|
||||
string now)
|
||||
{
|
||||
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
|
||||
var receipt = await AetheriaHangar.AdmitAsync(node, new AetheriaDeploymentRequest
|
||||
{
|
||||
RequestId = requestId,
|
||||
PlayerKey = hangar.PlayerKey,
|
||||
Mode = AetheriaGameModes.Terminus,
|
||||
ShipId = shipId,
|
||||
LoadoutTemplateKey = ship?.LoadoutTemplateKey ?? "",
|
||||
ExpectedHangarRevision = expectedRevision,
|
||||
ModePolicyId = AetheriaModePolicies.TerminusLocal
|
||||
}, now).ConfigureAwait(false);
|
||||
if (!receipt.Accepted)
|
||||
return receipt;
|
||||
|
||||
await AetheriaDaemonZoneGenerator.WritePlayableRunAsync(
|
||||
node,
|
||||
catalog,
|
||||
now,
|
||||
AetheriaDaemonTerminusScenarios.Standard,
|
||||
receipt).ConfigureAwait(false);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
public static async Task<bool> CanContinueTerminusAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
string deploymentId)
|
||||
{
|
||||
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
var ship = (hangar?.Ships ?? []).SingleOrDefault(value =>
|
||||
string.Equals(value.ShipId, shipId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.ActiveDeploymentId, deploymentId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.Status, AetheriaHangarShipStatuses.Deployed, StringComparison.Ordinal));
|
||||
if (ship == null)
|
||||
return false;
|
||||
var deployment = (hangar!.Deployments ?? []).SingleOrDefault(value =>
|
||||
value.Accepted &&
|
||||
string.Equals(value.DeploymentId, deploymentId, StringComparison.Ordinal) &&
|
||||
string.Equals(value.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal));
|
||||
if (deployment == null)
|
||||
return false;
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
return !string.IsNullOrWhiteSpace(settings?.ActiveRunKey);
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(
|
||||
AetheriaDaemonLoadout source,
|
||||
string factionKey) => new()
|
||||
{
|
||||
Name = StarterLoadoutName,
|
||||
Kind = "ship",
|
||||
FactionKey = factionKey,
|
||||
Hull = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = source.HullItemKey,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Quantity = 1,
|
||||
Enabled = true
|
||||
},
|
||||
Equipment = source.Equipment.Select(ToRuntimeSlot).ToArray(),
|
||||
CargoBays = source.CargoBays.Select(ToRuntimeSlot).ToArray(),
|
||||
DockingBays = source.DockingBays.Select(ToRuntimeSlot).ToArray(),
|
||||
CargoContents = source.CargoBays.Select((_, index) => new AetheriaRuntimeCargoBayLoadoutCommit
|
||||
{
|
||||
Items = index == 0 ? source.Cargo.Select(ToRuntimeSlot).ToArray() : []
|
||||
}).ToArray(),
|
||||
DockingBayContents = source.DockingBays.Select(_ => new AetheriaRuntimeCargoBayLoadoutCommit()).ToArray(),
|
||||
DockingBayAssignments = source.DockingBays.Select(_ => -1).ToArray(),
|
||||
WeaponGroups = source.WeaponGroups.Select(group => (IReadOnlyList<int>)group.ToArray()).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaEntityItemSlot slot) => new()
|
||||
{
|
||||
X = slot.Position?.X ?? 0,
|
||||
Y = slot.Position?.Y ?? 0,
|
||||
Rotation = slot.Rotation,
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = slot.ItemKey,
|
||||
Quality = slot.Quality,
|
||||
Durability = slot.Durability,
|
||||
Quantity = slot.Quantity,
|
||||
Enabled = slot.Enabled,
|
||||
OverrideShutdown = slot.OverrideShutdown,
|
||||
Temperature = slot.Temperature
|
||||
}
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeLoadoutItemSlotCommit ToRuntimeSlot(AetheriaLoadoutItemSlot slot) => new()
|
||||
{
|
||||
X = slot.Position?.X ?? 0,
|
||||
Y = slot.Position?.Y ?? 0,
|
||||
Rotation = slot.Rotation,
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -32,10 +32,18 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
AetheriaStateNode node,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now,
|
||||
string scenario)
|
||||
string scenario,
|
||||
AetheriaDeploymentReceipt? deployment = null)
|
||||
{
|
||||
scenario = AetheriaDaemonTerminusScenarios.Parse(scenario);
|
||||
var runId = RunIdFor(scenario);
|
||||
if (deployment != null && (!deployment.Accepted || !string.Equals(deployment.Mode, AetheriaGameModes.Terminus, StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException("A product Terminus run requires an accepted Terminus deployment.");
|
||||
var runId = deployment == null
|
||||
? RunIdFor(scenario)
|
||||
: $"terminus-{StableToken(deployment.DeploymentId)}";
|
||||
var generationSeed = deployment == null
|
||||
? GenerationSeed
|
||||
: AetheriaDaemonRunFactory.StableSeed(deployment.DeploymentId);
|
||||
var runKey = new CultRecordKey($"global:aetheria.run_state.{runId}.v1");
|
||||
var zoneKey = new CultRecordKey($"global:aetheria.zone_state.{runId}.0.v1");
|
||||
var corporationKeys = (catalog.Corporations ?? Array.Empty<AetheriaRuntimeCorporation>())
|
||||
|
|
@ -54,7 +62,7 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
["raider"] = corporationKeys.ElementAtOrDefault(1) ?? corporationKeys.ElementAtOrDefault(0) ?? "",
|
||||
["neutral"] = corporationKeys.ElementAtOrDefault(2) ?? corporationKeys.ElementAtOrDefault(0) ?? ""
|
||||
};
|
||||
var rootRandom = new CultMath.Random(GenerationSeed);
|
||||
var rootRandom = new CultMath.Random(generationSeed);
|
||||
var adjacency = new Dictionary<int, IReadOnlyList<int>> { [0] = Array.Empty<int>() };
|
||||
var loadouts = availabilityFactions.Values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
|
|
@ -69,9 +77,11 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
homeZones,
|
||||
adjacency),
|
||||
StringComparer.Ordinal);
|
||||
var entities = GenerateEntities(loadouts, availabilityFactions, catalog, scenario);
|
||||
var entities = GenerateEntities(loadouts, availabilityFactions, catalog, scenario, runId);
|
||||
if (deployment != null)
|
||||
ApplyDeployment(entities[1], deployment.Loadout);
|
||||
var entityKeys = Enumerable.Range(0, entities.Length)
|
||||
.Select(index => EntityKey(scenario, 0, index))
|
||||
.Select(index => EntityKeyForRun(runId, 0, index))
|
||||
.ToArray();
|
||||
|
||||
var settings = await node.MutableDocument<AetheriaPlayerSettings>(AetheriaStateNode.PlayerSettingsKey)
|
||||
|
|
@ -92,7 +102,7 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
CurrentZoneIndex = 0,
|
||||
DiscoveredZoneIndices = [0],
|
||||
ZoneKeys = [zoneKey.ToString()],
|
||||
GenerationSeed = GenerationSeed,
|
||||
GenerationSeed = generationSeed,
|
||||
CurrentEntityKey = entityKeys[1],
|
||||
LifecyclePhase = AetheriaRuntimeRunLifecycle.Active,
|
||||
TerminalFrameId = -1,
|
||||
|
|
@ -141,6 +151,9 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
return $"global:aetheria.run_state.{RunIdFor(scenario)}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
}
|
||||
|
||||
private static string EntityKeyForRun(string runId, int zoneIndex, int entityIndex) =>
|
||||
$"global:aetheria.run_state.{runId}.zone.{zoneIndex}.entity.{entityIndex}.v1";
|
||||
|
||||
private static AetheriaOrbitSnapshot[] GenerateOrbits()
|
||||
{
|
||||
var bodies = BodyPlans();
|
||||
|
|
@ -241,10 +254,11 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
IReadOnlyDictionary<string, AetheriaDaemonLoadoutGenerator> loadouts,
|
||||
IReadOnlyDictionary<string, string> availabilityFactions,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string scenario)
|
||||
string scenario,
|
||||
string runId)
|
||||
{
|
||||
var keys = Enumerable.Range(0, 12)
|
||||
.Select(index => EntityKey(scenario, 0, index))
|
||||
.Select(index => EntityKeyForRun(runId, 0, index))
|
||||
.ToArray();
|
||||
|
||||
var entities = new[]
|
||||
|
|
@ -340,6 +354,63 @@ internal static class AetheriaDaemonZoneGenerator
|
|||
FillCargoBays(entities[1], salvage.ItemKey, catalog);
|
||||
}
|
||||
|
||||
private static void ApplyDeployment(
|
||||
AetheriaEntitySnapshot entity,
|
||||
AetheriaRuntimeEntityLoadoutCommit loadout)
|
||||
{
|
||||
entity.Name = string.IsNullOrWhiteSpace(loadout.Name) ? entity.Name : loadout.Name;
|
||||
entity.Kind = string.IsNullOrWhiteSpace(loadout.Kind) ? entity.Kind : loadout.Kind;
|
||||
entity.FactionKey = string.IsNullOrWhiteSpace(loadout.FactionKey) ? entity.FactionKey : loadout.FactionKey;
|
||||
entity.HullItemKey = loadout.Hull?.ItemKey ?? entity.HullItemKey;
|
||||
entity.Equipment = (loadout.Equipment ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.CargoBays = (loadout.CargoBays ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.DockingBays = (loadout.DockingBays ?? []).Select(ToEntitySlot).ToArray();
|
||||
entity.CargoContents = (loadout.CargoContents ?? []).Select(ToCargoBay).ToArray();
|
||||
entity.DockingBayContents = (loadout.DockingBayContents ?? []).Select(ToCargoBay).ToArray();
|
||||
entity.DockingBayAssignments = (loadout.DockingBayAssignments ?? []).ToArray();
|
||||
entity.WeaponGroups = (loadout.WeaponGroups ?? [])
|
||||
.Select(group => new AetheriaWeaponGroupSnapshot
|
||||
{
|
||||
EquipmentIndices = (group ?? []).ToArray()
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static AetheriaEntityItemSlot ToEntitySlot(AetheriaRuntimeLoadoutItemSlotCommit slot) => new()
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
|
||||
Rotation = slot.Rotation ?? "None",
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
};
|
||||
|
||||
private static AetheriaCargoBayLoadout ToCargoBay(AetheriaRuntimeCargoBayLoadoutCommit bay) => new()
|
||||
{
|
||||
Items = (bay.Items ?? []).Select(slot => new AetheriaLoadoutItemSlot
|
||||
{
|
||||
Position = new AetheriaGridCoord { X = slot.X, Y = slot.Y },
|
||||
Rotation = slot.Rotation ?? "None",
|
||||
Item = new AetheriaLoadoutItem
|
||||
{
|
||||
ItemKey = slot.Item?.ItemKey ?? "",
|
||||
Quality = slot.Item?.Quality ?? 1,
|
||||
Durability = slot.Item?.Durability ?? 1,
|
||||
Quantity = slot.Item?.Quantity ?? 1,
|
||||
Enabled = slot.Item?.Enabled ?? true,
|
||||
OverrideShutdown = slot.Item?.OverrideShutdown ?? false,
|
||||
Temperature = slot.Item?.Temperature ?? 0
|
||||
}
|
||||
}).ToArray()
|
||||
};
|
||||
|
||||
private static string StableToken(string value) =>
|
||||
new string((value ?? "").Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '-').ToArray()).Trim('-');
|
||||
|
||||
private static void FillCargoBays(
|
||||
AetheriaEntitySnapshot entity,
|
||||
string itemKey,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ if (persistedRuntimeCatalog == null ||
|
|||
nativeCatalogChanged)
|
||||
await node.RefreshRuntimeCatalogAsync().ConfigureAwait(false);
|
||||
TraceStartup("runtime-catalog");
|
||||
await AetheriaDaemonHangarCoordinator.EnsureAsync(node, node.RuntimeCatalog().Latest(), startedAtUtc).ConfigureAwait(false);
|
||||
TraceStartup("hangar");
|
||||
var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false);
|
||||
TraceStartup("verse-host");
|
||||
await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false);
|
||||
|
|
@ -106,6 +108,7 @@ using var soaPublisher = new AetheriaRuntimeDaemonSoaFramePublisher(
|
|||
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
bodyDemand);
|
||||
var stopped = new TaskCompletionSource<object?>();
|
||||
var hangarActivationRequested = false;
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
|
|
@ -121,12 +124,9 @@ while (!stopped.Task.IsCompleted)
|
|||
while (!stopped.Task.IsCompleted)
|
||||
{
|
||||
ThrowIfClientHostFaulted(cultMeshClientHost);
|
||||
await AcceptCoreEveInvocationsAsync(node, options, latestFrame).ConfigureAwait(false);
|
||||
hangarActivationRequested |= await AcceptCoreEveInvocationsAsync(node, options, latestFrame).ConfigureAwait(false);
|
||||
await AcceptEveCommandsAsync(node, options, ingressState).ConfigureAwait(false);
|
||||
if (await ApplyRequestedNewGameSessionAsync(node, options).ConfigureAwait(false))
|
||||
latestFrame = null;
|
||||
|
||||
if (playableWorldDemand.IsActive &&
|
||||
if (hangarActivationRequested && playableWorldDemand.IsActive &&
|
||||
(HasPlayableRun(latestFrame?.Run) ||
|
||||
HasPlayableRun(await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false))))
|
||||
break;
|
||||
|
|
@ -344,11 +344,6 @@ static async Task<AetheriaRuntimeDaemonTickResult> TickAsync(
|
|||
TracePhase("core-ingress");
|
||||
await AcceptEveCommandsAsync(node, options, ingressState).ConfigureAwait(false);
|
||||
TracePhase("provider-ingress");
|
||||
if (await ApplyRequestedNewGameSessionAsync(node, options).ConfigureAwait(false))
|
||||
{
|
||||
currentFrame = null;
|
||||
ingressState.ControlPlaneInitialized = false;
|
||||
}
|
||||
if (!ingressState.ControlPlaneInitialized || buildPublications)
|
||||
await RefreshControlPlaneInputsAsync(node, ingressState).ConfigureAwait(false);
|
||||
|
||||
|
|
@ -2054,6 +2049,9 @@ static async Task PublishClientGameplayDocumentsAsync(
|
|||
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.DaemonGameSurface)
|
||||
.ReplaceAsync(portableGameSurface)
|
||||
.ConfigureAwait(false);
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.HangarSurface)
|
||||
.ReplaceAsync(ReidentifySurface(portableGameSurface, AetheriaRuntimeHangarCommands.SurfaceId, "Terminus"))
|
||||
.ConfigureAwait(false);
|
||||
await PublishDaemonSectorMapSurfaceAsync(node, result.Frame, inputCatalog).ConfigureAwait(false);
|
||||
}
|
||||
TraceClientDocumentPhase("game-topology");
|
||||
|
|
@ -2175,7 +2173,15 @@ static EveProviderAdvertisementDocument BuildCoreProviderAdvertisement(
|
|||
"cultmesh-record",
|
||||
"active",
|
||||
"graph",
|
||||
mapInteraction)
|
||||
mapInteraction),
|
||||
new EveAdvertisedSurface(
|
||||
AetheriaRuntimeHangarCommands.SurfaceId,
|
||||
EveSurfaceDocument.SchemaId,
|
||||
AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(),
|
||||
"cultmesh-record",
|
||||
"active",
|
||||
"interactive-world",
|
||||
interaction)
|
||||
},
|
||||
Array.Empty<EveAdvertisedCommand>(),
|
||||
new[] { AetheriaRuntimeDaemonSoaFramePublisher.ProducerId });
|
||||
|
|
@ -2413,11 +2419,12 @@ static AetheriaRuntimeSurfaceComponent SurfaceNode(
|
|||
children);
|
||||
}
|
||||
|
||||
static async Task AcceptCoreEveInvocationsAsync(
|
||||
static async Task<bool> AcceptCoreEveInvocationsAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options,
|
||||
AetheriaRuntimeDaemonFrameDocument? currentFrame)
|
||||
{
|
||||
var activatedSession = false;
|
||||
var accounted = new HashSet<string>(
|
||||
currentFrame?.AccountedCommandIds ?? Array.Empty<string>(),
|
||||
StringComparer.Ordinal);
|
||||
|
|
@ -2436,6 +2443,13 @@ static async Task AcceptCoreEveInvocationsAsync(
|
|||
if (accounted.Contains(request.CommandId) || receipted.Contains(request.CommandId) || submitted.Contains(request.CommandId))
|
||||
continue;
|
||||
|
||||
if (string.Equals(request.SurfaceId, AetheriaRuntimeHangarCommands.SurfaceId, StringComparison.Ordinal))
|
||||
{
|
||||
activatedSession |= await AcceptHangarInvocationAsync(node, options, request).ConfigureAwait(false);
|
||||
receipted.Add(request.CommandId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (AetheriaRuntimeDaemonOperationsClient.TryCreateSurfaceCommandDocument(
|
||||
request,
|
||||
currentFrame,
|
||||
|
|
@ -2467,8 +2481,165 @@ static async Task AcceptCoreEveInvocationsAsync(
|
|||
await node.Database.PutAsync(AetheriaRuntimeVerseRecordKeys.EveReceiptForCommand(denied.CommandId), denied)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
return activatedSession;
|
||||
}
|
||||
|
||||
static EveSurfaceDocument ReidentifySurface(EveSurfaceDocument source, string surfaceId, string title) =>
|
||||
new(
|
||||
source.Type,
|
||||
source.Schema,
|
||||
source.ProviderId,
|
||||
source.ProviderKind,
|
||||
title,
|
||||
source.Version,
|
||||
source.UpdatedAtUtc,
|
||||
new EveSurfaceTree(surfaceId, source.Surface.Root, source.Surface.Styles),
|
||||
source.Commands);
|
||||
|
||||
static async Task<bool> AcceptHangarInvocationAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options,
|
||||
EveSurfaceCommandRequest request)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
var command = request.Command ?? "";
|
||||
var accepted = false;
|
||||
var diagnostic = "";
|
||||
var activatesSession = false;
|
||||
try
|
||||
{
|
||||
var shipId = Payload(request, "shipId");
|
||||
var expectedRevision = PayloadLong(request, "expectedHangarRevision", -1);
|
||||
switch (command)
|
||||
{
|
||||
case AetheriaRuntimeHangarCommands.SelectShip:
|
||||
case AetheriaRuntimeHangarCommands.SelectTerminus:
|
||||
case AetheriaRuntimeHangarCommands.EditLoadout:
|
||||
accepted = true;
|
||||
break;
|
||||
case AetheriaRuntimeHangarCommands.SelectStarbridge:
|
||||
case AetheriaRuntimeHangarCommands.SelectArena:
|
||||
diagnostic = "This mode has no session bootstrap yet.";
|
||||
break;
|
||||
case AetheriaRuntimeHangarCommands.EquipItem:
|
||||
{
|
||||
var result = await AetheriaHangar.EquipAsync(
|
||||
node,
|
||||
shipId,
|
||||
Payload(request, "itemKey"),
|
||||
expectedRevision,
|
||||
node.RuntimeCatalog().Latest(),
|
||||
now).ConfigureAwait(false);
|
||||
accepted = result.Accepted;
|
||||
diagnostic = result.Diagnostic;
|
||||
if (accepted) await PublishStateSurfacesAsync(node, options, now).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
case AetheriaRuntimeHangarCommands.RemoveItem:
|
||||
{
|
||||
var result = await AetheriaHangar.RemoveAsync(
|
||||
node,
|
||||
shipId,
|
||||
(int)PayloadLong(request, "equipmentIndex", -1),
|
||||
expectedRevision,
|
||||
now).ConfigureAwait(false);
|
||||
accepted = result.Accepted;
|
||||
diagnostic = result.Diagnostic;
|
||||
if (accepted) await PublishStateSurfacesAsync(node, options, now).ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
case AetheriaRuntimeHangarCommands.Launch:
|
||||
{
|
||||
var receipt = await AetheriaDaemonHangarCoordinator.LaunchTerminusAsync(
|
||||
node,
|
||||
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
|
||||
request.CommandId,
|
||||
shipId,
|
||||
expectedRevision,
|
||||
now).ConfigureAwait(false);
|
||||
accepted = receipt.Accepted;
|
||||
diagnostic = receipt.Diagnostic;
|
||||
if (accepted)
|
||||
{
|
||||
await ActivateTerminusSessionAsync(node, options, request.CommandId, now).ConfigureAwait(false);
|
||||
activatesSession = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AetheriaRuntimeHangarCommands.Continue:
|
||||
{
|
||||
accepted = await AetheriaDaemonHangarCoordinator.CanContinueTerminusAsync(
|
||||
node,
|
||||
shipId,
|
||||
Payload(request, "deploymentId")).ConfigureAwait(false);
|
||||
diagnostic = accepted ? "" : "No resumable Terminus deployment exists for this ship.";
|
||||
if (accepted)
|
||||
{
|
||||
await ActivateTerminusSessionAsync(node, options, request.CommandId, now).ConfigureAwait(false);
|
||||
activatesSession = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
diagnostic = "Command is not advertised by the Hangar surface.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
accepted = false;
|
||||
diagnostic = error.Message;
|
||||
}
|
||||
|
||||
var receiptDocument = new EveCommandReceiptDocument(
|
||||
$"receipt:{request.CommandId}:{(accepted ? "applied" : "denied")}",
|
||||
request.CommandId,
|
||||
command,
|
||||
accepted ? "applied" : "denied",
|
||||
"Aetheria Hangar",
|
||||
options.DaemonId,
|
||||
request.ProviderId,
|
||||
request.SurfaceId,
|
||||
diagnostic,
|
||||
now,
|
||||
0);
|
||||
await node.Database.PutAsync(AetheriaRuntimeVerseRecordKeys.EveReceiptForCommand(request.CommandId), receiptDocument)
|
||||
.ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return activatesSession;
|
||||
}
|
||||
|
||||
static async Task ActivateTerminusSessionAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options,
|
||||
string commandId,
|
||||
string now)
|
||||
{
|
||||
var run = await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("Terminus activation requires a canonical run checkpoint.");
|
||||
await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
|
||||
.ReplaceAsync(new AetheriaGameSessionState
|
||||
{
|
||||
Mode = AetheriaGameSessionState.TerminusMode,
|
||||
SessionId = options.SessionId,
|
||||
RunId = run.RunId,
|
||||
ControlledEntityKey = run.CurrentEntityKey,
|
||||
EntrySurfaceId = AetheriaRuntimeHangarCommands.SurfaceId,
|
||||
SimulationRate = 1,
|
||||
EffectiveSimulationRate = 1,
|
||||
LastStartCommandId = commandId,
|
||||
UpdatedAtUtc = now
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static string Payload(EveSurfaceCommandRequest request, string key) =>
|
||||
request.PayloadFields.TryGetValue(key, out var value) ? value ?? "" : "";
|
||||
|
||||
static long PayloadLong(EveSurfaceCommandRequest request, string key, long fallback) =>
|
||||
long.TryParse(Payload(request, key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
static async Task AcceptEveCommandsAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options,
|
||||
|
|
@ -2544,6 +2715,13 @@ static async Task PublishStateSurfacesAsync(
|
|||
var playerSettingsUpdatedAt = string.IsNullOrWhiteSpace(playerSettings.LastUpdatedAtUtc)
|
||||
? updatedAtUtc
|
||||
: playerSettings.LastUpdatedAtUtc;
|
||||
var hangar = await node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
AetheriaLoadoutTemplate? hangarLoadout = null;
|
||||
var selectedHangarShip = hangar?.Ships?.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(selectedHangarShip?.LoadoutTemplateKey))
|
||||
hangarLoadout = await node.MutableDocument<AetheriaLoadoutTemplate>(new(selectedHangarShip.LoadoutTemplateKey))
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaStateNode.OperationsSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildOperationsSurface(eveStatus, verseHost, runtimeSession))
|
||||
|
|
@ -2554,6 +2732,18 @@ static async Task PublishStateSurfacesAsync(
|
|||
await node.MutableDocument<EveProviderAdvertisementDocument>(AetheriaStateNode.ProviderAdvertisementSurfaceKey)
|
||||
.ReplaceAsync(AetheriaEveSurfaceDocuments.BuildProviderAdvertisement(verseHost, node.StatePath, updatedAtUtc))
|
||||
.ConfigureAwait(false);
|
||||
if (hangar != null)
|
||||
await node.MutableDocument<EveSurfaceDocument>(AetheriaRuntimeVerseRecordKeys.HangarSurface)
|
||||
.ReplaceAsync(AetheriaRuntimeSurfaceDocuments.ToPortableSurface(
|
||||
AetheriaRuntimeHangarSurfaceBuilder.Build(
|
||||
hangar,
|
||||
selectedHangarShip?.ShipId ?? "",
|
||||
AetheriaGameModes.Terminus,
|
||||
updatedAtUtc,
|
||||
Math.Max(1, hangar.Revision),
|
||||
hangarLoadout,
|
||||
node.RuntimeCatalog().Latest())))
|
||||
.ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
@ -2581,7 +2771,8 @@ static async Task PublishOdinSurfaceAnnouncementsAsync(
|
|||
("aetheria.inventory.panel", "Inventory Panel", AetheriaRuntimeVerseRecordKeys.InventoryPanelSurface),
|
||||
("aetheria.inventory.panel.dropdown", "Inventory Dropdown", AetheriaRuntimeVerseRecordKeys.InventoryDropdownSurface),
|
||||
(AetheriaRuntimeSectorMapSurfaceBuilder.SurfaceId, "Sector Map", AetheriaRuntimeVerseRecordKeys.MapMenuSurface),
|
||||
("aetheria.trade.menu", "Trade Menu", AetheriaRuntimeVerseRecordKeys.TradeMenuSurface)
|
||||
("aetheria.trade.menu", "Trade Menu", AetheriaRuntimeVerseRecordKeys.TradeMenuSurface),
|
||||
(AetheriaRuntimeHangarCommands.SurfaceId, "Aetheria Hangar", AetheriaRuntimeVerseRecordKeys.HangarSurface)
|
||||
};
|
||||
|
||||
var documents = new List<CultNetDocumentPutRawMessage>();
|
||||
|
|
@ -3115,43 +3306,6 @@ static async Task EnsureGameSessionAsync(
|
|||
await node.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
static async Task<bool> ApplyRequestedNewGameSessionAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options)
|
||||
{
|
||||
var menu = await node.MutableDocument<AetheriaMainMenuState>(AetheriaStateNode.MainMenuStateKey)
|
||||
.ReadAsync().ConfigureAwait(false);
|
||||
if (menu == null ||
|
||||
!string.Equals(menu.LastCommand, AetheriaRuntimeMainMenuCommands.NewGame, StringComparison.Ordinal) ||
|
||||
string.IsNullOrWhiteSpace(menu.LastCommandId))
|
||||
return false;
|
||||
|
||||
var session = await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
|
||||
.ReadAsync().ConfigureAwait(false) ?? new AetheriaGameSessionState();
|
||||
if (string.Equals(session.LastStartCommandId, menu.LastCommandId, StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToString("O");
|
||||
var written = await AetheriaDaemonRunFactory.WriteAsync(
|
||||
node,
|
||||
await node.RuntimeCatalogForGenerationAsync().ConfigureAwait(false),
|
||||
now,
|
||||
menu.LastCommandId).ConfigureAwait(false);
|
||||
session.Mode = written.SessionMode;
|
||||
session.SessionId = options.SessionId;
|
||||
session.RunId = written.RunId;
|
||||
session.ControlledEntityKey = written.CurrentEntityKey;
|
||||
session.EntrySurfaceId = AetheriaRuntimeDaemonGameSurfaceBuilder.PilotSurfaceId;
|
||||
session.SimulationRate = 1;
|
||||
session.EffectiveSimulationRate = 1;
|
||||
session.LastStartCommandId = menu.LastCommandId;
|
||||
session.UpdatedAtUtc = now;
|
||||
await node.MutableDocument<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
|
||||
.ReplaceAsync(session).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task EnsureVerseAuthorityPolicyAsync(
|
||||
AetheriaStateNode node,
|
||||
AetheriaDaemonHostOptions options)
|
||||
|
|
@ -3797,7 +3951,8 @@ internal sealed class AetheriaPlayableWorldDemandState
|
|||
(demand.RecordKeys.Any(key =>
|
||||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonFrameLatest.ToString(), StringComparison.Ordinal) ||
|
||||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameSurface.ToString(), StringComparison.Ordinal) ||
|
||||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(), StringComparison.Ordinal)) ||
|
||||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.DaemonGameReactiveSurface.ToString(), StringComparison.Ordinal) ||
|
||||
string.Equals(key, AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(), StringComparison.Ordinal)) ||
|
||||
demand.SchemaIds.Contains(AetheriaRuntimeDaemonSchemas.Frame, StringComparer.Ordinal) ||
|
||||
demand.BodyIds.Contains(AetheriaRuntimeDaemonSoaFramePublisher.BodyId, StringComparer.Ordinal));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,163 @@ public static class AetheriaHangar
|
|||
return receipt;
|
||||
}
|
||||
|
||||
public static async Task<AetheriaHangarMutationResult> EquipAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
string itemKey,
|
||||
long expectedRevision,
|
||||
AetheriaRuntimeCatalogSnapshot catalog,
|
||||
string now)
|
||||
{
|
||||
await AdmissionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
|
||||
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
|
||||
if (rejection != null) return new(false, rejection, hangar.Revision);
|
||||
var stack = (hangar.Inventory ?? []).SingleOrDefault(value =>
|
||||
string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal) && value.Quantity > 0);
|
||||
if (stack == null) return new(false, "item is not available in Hangar inventory", hangar.Revision);
|
||||
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
|
||||
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
|
||||
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
|
||||
|
||||
var destination = ToRuntimeEntity(template.RootEntity);
|
||||
var source = new AetheriaRuntimeEntitySnapshotCommit
|
||||
{
|
||||
Equipment =
|
||||
[
|
||||
new AetheriaRuntimeLoadoutItemSlotCommit
|
||||
{
|
||||
Item = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = itemKey,
|
||||
Quantity = 1,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
if (!AetheriaRuntimeRefitTransactions.TryEquip(
|
||||
source,
|
||||
AetheriaRuntimeRefitSourceKinds.Equipment,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
destination,
|
||||
itemKey,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
catalog,
|
||||
out rejection))
|
||||
return new(false, rejection, hangar.Revision);
|
||||
|
||||
var updatedHangar = Clone(hangar);
|
||||
var updatedStack = updatedHangar.Inventory.Single(value => string.Equals(value.ItemKey, itemKey, StringComparison.Ordinal));
|
||||
updatedStack.Quantity--;
|
||||
updatedHangar.Inventory = updatedHangar.Inventory.Where(value => value.Quantity > 0).ToArray();
|
||||
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
|
||||
updatedHangar.UpdatedAtUtc = now;
|
||||
var updatedTemplate = AetheriaRuntimeStateMapper.ToLoadoutTemplate(
|
||||
new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = template.Name,
|
||||
OwnerPlayerKey = template.OwnerPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(destination)
|
||||
},
|
||||
now);
|
||||
updatedTemplate.CreatedAtUtc = template.CreatedAtUtc;
|
||||
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
|
||||
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new(true, "", updatedHangar.Revision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AdmissionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<AetheriaHangarMutationResult> RemoveAsync(
|
||||
AetheriaStateNode node,
|
||||
string shipId,
|
||||
int equipmentIndex,
|
||||
long expectedRevision,
|
||||
string now)
|
||||
{
|
||||
await AdmissionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var pointer = node.MutableDocument<AetheriaHangarState>(AetheriaStateNode.HangarKey);
|
||||
var hangar = await pointer.ReadAsync().ConfigureAwait(false)
|
||||
?? throw new InvalidOperationException("The canonical Hangar document does not exist.");
|
||||
var rejection = ValidateRefit(hangar, shipId, expectedRevision, out var ship);
|
||||
if (rejection != null) return new(false, rejection, hangar.Revision);
|
||||
var templatePointer = node.MutableDocument<AetheriaLoadoutTemplate>(new(ship!.LoadoutTemplateKey));
|
||||
var template = await templatePointer.ReadAsync().ConfigureAwait(false);
|
||||
if (template == null) return new(false, "ship loadout template is missing", hangar.Revision);
|
||||
var equipment = (template.RootEntity.Equipment ?? []).ToList();
|
||||
if (equipmentIndex < 0 || equipmentIndex >= equipment.Count)
|
||||
return new(false, "equipment index is outside the configured loadout", hangar.Revision);
|
||||
var removed = equipment[equipmentIndex];
|
||||
if (string.IsNullOrWhiteSpace(removed.Item?.ItemKey))
|
||||
return new(false, "configured equipment item is missing", hangar.Revision);
|
||||
equipment.RemoveAt(equipmentIndex);
|
||||
|
||||
var updatedTemplate = CloneTemplate(template);
|
||||
updatedTemplate.RootEntity.Equipment = equipment.ToArray();
|
||||
updatedTemplate.RootEntity.WeaponGroups = (updatedTemplate.RootEntity.WeaponGroups ?? [])
|
||||
.Select(group => (group ?? [])
|
||||
.Where(index => index != equipmentIndex)
|
||||
.Select(index => index > equipmentIndex ? index - 1 : index)
|
||||
.ToArray())
|
||||
.ToArray();
|
||||
updatedTemplate.UpdatedAtUtc = now;
|
||||
var updatedHangar = Clone(hangar);
|
||||
var stack = updatedHangar.Inventory.FirstOrDefault(value =>
|
||||
string.Equals(value.ItemKey, removed.Item.ItemKey, StringComparison.Ordinal));
|
||||
if (stack == null)
|
||||
updatedHangar.Inventory = updatedHangar.Inventory.Append(new AetheriaHangarItemStack
|
||||
{
|
||||
ItemKey = removed.Item.ItemKey,
|
||||
Quantity = 1
|
||||
}).ToArray();
|
||||
else
|
||||
stack.Quantity++;
|
||||
updatedHangar.Revision = checked(updatedHangar.Revision + 1);
|
||||
updatedHangar.UpdatedAtUtc = now;
|
||||
await templatePointer.ReplaceAsync(updatedTemplate).ConfigureAwait(false);
|
||||
await pointer.ReplaceAsync(updatedHangar).ConfigureAwait(false);
|
||||
await node.FlushAsync().ConfigureAwait(false);
|
||||
return new(true, "", updatedHangar.Revision);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AdmissionGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateRefit(
|
||||
AetheriaHangarState hangar,
|
||||
string shipId,
|
||||
long expectedRevision,
|
||||
out AetheriaHangarShip? ship)
|
||||
{
|
||||
ship = null;
|
||||
if (expectedRevision != hangar.Revision) return "hangar revision mismatch";
|
||||
ship = (hangar.Ships ?? []).SingleOrDefault(value => string.Equals(value.ShipId, shipId, StringComparison.Ordinal));
|
||||
if (ship == null) return "ship is not owned by Hangar";
|
||||
if (!string.Equals(ship.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal))
|
||||
return "deployed ship cannot be refit from the Hangar";
|
||||
if (string.IsNullOrWhiteSpace(ship.LoadoutTemplateKey)) return "ship has no configured loadout";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? Validate(
|
||||
AetheriaHangarState hangar,
|
||||
AetheriaDeploymentRequest request,
|
||||
|
|
@ -191,4 +348,87 @@ public static class AetheriaHangar
|
|||
OverrideShutdown = source.OverrideShutdown,
|
||||
Temperature = source.Temperature
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeEntitySnapshotCommit ToRuntimeEntity(AetheriaEntityLoadout source)
|
||||
{
|
||||
var loadout = ToRuntimeLoadout(source);
|
||||
return new AetheriaRuntimeEntitySnapshotCommit
|
||||
{
|
||||
Name = loadout.Name,
|
||||
Kind = loadout.Kind,
|
||||
FactionKey = loadout.FactionKey,
|
||||
HullItemKey = loadout.Hull.ItemKey,
|
||||
Equipment = loadout.Equipment,
|
||||
CargoBays = loadout.CargoBays,
|
||||
DockingBays = loadout.DockingBays,
|
||||
CargoContents = loadout.CargoContents,
|
||||
DockingBayContents = loadout.DockingBayContents,
|
||||
DockingBayAssignments = loadout.DockingBayAssignments,
|
||||
WeaponGroups = loadout.WeaponGroups
|
||||
};
|
||||
}
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaRuntimeEntitySnapshotCommit source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
FactionKey = source.FactionKey,
|
||||
Hull = new AetheriaRuntimeLoadoutItemCommit
|
||||
{
|
||||
ItemKey = source.HullItemKey,
|
||||
Quantity = 1,
|
||||
Quality = 1,
|
||||
Durability = 1,
|
||||
Enabled = true
|
||||
},
|
||||
Equipment = source.Equipment ?? [],
|
||||
CargoBays = source.CargoBays ?? [],
|
||||
DockingBays = source.DockingBays ?? [],
|
||||
CargoContents = source.CargoContents ?? [],
|
||||
DockingBayContents = source.DockingBayContents ?? [],
|
||||
DockingBayAssignments = source.DockingBayAssignments ?? [],
|
||||
WeaponGroups = source.WeaponGroups ?? []
|
||||
};
|
||||
|
||||
private static AetheriaRuntimeEntityLoadoutCommit ToRuntimeLoadout(AetheriaEntityLoadout source) => new()
|
||||
{
|
||||
Name = source.Name,
|
||||
Kind = source.Kind,
|
||||
FactionKey = source.FactionKey,
|
||||
Hull = Clone(source.Hull),
|
||||
Equipment = (source.Equipment ?? []).Select(Clone).ToArray(),
|
||||
CargoBays = (source.CargoBays ?? []).Select(Clone).ToArray(),
|
||||
DockingBays = (source.DockingBays ?? []).Select(Clone).ToArray(),
|
||||
CargoContents = (source.CargoContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayContents = (source.DockingBayContents ?? []).Select(Clone).ToArray(),
|
||||
DockingBayAssignments = (source.DockingBayAssignments ?? []).ToArray(),
|
||||
WeaponGroups = (source.WeaponGroups ?? []).Select(group => (IReadOnlyList<int>)(group ?? []).ToArray()).ToArray(),
|
||||
Children = (source.Children ?? []).Select(ToRuntimeLoadout).ToArray()
|
||||
};
|
||||
|
||||
private static AetheriaLoadoutTemplate CloneTemplate(AetheriaLoadoutTemplate source)
|
||||
{
|
||||
var clone = AetheriaRuntimeStateMapper.ToLoadoutTemplate(new AetheriaRuntimeLoadoutTemplateCommit
|
||||
{
|
||||
Name = source.Name,
|
||||
OwnerPlayerKey = source.OwnerPlayerKey,
|
||||
RootEntity = ToRuntimeLoadout(source.RootEntity)
|
||||
}, source.UpdatedAtUtc);
|
||||
clone.CreatedAtUtc = source.CreatedAtUtc;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AetheriaHangarMutationResult
|
||||
{
|
||||
public AetheriaHangarMutationResult(bool accepted, string diagnostic, long hangarRevision)
|
||||
{
|
||||
Accepted = accepted;
|
||||
Diagnostic = diagnostic;
|
||||
HangarRevision = hangarRevision;
|
||||
}
|
||||
|
||||
public bool Accepted { get; }
|
||||
public string Diagnostic { get; }
|
||||
public long HangarRevision { get; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ namespace GameCult.Aetheria.State.Verse
|
|||
Surface(AetheriaRuntimeMainMenuCommands.InputSettingsSurfaceId, "Main Menu Input Settings", AetheriaRuntimeVerseRecordKeys.MainMenuInputSettingsSurface.ToString(), "player", "Main menu input settings panel.", transport: "cultmesh-record"),
|
||||
Surface(AetheriaRuntimeMainMenuCommands.PlayerSettingsSurfaceId, "Main Menu Player Settings", AetheriaRuntimeVerseRecordKeys.MainMenuPlayerSettingsSurface.ToString(), "player", "Main menu player settings panel.", transport: "cultmesh-record"),
|
||||
Surface(AetheriaRuntimeMainMenuCommands.VerseSettingsSurfaceId, "Main Menu Verse Settings", AetheriaRuntimeVerseRecordKeys.MainMenuVerseSettingsSurface.ToString(), "player", "Main menu Verse settings panel.", transport: "cultmesh-record"),
|
||||
Surface(AetheriaRuntimeHangarCommands.SurfaceId, "Aetheria Hangar", AetheriaRuntimeVerseRecordKeys.HangarSurface.ToString(), "player", "Daemon-owned ship fitting and mode deployment launcher.", providerId: "aetheria.daemon", providerKind: "game.daemon", transport: "cultmesh-record", surfaceKind: "interactive-world", worldInteraction: WorldInteraction("provider-authored-world-surface"), requiresPlugins: FieldPluginRequirements()),
|
||||
Surface(AetheriaRuntimeInputSettingsCommands.SurfaceId, "Input Settings", "", "player", "Runtime input binding surface.", status: "client-contextual"),
|
||||
Surface(AetheriaRuntimeVerseHostCommands.SurfaceId, "Verse Host Settings", "", "operator", "Verse host visibility and discovery surface.", status: "client-contextual"),
|
||||
Surface(AetheriaRuntimeLoadoutTemplateCommands.SurfaceId, "Loadout Templates", "", "player", "Loadout template save surface.", status: "client-contextual"),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Aetheria.State.Documents;
|
||||
|
||||
#nullable enable
|
||||
|
||||
|
|
@ -15,7 +16,10 @@ namespace GameCult.Aetheria.State.Verse
|
|||
public const string SelectStarbridge = "aetheria.hangar.select_mode.starbridge";
|
||||
public const string SelectArena = "aetheria.hangar.select_mode.arena";
|
||||
public const string EditLoadout = "aetheria.hangar.edit_loadout";
|
||||
public const string EquipItem = "aetheria.hangar.loadout.equip";
|
||||
public const string RemoveItem = "aetheria.hangar.loadout.remove";
|
||||
public const string Launch = "aetheria.hangar.launch";
|
||||
public const string Continue = "aetheria.hangar.continue";
|
||||
}
|
||||
|
||||
public static class AetheriaRuntimeHangarSurfaceBuilder
|
||||
|
|
@ -25,14 +29,19 @@ namespace GameCult.Aetheria.State.Verse
|
|||
string selectedShipId,
|
||||
string selectedMode,
|
||||
string updatedAtUtc,
|
||||
long version = 1)
|
||||
long version = 1,
|
||||
AetheriaLoadoutTemplate? loadout = null,
|
||||
AetheriaRuntimeCatalogSnapshot? catalog = null)
|
||||
{
|
||||
if (hangar == null) throw new ArgumentNullException(nameof(hangar));
|
||||
var ships = hangar.Ships ?? Array.Empty<AetheriaHangarShip>();
|
||||
var selected = ships.FirstOrDefault(ship => string.Equals(ship.ShipId, selectedShipId, StringComparison.Ordinal))
|
||||
?? ships.FirstOrDefault();
|
||||
var mode = AetheriaGameModes.IsKnown(selectedMode) ? selectedMode : AetheriaGameModes.Terminus;
|
||||
var canLaunch = selected != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal);
|
||||
var canLaunch = selected != null && loadout != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Available, StringComparison.Ordinal);
|
||||
var canContinue = selected != null && string.Equals(selected.Status, AetheriaHangarShipStatuses.Deployed, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(selected.ActiveDeploymentId);
|
||||
var equipment = loadout?.RootEntity?.Equipment ?? Array.Empty<AetheriaLoadoutItemSlot>();
|
||||
var inventory = hangar.Inventory ?? Array.Empty<AetheriaHangarItemStack>();
|
||||
|
||||
var root = Component(
|
||||
"aetheria.hangar.root",
|
||||
|
|
@ -63,6 +72,22 @@ namespace GameCult.Aetheria.State.Verse
|
|||
Button("aetheria.hangar.fit.edit", "EDIT LOADOUT", AetheriaRuntimeHangarCommands.EditLoadout,
|
||||
("targetSurfaceId", AetheriaRuntimeInventoryPanelSurfaceBuilder.SurfaceId))
|
||||
}, "fit"),
|
||||
Panel("aetheria.hangar.loadout", "LOADOUT", equipment.Select((slot, index) =>
|
||||
Button(
|
||||
$"aetheria.hangar.loadout.item.{index}",
|
||||
$"{ItemName(catalog, slot.Item?.ItemKey)} [{slot.Position?.X ?? 0},{slot.Position?.Y ?? 0}] REMOVE",
|
||||
AetheriaRuntimeHangarCommands.RemoveItem,
|
||||
("shipId", selected?.ShipId ?? ""),
|
||||
("equipmentIndex", index.ToString(CultureInfo.InvariantCulture)),
|
||||
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))).ToArray(), "loadout"),
|
||||
Panel("aetheria.hangar.inventory", "HANGAR INVENTORY", inventory.Select(stack =>
|
||||
Button(
|
||||
"aetheria.hangar.inventory." + StableToken(stack.ItemKey),
|
||||
$"{ItemName(catalog, stack.ItemKey)} x{stack.Quantity} EQUIP",
|
||||
AetheriaRuntimeHangarCommands.EquipItem,
|
||||
("shipId", selected?.ShipId ?? ""),
|
||||
("itemKey", stack.ItemKey),
|
||||
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))).ToArray(), "inventory"),
|
||||
Component("aetheria.hangar.bays", "row", Props(("label", "OWNED SHIPS")),
|
||||
ships.Select(ship => Button(
|
||||
"aetheria.hangar.bay." + StableToken(ship.ShipId),
|
||||
|
|
@ -84,14 +109,18 @@ namespace GameCult.Aetheria.State.Verse
|
|||
("enabled", canLaunch.ToString().ToLowerInvariant()),
|
||||
("shipId", selected?.ShipId ?? ""),
|
||||
("mode", mode),
|
||||
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture)))
|
||||
("expectedHangarRevision", hangar.Revision.ToString(CultureInfo.InvariantCulture))),
|
||||
Button("aetheria.hangar.continue", "CONTINUE", AetheriaRuntimeHangarCommands.Continue,
|
||||
("enabled", canContinue.ToString().ToLowerInvariant()),
|
||||
("shipId", selected?.ShipId ?? ""),
|
||||
("deploymentId", selected?.ActiveDeploymentId ?? ""))
|
||||
}, Layout(("gridArea", "launch"), ("display", "flex"), ("justifyContent", "flex-end"), ("gap", "8")))
|
||||
},
|
||||
Layout(
|
||||
("display", "grid"),
|
||||
("gridTemplateColumns", "minmax(260px, 0.75fr) minmax(480px, 1.6fr) minmax(280px, 0.8fr)"),
|
||||
("gridTemplateRows", "auto minmax(360px, 1fr) auto"),
|
||||
("gridTemplateAreas", "\"launch launch launch\" \"summary preview fit\" \"bays bays bays\""),
|
||||
("gridTemplateRows", "auto minmax(300px, 1fr) minmax(180px, 0.65fr) auto"),
|
||||
("gridTemplateAreas", "\"launch launch launch\" \"summary preview fit\" \"inventory loadout loadout\" \"bays bays bays\""),
|
||||
("gap", "10"),
|
||||
("height", "100%")),
|
||||
Style(("background", "#070b0d"), ("color", "#d8eef2")));
|
||||
|
|
@ -110,10 +139,16 @@ namespace GameCult.Aetheria.State.Verse
|
|||
Command(AetheriaRuntimeHangarCommands.SelectStarbridge, "Select Starbridge"),
|
||||
Command(AetheriaRuntimeHangarCommands.SelectArena, "Select Arena"),
|
||||
Command(AetheriaRuntimeHangarCommands.EditLoadout, "Edit Loadout"),
|
||||
Command(AetheriaRuntimeHangarCommands.Launch, "Launch")
|
||||
Command(AetheriaRuntimeHangarCommands.EquipItem, "Equip Item"),
|
||||
Command(AetheriaRuntimeHangarCommands.RemoveItem, "Remove Item"),
|
||||
Command(AetheriaRuntimeHangarCommands.Launch, "Launch"),
|
||||
Command(AetheriaRuntimeHangarCommands.Continue, "Continue")
|
||||
});
|
||||
}
|
||||
|
||||
private static string ItemName(AetheriaRuntimeCatalogSnapshot? catalog, string? itemKey) =>
|
||||
catalog?.FindItem(itemKey ?? "")?.Name ?? (string.IsNullOrWhiteSpace(itemKey) ? "Unknown" : itemKey);
|
||||
|
||||
private static AetheriaRuntimeSurfaceComponent Panel(string id, string title, IReadOnlyList<AetheriaRuntimeSurfaceComponent> children, string area) =>
|
||||
Component(id, "panel", Props(("title", title)), children, Layout(("gridArea", area)), Style(("background", "#11181c"), ("border", "1px solid #60737a"), ("padding", "12")));
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,9 @@ namespace GameCult.Aetheria.State.Verse
|
|||
public static CultRecordKey TradeMenuSurface { get; } =
|
||||
new CultRecordKey("eve:surface:aetheria.trade.menu");
|
||||
|
||||
public static CultRecordKey HangarSurface { get; } =
|
||||
new CultRecordKey("eve:surface:aetheria.hangar");
|
||||
|
||||
public static CultRecordKey DaemonCommand(string commandId)
|
||||
{
|
||||
return new CultRecordKey($"daemon:commands:{StableToken(commandId)}:gamecult.aetheria.daemon_command.v1");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Aetheria Game Modes And Progression
|
||||
|
||||
Status: adopted target design; shared Hangar state is not yet implemented
|
||||
Status: adopted design; the first daemon-owned Hangar/Terminus deployment slice is implemented
|
||||
|
||||
## Product Modes
|
||||
|
||||
|
|
@ -177,22 +177,31 @@ a parallel gameplay path.
|
|||
|
||||
## Current Migration Seam
|
||||
|
||||
The daemon already owns typed run checkpoints, loadout templates, catalog keys,
|
||||
combat, loot, docking, trade, and Terminus scenario generation. Starbridge has
|
||||
typed session, station-stock, role, wave, and summary documents. These are
|
||||
components of the design, not yet a durable shared Hangar owner.
|
||||
The daemon now owns one typed local Hangar, its starter ship, stored equipment,
|
||||
saved loadout template, Hangar revision, and immutable Terminus deployment
|
||||
receipt. It publishes `aetheria.hangar` as the ordinary Unity entry surface.
|
||||
The surface advertises equip, remove, launch, and continue operations. Launch
|
||||
instantiates the current committed loadout into a newly identified Terminus
|
||||
run; continue reopens the deployed run selected by the existing checkpoint.
|
||||
Unity remains a generic Eve lowerer and never edits those documents directly.
|
||||
|
||||
This is the first vertical slice, not the completed cross-mode progression
|
||||
machine. Settlement, currencies/unlocks, multiple owned ships, richer fitting
|
||||
interaction, Starbridge admission, and Arena admission still need to enter the
|
||||
same Hangar boundary. The old main-menu `New Game` writer is no longer a
|
||||
product run-creation authority. Explicit Terminus proof profiles remain
|
||||
verification-only inputs.
|
||||
|
||||
Arena does not yet have a named server/session/admission/score document family
|
||||
or a preserved headless controller harness. Shared simulation smokes are useful
|
||||
substrate, but they do not constitute the Arena mode. Starbridge's existing
|
||||
documents also do not by themselves prove mixed-authority convergence.
|
||||
|
||||
The next state-architecture cut is to define the canonical Hangar, Deployment,
|
||||
and Settlement documents; define one typed mode-selection boundary; and keep a
|
||||
minimal Terminus, Starbridge, and Arena vertical slice alive against it. Until
|
||||
that cut lands, documentation and verification must not describe existing run
|
||||
checkpoints or proof scenarios as cross-mode progression authority, or generic
|
||||
simulation smokes as Arena product proof.
|
||||
The next state-architecture cut is to carry this Hangar and Deployment boundary
|
||||
through Starbridge and Arena admission, then add exactly-once Settlement. Until
|
||||
those cuts land, documentation and verification must not describe the local
|
||||
starter Hangar as completed cross-mode progression, or generic simulation
|
||||
smokes as Arena product proof.
|
||||
|
||||
## Required Proofs
|
||||
|
||||
|
|
|
|||
|
|
@ -24,10 +24,14 @@ leaving Play drops that client while the authoritative daemon remains alive and
|
|||
ready for the next connection.
|
||||
|
||||
Starting the daemon does not create, load, or step a game world. It starts the
|
||||
typed state boundary and CultMesh transport in `ready` mode. A playable-world
|
||||
subscription loads an existing run. Typed mode selection chooses authority and
|
||||
policy; a committed deployment/admission creates session state through the
|
||||
applicable provider or Arena server. Only that activation boundary opens
|
||||
typed state boundary and CultMesh transport in `ready` mode and publishes the
|
||||
`aetheria.hangar` Eve surface. The Hangar exposes the canonical saved loadout
|
||||
and typed equip/remove operations. **Launch** commits a Terminus deployment
|
||||
from the current Hangar revision and creates its run; **Continue** selects the
|
||||
existing deployed Terminus checkpoint without creating a replacement run.
|
||||
Typed mode selection chooses authority and policy; a committed
|
||||
deployment/admission creates session state through the applicable provider or
|
||||
Arena server. Only that activation boundary opens
|
||||
simulation persistence and starts the fixed simulation clock. When the last
|
||||
playable-world subscription is withdrawn,
|
||||
the daemon commits the final public frame and matching private Ymir checkpoint,
|
||||
|
|
@ -53,9 +57,10 @@ being debugged.
|
|||
1. Press **Build daemon** after changing daemon code. Use **Reimport state &
|
||||
build** only when the isolated development state must be replaced.
|
||||
2. Press **Start daemon** and wait for the window to report the live endpoint.
|
||||
3. Enter and leave Unity Play Mode normally. The daemon remains running across
|
||||
client sessions.
|
||||
4. Edit and inspect the generic EveUnity-lowered world in Play Mode.
|
||||
3. Enter Unity Play Mode. The generic client mounts `aetheria.hangar`.
|
||||
4. Configure the saved loadout, then choose **Launch** for a new Terminus run
|
||||
or **Continue** for the currently deployed saved run. The same mounted Eve
|
||||
surface becomes the gameplay composition after activation.
|
||||
5. Use Unity Pause to submit the advertised `simulation.pause` action to the
|
||||
daemon. Unity unpause submits `simulation.rate.realtime`.
|
||||
6. While paused, use **Advance one step** to commit exactly one fixed daemon
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ $daemon = Start-Process dotnet -ArgumentList @(
|
|||
"--client-cultmesh-port", $Port,
|
||||
"--tick-interval-ms", 20,
|
||||
"--fixed-delta-ms", 20,
|
||||
"--terminus-scenario", "standard",
|
||||
"--no-odin-announcements"
|
||||
) -PassThru -WindowStyle Hidden -RedirectStandardOutput $daemonLog -RedirectStandardError "$daemonLog.error"
|
||||
Write-Host "Daemon PID: $($daemon.Id)"
|
||||
|
|
@ -79,7 +78,7 @@ try {
|
|||
}
|
||||
if (-not $ready) { throw "Aetheria daemon did not publish its Eve endpoint." }
|
||||
$env:EVEUNITY_RENDEZVOUS_ENDPOINT = "cultnet+tcp://127.0.0.1:$Port"
|
||||
$env:EVEUNITY_SURFACE_ID = "aetheria.pilot"
|
||||
$env:EVEUNITY_SURFACE_ID = "aetheria.hangar"
|
||||
$client = Start-Process $clientExe -ArgumentList "-force-d3d11" -PassThru
|
||||
Write-Host "Client PID: $($client.Id)"
|
||||
Write-Host "Close the client window to stop the daemon."
|
||||
|
|
|
|||
|
|
@ -114,4 +114,12 @@ foreach ($forbiddenInput in @("AetheriaRuntimeVerseSchemas", "AetheriaRuntimeVer
|
|||
if ($bootstrapSource -match [regex]::Escape($forbiddenInput)) { throw "Client bootstrap depends on forbidden product input '$forbiddenInput'." }
|
||||
}
|
||||
|
||||
$launcherSource = Get-Content (Join-Path (Split-Path $PSScriptRoot -Parent) "scripts/run-aetheria-unity.ps1") -Raw
|
||||
if ($launcherSource -notmatch 'EVEUNITY_SURFACE_ID\s*=\s*"aetheria\.hangar"') {
|
||||
throw "Released launcher must enter through the daemon-owned Hangar surface."
|
||||
}
|
||||
if ($launcherSource -match '--terminus-scenario') {
|
||||
throw "Released launcher must not create product state through a Terminus proof-scenario flag."
|
||||
}
|
||||
|
||||
Write-Host "Aetheria Unity client dependency and bootstrap verification passed."
|
||||
|
|
|
|||
Loading…
Reference in a new issue