feat(terminus): restore single-player pilot loop

This commit is contained in:
Meta Vens 2026-07-11 01:08:31 +01:00
commit bfb5d9d1d5
11 changed files with 186 additions and 9 deletions

View file

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

View file

@ -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<AetheriaRuntimeDaemonTickResult> 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<AetheriaGameSessionState>(AetheriaStateNode.GameSessionStateKey)
.ReadAsync().ConfigureAwait(false);
if (existing != null &&
string.Equals(existing.Mode, AetheriaGameSessionState.TerminusMode, StringComparison.Ordinal) &&
!string.IsNullOrWhiteSpace(existing.ControlledEntityKey))
return;
await node.MutableDocument<AetheriaGameSessionState>(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<bool> ApplyRequestedTerminusSessionAsync(
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");
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<AetheriaGameSessionState>(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<AetheriaRuntimeEntitySnapshotCommit>()).Count > 0);
}
static bool HasStarbridgeScenario(AetheriaRuntimeRunCheckpointCommit? run)
static bool HasTerminusRun(AetheriaRuntimeRunCheckpointCommit? run)
{
var zones = run?.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>();
var entities = zones.SelectMany(zone => zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>()).ToArray();

View file

@ -38,6 +38,7 @@ public static class AetheriaDocumentRegistry
typeof(AetheriaEntitySnapshot),
typeof(AetheriaVerseHostSettings),
typeof(AetheriaMainMenuState),
typeof(AetheriaGameSessionState),
typeof(AetheriaEveCommandAcceptanceStatus),
typeof(AetheriaRuntimeDaemonProviderAdvertisementDocument),
typeof(AetheriaRuntimeDaemonHealthDocument),

View file

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

View file

@ -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; } = "";
}

View file

@ -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()
{

View file

@ -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 ?? ""
};

View file

@ -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<AetheriaRuntimeEntitySnapshotCommit> 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<AetheriaRuntimeCargoBayLoadoutCommit>())
.SelectMany(bay => bay?.Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>())
.ToArray();
if (loot.Length == 0)
return;
var bays = (actor.CargoContents ?? Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>()).ToList();
if (bays.Count == 0)
bays.Add(new AetheriaRuntimeCargoBayLoadoutCommit());
bays[0].Items = (bays[0].Items ?? Array.Empty<AetheriaRuntimeLoadoutItemSlotCommit>()).Concat(loot).ToArray();
actor.CargoContents = bays.ToArray();
wreck.CargoContents = Array.Empty<AetheriaRuntimeCargoBayLoadoutCommit>();
}
private static void ApplyMovementIntent(
AetheriaRuntimeRunCheckpointCommit run,
IReadOnlyList<AetheriaRuntimeEntitySnapshotCommit> entities,

View file

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

View file

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

View file

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