Restore autonomous return home

This commit is contained in:
Meta Vens 2026-07-11 19:49:48 +01:00
commit d79aef1053
2 changed files with 160 additions and 1 deletions

View file

@ -26,6 +26,7 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
SchedulerCollapsesDuplicateAssignmentMarkers();
SchedulerAssignsShortestGalaxyRoute();
AgentTraversesGalaxyRouteBeforeExecutingTask();
IdleAgentReturnsToCanonicalHomeAndDocks();
AttackAgentControlsOptimumRangeThroughMovementLever();
AgentCompletesAttackTaskThroughTargetFireAndYmir();
AgentCompletesHaulTaskThroughMovementAndCargoCommands();
@ -760,6 +761,72 @@ internal sealed class AetheriaDaemonYmirSmokeChecks
"agent must execute the task only after arriving in its destination zone");
}
private static void IdleAgentReturnsToCanonicalHomeAndDocks()
{
var worker = Entity(0, 0, "workers");
worker.EntityId = "worker.return-home";
worker.HomeEntityId = "station.home";
worker.AgentTaskCapabilities = [AetheriaRuntimeAgentTaskTypes.Haul];
var home = Entity(0, 60, "workers");
home.EntityId = "station.home";
home.Kind = "station";
var run = new AetheriaRuntimeRunCheckpointCommit
{
RunId = "agent-return-home-smoke",
Zones =
[
new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, PositionX = 0, GravityTerrainRadius = 100, AdjacentZoneIndices = [1], Entities = [worker] },
new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 1, PositionX = 100, GravityTerrainRadius = 100, AdjacentZoneIndices = [0, 2], Entities = Array.Empty<AetheriaRuntimeEntitySnapshotCommit>() },
new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 2, PositionX = 200, GravityTerrainRadius = 100, AdjacentZoneIndices = [1], Entities = [home] }
]
};
var sawApproach = false;
var travelCount = 0;
var sawDock = false;
var rejectedHomeCommands = new List<string>();
for (var frame = 0; frame < 40; frame++)
{
var tick = AetheriaRuntimeDaemonTickRunner.Tick(
Path.Combine(Path.GetTempPath(), "aetheria-agent-return-home-smoke.cc"),
run,
new AetheriaRuntimeDaemonTickOptions
{
WorldPhysics = new AetheriaYmirWorldPhysics(),
FrameId = frame,
FixedDeltaSeconds = 0.1,
SimulationTimeSeconds = frame * 0.1,
BuildPublications = false
});
sawApproach |= tick.OperationResult.AppliedCommandIds.Any(id => id.Contains(":home-approach", StringComparison.Ordinal));
travelCount += tick.OperationResult.AppliedCommandIds.Count(id => id.EndsWith(":home-travel", StringComparison.Ordinal));
sawDock |= tick.OperationResult.AppliedCommandIds.Any(id => id.EndsWith(":home-dock", StringComparison.Ordinal));
rejectedHomeCommands.AddRange(tick.OperationResult.RejectedCommandIds.Where(id => id.Contains(":home-", StringComparison.Ordinal)));
if (sawDock)
break;
}
var homeZone = run.Zones.Single(zone => zone.ZoneIndex == 2);
var arrivedWorker = homeZone.Entities.Single(entity => entity.EntityId == "worker.return-home");
var arrivedHome = homeZone.Entities.Single(entity => entity.EntityId == "station.home");
Require(sawApproach, "idle worker must approach its route and home through shared movement commands");
RequireEqual(2, travelCount, "idle worker must traverse the shortest route to its canonical home");
Require(sawDock,
$"idle worker must dock through the daemon docking command; worker={arrivedWorker.PositionX:0.###},{arrivedWorker.PositionZ:0.###} home={arrivedHome.PositionX:0.###},{arrivedHome.PositionZ:0.###} rejected={string.Join(",", rejectedHomeCommands)}");
Require(arrivedHome.DockingBayAssignments.Contains(arrivedWorker.EntityIndex),
"home docking parentage must be the authoritative completion fact");
var settled = AetheriaRuntimeDaemonTickRunner.Tick(
Path.Combine(Path.GetTempPath(), "aetheria-agent-return-home-settled-smoke.cc"),
run,
new AetheriaRuntimeDaemonTickOptions
{
WorldPhysics = new AetheriaYmirWorldPhysics(), FrameId = 41,
FixedDeltaSeconds = 0.1, SimulationTimeSeconds = 4.1, BuildPublications = false
});
Require(!settled.OperationResult.AppliedCommandIds.Any(id => id.Contains(":home-", StringComparison.Ordinal)),
"docked worker must not emit a perpetual homecoming repair loop");
}
private static void AgentClaimsAndCompletesExploreTaskThroughCommands()
{
var agent = Entity(0, 0, "workers");

View file

@ -95,7 +95,9 @@ namespace GameCult.Aetheria.State.Verse
ReleaseInvalidAssignments(run);
AssignQueuedTasks(run, frameId);
return PlanAssignedTasks(run, frameId, catalog, simulationTimeSeconds);
var commands = PlanAssignedTasks(run, frameId, catalog, simulationTimeSeconds).ToList();
commands.AddRange(PlanIdleReturns(run, frameId));
return commands;
}
public static void Reconcile(
@ -335,6 +337,96 @@ namespace GameCult.Aetheria.State.Verse
return commands;
}
private static IReadOnlyList<AetheriaRuntimeDaemonCommandDocument> PlanIdleReturns(
AetheriaRuntimeRunCheckpointCommit run,
long frameId)
{
var commands = new List<AetheriaRuntimeDaemonCommandDocument>();
var locations = (run.Zones ?? Array.Empty<AetheriaRuntimeZoneSnapshotCommit>())
.Where(zone => zone != null)
.SelectMany(zone => (zone.Entities ?? Array.Empty<AetheriaRuntimeEntitySnapshotCommit>())
.Where(entity => entity != null)
.Select(entity => (Zone: zone, Entity: entity)))
.ToArray();
var byId = locations
.Where(value => !string.IsNullOrWhiteSpace(value.Entity.EntityId))
.ToDictionary(value => value.Entity.EntityId, StringComparer.Ordinal);
foreach (var worker in locations
.Where(value => value.Entity.IsActive)
.Where(value => (value.Entity.AgentTaskCapabilities ?? Array.Empty<string>()).Count > 0)
.Where(value => string.IsNullOrWhiteSpace(value.Entity.AssignedAgentTaskId))
.Where(value => !string.IsNullOrWhiteSpace(value.Entity.HomeEntityId)))
{
if (!byId.TryGetValue(worker.Entity.HomeEntityId, out var home) || !home.Entity.IsActive)
continue;
if (worker.Zone.ZoneIndex != home.Zone.ZoneIndex)
{
var route = FindZoneRoute(run, worker.Zone.ZoneIndex, home.Zone.ZoneIndex);
if (route.Count < 2)
continue;
var settings = AetheriaRuntimeDaemonRenderSettings.AetheriaDefault;
var exit = AetheriaRuntimeDaemonRenderQueries.QueryWormholeExits(
run,
worker.Zone,
AetheriaRuntimeDaemonRenderQueries.ResolveZoneRenderRadius(worker.Zone, 1200),
settings.WormholeDistanceRatio)
.First(candidate => candidate.TargetZoneIndex == route[1]);
var dx = exit.PositionX - worker.Entity.PositionX;
var dz = exit.PositionZ - worker.Entity.PositionZ;
if (Math.Sqrt(dx * dx + dz * dz) > AetheriaRuntimeDaemonOperationContext.DefaultWormholeExitRadius * 0.8)
commands.Add(IdleMovement(worker.Zone.ZoneIndex, worker.Entity, frameId, dx, dz, "home-approach-wormhole"));
else
commands.Add(IdleCommand(worker.Zone.ZoneIndex, worker.Entity, frameId,
AetheriaRuntimeDaemonCommandKinds.EnterWormhole, "home-travel", command => command.TargetZoneIndex = route[1]));
continue;
}
if ((home.Entity.DockingBayAssignments ?? Array.Empty<int>()).Contains(worker.Entity.EntityIndex))
continue;
var homeDx = home.Entity.PositionX - worker.Entity.PositionX;
var homeDz = home.Entity.PositionZ - worker.Entity.PositionZ;
var dockingContactDistance = InteractionRadius(home.Entity) + InteractionRadius(worker.Entity);
if (Math.Sqrt(homeDx * homeDx + homeDz * homeDz) > dockingContactDistance + 0.5)
commands.Add(IdleMovement(worker.Zone.ZoneIndex, worker.Entity, frameId, homeDx, homeDz, "home-approach"));
else
commands.Add(IdleCommand(worker.Zone.ZoneIndex, worker.Entity, frameId,
AetheriaRuntimeDaemonCommandKinds.Dock, "home-dock",
command => command.TargetEntityKey = EntityKey(run, home.Zone.ZoneIndex, home.Entity.EntityIndex)));
}
return commands;
}
private static AetheriaRuntimeDaemonCommandDocument IdleMovement(
int zoneIndex,
AetheriaRuntimeEntitySnapshotCommit entity,
long frameId,
double dx,
double dz,
string phase)
{
var length = Math.Sqrt(dx * dx + dz * dz);
return IdleCommand(zoneIndex, entity, frameId, AetheriaRuntimeDaemonCommandKinds.SetMoveVector, phase, command =>
{
command.DirectionX = length <= 0.0001 ? 0 : dx / length;
command.DirectionY = length <= 0.0001 ? 0 : dz / length;
command.ScalarValue = length <= 0.0001 ? 0 : 1;
});
}
private static AetheriaRuntimeDaemonCommandDocument IdleCommand(
int zoneIndex,
AetheriaRuntimeEntitySnapshotCommit entity,
long frameId,
AetheriaRuntimeDaemonCommandKinds kind,
string phase,
Action<AetheriaRuntimeDaemonCommandDocument> configure)
{
var command = AetheriaRuntimeDaemonCommandDocument.Create(
kind, RuntimeId, "daemon-agent-scheduler", frameId, $"zone.{zoneIndex}.entity.{entity.EntityIndex}");
command.CommandId = string.Join(":", RuntimeId, entity.EntityId, frameId.ToString(CultureInfo.InvariantCulture), phase);
configure(command);
return command;
}
private static (AetheriaRuntimeZoneSnapshotCommit? Zone, AetheriaRuntimeEntitySnapshotCommit? Entity) FindAssignedEntity(
AetheriaRuntimeRunCheckpointCommit run,
AetheriaRuntimeAgentTaskCommit task)