From bfb5d9d1d55a459ca040da65d502a165e9fcc7a5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 11 Jul 2026 01:08:31 +0100 Subject: [PATCH] feat(terminus): restore single-player pilot loop --- .../AetheriaDaemonZoneGenerator.cs | 8 +-- Aetheria.State.Daemon/Program.cs | 65 ++++++++++++++++++- Aetheria.State/AetheriaDocumentRegistry.cs | 1 + Aetheria.State/AetheriaStateNode.cs | 3 + .../Documents/AetheriaGameSessionState.cs | 20 ++++++ .../Tests/DaemonRuntimeDocumentTests.cs | 58 +++++++++++++++++ ...AetheriaRuntimeDaemonGameSurfaceBuilder.cs | 6 ++ .../AetheriaRuntimeDaemonSimulation.cs | 29 +++++++++ .../Runtime/AetheriaRuntimeGameDocuments.cs | 2 +- .../AetheriaRuntimeMainMenuSurfaceBuilder.cs | 2 +- .../eve/aetheria.provider-advertisement.json | 1 + 11 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 Aetheria.State/Documents/AetheriaGameSessionState.cs diff --git a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs index c50f71bf2..06ddc5cea 100644 --- a/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs +++ b/Aetheria.State.Daemon/AetheriaDaemonZoneGenerator.cs @@ -5,7 +5,7 @@ using GameCult.Caching; internal static class AetheriaDaemonZoneGenerator { - public const string RunId = "local-starbridge"; + public const string RunId = "local-terminus"; public const uint GenerationSeed = 0xA37E_2026u; public static async Task WritePlayableRunAsync( @@ -25,7 +25,7 @@ internal static class AetheriaDaemonZoneGenerator .ReadAsync() .ConfigureAwait(false) ?? new AetheriaPlayerSettings(); settings.ActiveRunKey = runKey.ToString(); - settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Starbridge Commander" : settings.PlayerName; + settings.PlayerName = string.IsNullOrWhiteSpace(settings.PlayerName) ? "Terminus Pilot" : settings.PlayerName; settings.TutorialPassed = true; settings.LastUpdatedAtUtc = now; await node.MutableDocument(AetheriaStateNode.PlayerSettingsKey) @@ -41,7 +41,7 @@ internal static class AetheriaDaemonZoneGenerator DiscoveredZoneIndices = [0], ZoneKeys = [zoneKey.ToString()], GenerationSeed = GenerationSeed, - CurrentEntityKey = entityKeys[0], + CurrentEntityKey = entityKeys[1], UpdatedAtUtc = now }).ConfigureAwait(false); @@ -218,7 +218,7 @@ internal static class AetheriaDaemonZoneGenerator HullItemKey = loadout.HullItemKey, IsActive = true, HeatsinksEnabled = true, - TractorPower = 1, + TractorPower = 0, Visibility = visibility, VisibilitySourceCount = 1, TargetEntityKey = target, diff --git a/Aetheria.State.Daemon/Program.cs b/Aetheria.State.Daemon/Program.cs index bdf460923..aa7a8da16 100644 --- a/Aetheria.State.Daemon/Program.cs +++ b/Aetheria.State.Daemon/Program.cs @@ -31,7 +31,7 @@ await EnsureWorldDocumentAsync(node).ConfigureAwait(false); await EnsureTradeValuePolicyAsync(node, startedAtUtc).ConfigureAwait(false); await node.FlushAsync().ConfigureAwait(false); await EnsurePlayableRunDocumentsAsync(node, options, startedAtUtc).ConfigureAwait(false); -await EnsureStarbridgeSessionDocumentsAsync(node, options).ConfigureAwait(false); +await EnsureTerminusGameSessionAsync(node, options, startedAtUtc).ConfigureAwait(false); var verseHost = await EnsureVerseHostSettingsAsync(node, options, startedAtUtc).ConfigureAwait(false); await EnsureVerseAuthorityPolicyAsync(node, options).ConfigureAwait(false); discoveryHost.Update(verseHost); @@ -119,6 +119,8 @@ static async Task TickAsync( { await AcceptCoreEveInvocationsAsync(node, options, currentFrame).ConfigureAwait(false); await AcceptEveCommandsAsync(node, options).ConfigureAwait(false); + if (await ApplyRequestedTerminusSessionAsync(node, options).ConfigureAwait(false)) + currentFrame = null; var fixedDeltaSeconds = currentFrame?.FixedDeltaSeconds > 0 ? currentFrame.FixedDeltaSeconds @@ -2211,7 +2213,7 @@ static async Task EnsurePlayableRunDocumentsAsync( if (!string.IsNullOrWhiteSpace(settings?.ActiveRunKey)) { var existingRun = await ReadRuntimeRunCheckpointAsync(node, options.RenderSettings).ConfigureAwait(false); - if (HasPlayableRun(existingRun) && HasStarbridgeScenario(existingRun)) + if (HasPlayableRun(existingRun) && HasTerminusRun(existingRun)) return; } @@ -2222,6 +2224,63 @@ static async Task EnsurePlayableRunDocumentsAsync( .ConfigureAwait(false); } +static async Task EnsureTerminusGameSessionAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options, + string now) +{ + var existing = await node.MutableDocument(AetheriaStateNode.GameSessionStateKey) + .ReadAsync().ConfigureAwait(false); + if (existing != null && + string.Equals(existing.Mode, AetheriaGameSessionState.TerminusMode, StringComparison.Ordinal) && + !string.IsNullOrWhiteSpace(existing.ControlledEntityKey)) + return; + + await node.MutableDocument(AetheriaStateNode.GameSessionStateKey) + .ReplaceAsync(new AetheriaGameSessionState + { + Mode = AetheriaGameSessionState.TerminusMode, + SessionId = options.SessionId, + RunId = AetheriaDaemonZoneGenerator.RunId, + ControlledEntityKey = AetheriaDaemonZoneGenerator.EntityKey(0, 1), + EntrySurfaceId = AetheriaRuntimeDaemonGameSurfaceBuilder.PilotSurfaceId, + UpdatedAtUtc = now + }).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); +} + +static async Task ApplyRequestedTerminusSessionAsync( + AetheriaStateNode node, + AetheriaDaemonHostOptions options) +{ + var menu = await node.MutableDocument(AetheriaStateNode.MainMenuStateKey) + .ReadAsync().ConfigureAwait(false); + if (menu == null || + !string.Equals(menu.LastCommand, AetheriaRuntimeMainMenuCommands.NewGame, StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(menu.LastCommandId)) + return false; + + var session = await node.MutableDocument(AetheriaStateNode.GameSessionStateKey) + .ReadAsync().ConfigureAwait(false) ?? new AetheriaGameSessionState(); + if (string.Equals(session.LastStartCommandId, menu.LastCommandId, StringComparison.Ordinal)) + return false; + + var now = DateTimeOffset.UtcNow.ToString("O"); + await AetheriaDaemonZoneGenerator.WritePlayableRunAsync(node, node.RuntimeCatalog().Latest(), now) + .ConfigureAwait(false); + session.Mode = AetheriaGameSessionState.TerminusMode; + session.SessionId = options.SessionId; + session.RunId = AetheriaDaemonZoneGenerator.RunId; + session.ControlledEntityKey = AetheriaDaemonZoneGenerator.EntityKey(0, 1); + session.EntrySurfaceId = AetheriaRuntimeDaemonGameSurfaceBuilder.PilotSurfaceId; + session.LastStartCommandId = menu.LastCommandId; + session.UpdatedAtUtc = now; + await node.MutableDocument(AetheriaStateNode.GameSessionStateKey) + .ReplaceAsync(session).ConfigureAwait(false); + await node.FlushAsync().ConfigureAwait(false); + return true; +} + static async Task EnsureStarbridgeSessionDocumentsAsync( AetheriaStateNode node, AetheriaDaemonHostOptions options) @@ -2674,7 +2733,7 @@ static bool HasPlayableRun(AetheriaRuntimeRunCheckpointCommit? run) .Any(zone => (zone.Entities ?? Array.Empty()).Count > 0); } -static bool HasStarbridgeScenario(AetheriaRuntimeRunCheckpointCommit? run) +static bool HasTerminusRun(AetheriaRuntimeRunCheckpointCommit? run) { var zones = run?.Zones ?? Array.Empty(); var entities = zones.SelectMany(zone => zone.Entities ?? Array.Empty()).ToArray(); diff --git a/Aetheria.State/AetheriaDocumentRegistry.cs b/Aetheria.State/AetheriaDocumentRegistry.cs index 2e20e7e09..3f3598fc2 100644 --- a/Aetheria.State/AetheriaDocumentRegistry.cs +++ b/Aetheria.State/AetheriaDocumentRegistry.cs @@ -38,6 +38,7 @@ public static class AetheriaDocumentRegistry typeof(AetheriaEntitySnapshot), typeof(AetheriaVerseHostSettings), typeof(AetheriaMainMenuState), + typeof(AetheriaGameSessionState), typeof(AetheriaEveCommandAcceptanceStatus), typeof(AetheriaRuntimeDaemonProviderAdvertisementDocument), typeof(AetheriaRuntimeDaemonHealthDocument), diff --git a/Aetheria.State/AetheriaStateNode.cs b/Aetheria.State/AetheriaStateNode.cs index 714090d6e..719f24888 100644 --- a/Aetheria.State/AetheriaStateNode.cs +++ b/Aetheria.State/AetheriaStateNode.cs @@ -262,6 +262,9 @@ public sealed class AetheriaStateNode : IAsyncDisposable, IDisposable public static CultRecordKey MainMenuStateKey { get; } = new("global:aetheria.main_menu_state.v1"); + public static CultRecordKey GameSessionStateKey { get; } = + new("global:aetheria.game_session.v1"); + public static CultRecordKey TradeValuePolicyKey { get; } = new(AetheriaTradeValuePolicy.RecordKey); diff --git a/Aetheria.State/Documents/AetheriaGameSessionState.cs b/Aetheria.State/Documents/AetheriaGameSessionState.cs new file mode 100644 index 000000000..105e4dbe0 --- /dev/null +++ b/Aetheria.State/Documents/AetheriaGameSessionState.cs @@ -0,0 +1,20 @@ +using GameCult.Caching; +using MessagePack; + +namespace Aetheria.State.Documents; + +[CultDocument("aetheria.game_session", "aetheria.game_session.v1")] +[CultGlobal] +[MessagePackObject] +public sealed class AetheriaGameSessionState +{ + public const string TerminusMode = "terminus"; + + [Key(0)] public string Mode { get; set; } = TerminusMode; + [Key(1)] public string SessionId { get; set; } = ""; + [Key(2)] public string RunId { get; set; } = ""; + [Key(3)] public string ControlledEntityKey { get; set; } = ""; + [Key(4)] public string EntrySurfaceId { get; set; } = "aetheria.pilot"; + [Key(5)] public string LastStartCommandId { get; set; } = ""; + [Key(6)] public string UpdatedAtUtc { get; set; } = ""; +} diff --git a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs index 31ef059f9..e18b1d391 100644 --- a/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs +++ b/Assets/Scripts/Tests/DaemonRuntimeDocumentTests.cs @@ -2159,6 +2159,64 @@ public class DaemonRuntimeDocumentTests Assert.AreEqual(120, frame.SimulationSettings.AttackRange, 0.0001); } + [Test] + public void TerminusPilotTractorScoopsCargoFromTargetedWreck() + { + var loot = new AetheriaRuntimeLoadoutItemSlotCommit + { + Item = new AetheriaRuntimeLoadoutItemCommit { ItemKey = "raider-salvage" } + }; + var pilot = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 1, + Kind = "ship", + FactionKey = "player", + IsActive = true, + TargetEntityIndex = 6, + TractorPower = 1, + CargoContents = new[] { new AetheriaRuntimeCargoBayLoadoutCommit() } + }; + var wreck = new AetheriaRuntimeEntitySnapshotCommit + { + EntityIndex = 6, + Kind = "ship", + FactionKey = "raider", + IsActive = false, + PositionX = 10, + CargoContents = new[] + { + new AetheriaRuntimeCargoBayLoadoutCommit { Items = new[] { loot } } + }, + StatGrids = new[] + { + new AetheriaRuntimeEntityStatGridCommit { Name = "hull", Values = new[] { 0.0 } } + } + }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "local-terminus", + CurrentZoneIndex = 0, + CurrentEntityKey = "global:aetheria.run_state.local-terminus.zone.0.entity.1.v1", + Zones = new[] + { + new AetheriaRuntimeZoneSnapshotCommit + { + ZoneIndex = 0, + Entities = new[] { pilot, wreck } + } + } + }; + + AetheriaRuntimeDaemonSimulation.Step( + run, + new AetheriaRuntimeDaemonIntentState(), + 0.1, + AetheriaRuntimeDaemonSimulationSettings.AetheriaDefault); + + Assert.IsTrue(pilot.CargoContents.SelectMany(bay => bay.Items).Any(slot => slot.Item.ItemKey == "raider-salvage")); + Assert.IsEmpty(wreck.CargoContents); + } + [Test] public void SoaViewIndexRejectsInvalidRenderGroups() { diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs index a9473fa21..32f07afce 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonGameSurfaceBuilder.cs @@ -78,6 +78,9 @@ namespace GameCult.Aetheria.State.Verse CommandButton("aetheria.daemon.game.commands.move", "Move", AetheriaRuntimeDaemonCommandKinds.SetMoveVector), CommandButton("aetheria.daemon.game.commands.target", "Target Nearest", AetheriaRuntimeDaemonCommandKinds.TargetNearest), CommandButton("aetheria.daemon.game.commands.fire", "Fire", AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup), + CommandButton("aetheria.daemon.game.commands.tractor", "Scoop", AetheriaRuntimeDaemonCommandKinds.SetTractorPower), + CommandButton("aetheria.daemon.game.commands.dock", "Dock", AetheriaRuntimeDaemonCommandKinds.DockNearest), + CommandButton("aetheria.daemon.game.commands.undock", "Undock", AetheriaRuntimeDaemonCommandKinds.Undock), CommandButton("aetheria.daemon.game.commands.ping", "Sensor Ping", AetheriaRuntimeDaemonCommandKinds.SensorPing))) }; @@ -343,6 +346,9 @@ namespace GameCult.Aetheria.State.Verse ["focusCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.TargetNearest), ["targetCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.SetTarget), ["actionCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.FireWeaponGroup), + ["tractorCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.SetTractorPower), + ["dockCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.DockNearest), + ["undockCommand"] = CommandName(AetheriaRuntimeDaemonCommandKinds.Undock), ["zoneIndex"] = zone.ZoneIndex.ToString(CultureInfo.InvariantCulture), ["runId"] = run.RunId ?? "" }; diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs index 1c144cc71..adf0607e5 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeDaemonSimulation.cs @@ -38,10 +38,39 @@ namespace GameCult.Aetheria.State.Verse StepTargetPursuit(entities, settings); StepMovement(entities, deltaSeconds); StepCombat(run, zone, entities, intents, deltaSeconds, settings); + StepTractorSalvage(run, zone, entities, settings); RefreshContacts(entities, settings); } } + private static void StepTractorSalvage( + AetheriaRuntimeRunCheckpointCommit run, + AetheriaRuntimeZoneSnapshotCommit zone, + IReadOnlyList entities, + AetheriaRuntimeDaemonSimulationSettings settings) + { + if (!TryParseEntityIndex(run.CurrentEntityKey, out var actorIndex)) + return; + var actor = entities.FirstOrDefault(entity => entity.EntityIndex == actorIndex && IsAlive(entity)); + if (actor == null || actor.TractorPower <= 0 || actor.TargetEntityIndex < 0) + return; + var wreck = entities.FirstOrDefault(entity => entity.EntityIndex == actor.TargetEntityIndex && !IsAlive(entity)); + if (wreck == null || DistanceSq(actor, wreck) > settings.AttackRange * settings.AttackRange) + return; + + var loot = (wreck.CargoContents ?? Array.Empty()) + .SelectMany(bay => bay?.Items ?? Array.Empty()) + .ToArray(); + if (loot.Length == 0) + return; + var bays = (actor.CargoContents ?? Array.Empty()).ToList(); + if (bays.Count == 0) + bays.Add(new AetheriaRuntimeCargoBayLoadoutCommit()); + bays[0].Items = (bays[0].Items ?? Array.Empty()).Concat(loot).ToArray(); + actor.CargoContents = bays.ToArray(); + wreck.CargoContents = Array.Empty(); + } + private static void ApplyMovementIntent( AetheriaRuntimeRunCheckpointCommit run, IReadOnlyList entities, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs index 899b13bf7..b632972a8 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeGameDocuments.cs @@ -1567,7 +1567,7 @@ namespace GameCult.Aetheria.State.Verse var zone = zones.FirstOrDefault(candidate => candidate.ZoneIndex == run.CurrentZoneIndex) ?? zones.FirstOrDefault() ?? new AetheriaRuntimeZoneSnapshotCommit(); - var runId = string.IsNullOrWhiteSpace(run.RunId) ? "local-starbridge" : run.RunId; + var runId = string.IsNullOrWhiteSpace(run.RunId) ? "local-terminus" : run.RunId; return new DocumentContext(run, zone, runId); } diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeMainMenuSurfaceBuilder.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeMainMenuSurfaceBuilder.cs index d13d26dc9..50403f62f 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeMainMenuSurfaceBuilder.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeMainMenuSurfaceBuilder.cs @@ -709,7 +709,7 @@ namespace GameCult.Aetheria.State.Verse .Where(entity => entity != null && entity.IsActive) .Select(entity => { - var entityKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey(run?.RunId ?? "local-starbridge", currentZone.ZoneIndex, entity.EntityIndex); + var entityKey = AetheriaRuntimeRunCheckpointCommit.EntityRecordKey(run?.RunId ?? "local-terminus", currentZone.ZoneIndex, entity.EntityIndex); return new AetheriaMainMenuGravityObject( entityKey, entity.Kind, diff --git a/conformance/eve/aetheria.provider-advertisement.json b/conformance/eve/aetheria.provider-advertisement.json index a7b3f1477..5e06613a3 100644 --- a/conformance/eve/aetheria.provider-advertisement.json +++ b/conformance/eve/aetheria.provider-advertisement.json @@ -37,6 +37,7 @@ "aetheria.runtime.daemon.game_surface.v1", "aetheria.runtime.daemon.editor_surface.v1", "aetheria.runtime.daemon.command.v1", + "aetheria.game_session.v1", "gamecult.eve.surface.v1", "gamecult.eve.command.v1", "gamecult.eve.command_invocation.v1",