diff --git a/Aetheria.State.Daemon.Smoke/Program.cs b/Aetheria.State.Daemon.Smoke/Program.cs index 144f73b28..4086da78a 100644 --- a/Aetheria.State.Daemon.Smoke/Program.cs +++ b/Aetheria.State.Daemon.Smoke/Program.cs @@ -22,6 +22,8 @@ internal sealed class AetheriaDaemonYmirSmokeChecks MultipleActorsUseTheSameMovementLever(); AgentClaimsAndCompletesExploreTaskThroughCommands(); SchedulerAssignsHighestPriorityCompatibleTask(); + SchedulerRequeuesTaskFromDeadAgent(); + SchedulerCollapsesDuplicateAssignmentMarkers(); SchedulerAssignsShortestGalaxyRoute(); AgentTraversesGalaxyRouteBeforeExecutingTask(); AttackAgentControlsOptimumRangeThroughMovementLever(); @@ -604,6 +606,68 @@ internal sealed class AetheriaDaemonYmirSmokeChecks RequireEqual("high", agent.AssignedAgentTaskId, "agent assignment must point at the selected task"); } + private static void SchedulerRequeuesTaskFromDeadAgent() + { + var dead = Entity(0, 0, "workers"); + dead.IsActive = false; + dead.AssignedAgentTaskId = "recover-work"; + var replacement = Entity(1, 0, "workers"); + replacement.AgentTaskCapabilities = [AetheriaRuntimeAgentTaskTypes.Explore]; + var task = new AetheriaRuntimeAgentTaskCommit + { + TaskId = "recover-work", + CorporationKey = "workers", + TaskType = AetheriaRuntimeAgentTaskTypes.Explore, + ZoneIndex = 0, + Status = AetheriaRuntimeAgentTaskStatuses.Assigned, + AssignedEntityIndex = 0, + TargetPositionX = 100, + DeliveredQuantity = 3, + RequestedQuantity = 5 + }; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "agent-recovery-smoke", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [dead, replacement] }], + AgentTasks = [task] + }; + + AetheriaRuntimeAgentScheduler.AssignAndPlan(run, 8); + + RequireEqual(AetheriaRuntimeAgentTaskStatuses.Assigned, task.Status, + "work abandoned by an inactive agent must return to the corporation queue and be reassigned"); + RequireEqual(replacement.EntityIndex, task.AssignedEntityIndex, + "compatible active replacement must receive abandoned work"); + RequireEqual(3, task.DeliveredQuantity, + "reassignment must preserve authoritative task progress"); + Require(string.IsNullOrWhiteSpace(dead.AssignedAgentTaskId), + "inactive agent must not retain reservation authority"); + } + + private static void SchedulerCollapsesDuplicateAssignmentMarkers() + { + var first = Entity(0, 0, "workers"); + first.AssignedAgentTaskId = "single-owner"; + var duplicate = Entity(1, 0, "workers"); + duplicate.AssignedAgentTaskId = "single-owner"; + var task = AgentTask("single-owner", 1); + task.Status = AetheriaRuntimeAgentTaskStatuses.Assigned; + var run = new AetheriaRuntimeRunCheckpointCommit + { + RunId = "agent-single-owner-smoke", + Zones = [new AetheriaRuntimeZoneSnapshotCommit { ZoneIndex = 0, Entities = [first, duplicate] }], + AgentTasks = [task] + }; + + AetheriaRuntimeAgentScheduler.AssignAndPlan(run, 9); + + RequireEqual(1, run.Zones.SelectMany(zone => zone.Entities) + .Count(entity => string.Equals(entity.AssignedAgentTaskId, task.TaskId, StringComparison.Ordinal)), + "one task must have exactly one active carrier after reconciliation"); + RequireEqual(first.EntityIndex, task.AssignedEntityIndex, + "duplicate assignment reconciliation must be deterministic"); + } + private static AetheriaRuntimeAgentTaskCommit AgentTask(string id, int priority) => new() { TaskId = id, diff --git a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs index 4ba96bf6b..723b9d72a 100644 --- a/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs +++ b/Packages/org.gamecult.aetheria.state/Runtime/AetheriaRuntimeAgentTasks.cs @@ -745,17 +745,41 @@ namespace GameCult.Aetheria.State.Verse private static void ReleaseInvalidAssignments(AetheriaRuntimeRunCheckpointCommit run) { - var activeTaskIds = (run.AgentTasks ?? Array.Empty()) + var tasks = (run.AgentTasks ?? Array.Empty()) + .Where(task => task != null) + .ToArray(); + var entities = (run.Zones ?? Array.Empty()) + .Where(zone => zone != null) + .SelectMany(zone => zone.Entities ?? Array.Empty()) + .Where(entity => entity != null) + .ToArray(); + var activeTaskIds = tasks .Where(task => task != null && string.Equals(task.Status, AetheriaRuntimeAgentTaskStatuses.Assigned, StringComparison.Ordinal)) .Select(task => task.TaskId) .ToHashSet(StringComparer.Ordinal); - foreach (var entity in (run.Zones ?? Array.Empty()) - .Where(zone => zone != null) - .SelectMany(zone => zone.Entities ?? Array.Empty())) + foreach (var entity in entities) { - if (!string.IsNullOrWhiteSpace(entity.AssignedAgentTaskId) && !activeTaskIds.Contains(entity.AssignedAgentTaskId)) + if (!string.IsNullOrWhiteSpace(entity.AssignedAgentTaskId) && + (!entity.IsActive || !activeTaskIds.Contains(entity.AssignedAgentTaskId))) entity.AssignedAgentTaskId = ""; } + foreach (var task in tasks.Where(task => string.Equals(task.Status, AetheriaRuntimeAgentTaskStatuses.Assigned, StringComparison.Ordinal))) + { + var carriers = entities + .Where(entity => entity.IsActive && string.Equals(entity.AssignedAgentTaskId, task.TaskId, StringComparison.Ordinal)) + .OrderBy(entity => entity.EntityIndex) + .ToArray(); + if (carriers.Length == 0) + { + task.Status = AetheriaRuntimeAgentTaskStatuses.Queued; + task.AssignedEntityIndex = -1; + task.AssignedFrameId = -1; + continue; + } + task.AssignedEntityIndex = carriers[0].EntityIndex; + foreach (var duplicate in carriers.Skip(1)) + duplicate.AssignedAgentTaskId = ""; + } } private static string EntityKey(AetheriaRuntimeRunCheckpointCommit run, int zoneIndex, int entityIndex) =>