diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 98ae98c51..da2ff5eba 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -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() }, + 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(); + 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"); diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs index 723b9d72a..6b6df8c0a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs @@ -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 PlanIdleReturns( + AetheriaRuntimeRunCheckpointCommit run, + long frameId) + { + var commands = new List(); + var locations = (run.Zones ?? Array.Empty()) + .Where(zone => zone != null) + .SelectMany(zone => (zone.Entities ?? Array.Empty()) + .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()).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()).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 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)