class ERR_HQUnit { int Id; SCR_AIGroup Group; SCR_AIGroupUtilityComponent Utility; IEntity Radio; float NextReport; ref ERR_HQRoute Uplink; ref ERR_HQReport PendingReport; ref ERR_HQReportSelection ReportSelection = new ERR_HQReportSelection(); ref ERR_HQRoute Downlink; ref ERR_HQReport PendingOrder; ref ERR_HQReport Assignment; AIWaypoint Waypoint; ref ERR_HQTask Task; ref ERR_HQTask PendingTask; ref ERR_HQSearchReport PendingSweep; ref ERR_HQStatusReport PendingStatus; ref ERR_HQStatusSampler StatusSampler = new ERR_HQStatusSampler(); string ForceSourceId; float NextStatus; bool PendingCheckin; bool HomeGuard; string HomeId; bool PlanEligible; bool PlanFighting; vector PlanPosition; bool ReservedForSupport; int LogisticsReservation; ref ERR_MortarSupplyStatus PendingSupplyStatus; ref ERR_ConvoyCombatOrder PendingConvoyOrder; bool Eligible(ERR_GameMode mode) { if (ReservedForSupport || LogisticsReservation > 0 || !Group || !Utility || Group.GetPlayerCount(true) > 0 || Group.IsSlave() || Group.GetFaction() != mode.EnemyFaction()) return false; array agents = {}; Group.GetAgents(agents); bool survivor; foreach (AIAgent agent : agents) { if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue; survivor = true; if (CompartmentAccessComponent.GetVehicleIn(agent.GetControlledEntity())) return false; } return survivor; } void CancelPending() { PendingOrder = null; PendingConvoyOrder = null; PendingCheckin = false; PendingTask = null; Downlink = null; } ERR_HQTask PlannedTask() { if (PendingTask) return PendingTask; return Task; } void ReleaseAssignment() { ERR_GameMode mode = ERR_GameMode.Get(); ERR_ForceRecord record; if (mode && Group) record = mode.Forces().FindGroup(Group); if (record && !record.Eliminated) { record.Duty = record.HomeDuty; record.DutyTarget = record.HomePosition; } if (Waypoint) { if (Group) Group.RemoveWaypoint(Waypoint); SCR_EntityHelper.DeleteEntityAndChildren(Waypoint); } Waypoint = null; Assignment = null; Task = null; } void Release() { ReleaseAssignment(); CancelPending(); } } class ERR_HQ : ERR_CommandService { protected ERR_GameMode m_Mode; protected ref ERR_MortarSystem m_Mortars; protected ref ERR_EnemyEquipment m_Equipment; protected ref ERR_ConvoyPool m_ConvoyPool; protected ref ERR_ConvoyService m_Convoys; protected ref ERR_ConvoyTask m_ConvoyTask; protected ref array m_ConvoyPositions = {}; protected ref array m_ConvoyAngles = {}; protected ref ERR_ForceRoute m_ConvoyRoute; protected string m_ConvoyFailure; protected ref ERR_HQBodyObserver m_Bodies = new ERR_HQBodyObserver(); protected ref ERR_HQWatch m_Watch = new ERR_HQWatch(); protected ref ERR_MortarLogistics m_Logistics; protected ref array m_Units = {}; protected ref array m_Contacts = {}; protected ref ERR_HQMemory m_Memory = new ERR_HQMemory(); protected ref ERR_HQCoverage m_Coverage = new ERR_HQCoverage(); protected ref array m_Hunts = {}; protected ref array m_PlanBeliefs = {}; protected ref map m_HomeAvailable = new map(); protected ref ERR_HQNetwork m_Network = new ERR_HQNetwork(); protected ref ERR_HQJournal m_Journal = new ERR_HQJournal(); protected int m_NextId; protected float m_NextNetwork; protected float m_NextAssignment; void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Equipment = new ERR_EnemyEquipment(mode); m_ConvoyPool = new ERR_ConvoyPool(mode, 1); m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool); m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); } static ERR_HQ Get() { ERR_GameMode mode = ERR_GameMode.Get(); if (!mode) return null; return ERR_HQ.Cast(mode.Command()); } static bool MissionActive() { ERR_GameMode mode = ERR_GameMode.Get(); return mode && mode.Active(); } static void Event(string kind, string message, int messageId = 0) { ERR_HQ hq = Get(); if (hq) hq.Journal().Write(kind, message, messageId); } ERR_ConvoyPool ConvoyPool() { return m_ConvoyPool; } ERR_ConvoyService Convoys() { return m_Convoys; } ERR_ConvoyTask ConvoyTask() { return m_ConvoyTask; } bool PrepareConvoy(vector seed) { if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask) return false; m_ConvoyPositions.Clear(); m_ConvoyAngles.Clear(); m_ConvoyRoute = null; if (!ERR_ConvoyRoadPlan.Build(seed, m_ConvoyPositions, m_ConvoyAngles, m_ConvoyRoute)) { m_ConvoyPositions.Clear(); m_ConvoyAngles.Clear(); m_ConvoyRoute = null; m_ConvoyFailure = "No sufficiently long, wide and clear road near the deployment site."; return false; } m_ConvoyFailure = ""; return true; } bool StartConvoy() { if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask || !m_ConvoyRoute) return false; // Every existing formation must be admitted before the additional 19 bodies // are budgeted. In particular, asynchronous mortar startup cannot be skipped. foreach (ERR_HQUnit unit : m_Units) { if (unit.Group && !m_Mode.Forces().FindGroup(unit.Group)) { m_ConvoyFailure = "Existing force admission is still pending; convoy deployment withheld."; return false; } } ERR_ConvoyReceipt receipt = m_ConvoyPool.Reserve("everon-supply-convoy-1"); if (!receipt || receipt.Consumed) { m_ConvoyFailure = "The finite convoy allowance or physical force budget is unavailable."; return false; } m_ConvoyTask = m_Convoys.Dispatch(receipt, m_ConvoyPositions, m_ConvoyAngles, m_ConvoyRoute, GetGame().GetWorld().GetWorldTime()); if (!m_ConvoyTask) { m_ConvoyFailure = "Deployment preflight failed; no convoy created."; return false; } m_ConvoyFailure = ""; return !m_ConvoyTask.Stopped; } bool StartScenarioConvoy() { if (!m_Mode || !m_Mode.Sites()) return false; ERR_SiteRuntime site = m_Mode.Sites().Find("everon-supply-1"); if (!site || !site.Definition) return false; return PrepareConvoy(site.Definition.Position) && StartConvoy(); } string ConvoyStatus() { string detail = m_Convoys.Status(); if (!m_ConvoyFailure.IsEmpty()) detail += "; " + m_ConvoyFailure; return detail; } ERR_MortarSystem Mortars() { return m_Mortars; } bool PrepareSupport() { return m_Mortars.Prepare() && m_Logistics.Prepare(); } ERR_MortarLogistics Logistics() { return m_Logistics; } void ReserveGroup(SCR_AIGroup group) { foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != group) continue; ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime()); unit.ReservedForSupport = true; } } // Temporary reservations never alter the permanent support reservation. int LogisticsToken(SCR_AIGroup group) { foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.LogisticsReservation; return 0; } bool ReserveLogistics(SCR_AIGroup group, int token) { if (!Replication.IsServer() || token <= 0 || !MissionActive()) return false; foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != group || !unit.Eligible(m_Mode) || !unit.Radio || unit.Assignment || unit.PendingOrder || unit.Downlink || unit.Waypoint) continue; if (unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty()) continue; unit.LogisticsReservation = token; return true; } return false; } void ReleaseLogistics(SCR_AIGroup group, int token) { foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group && unit.LogisticsReservation == token) unit.LogisticsReservation = 0; } bool QueueSupplyStatus(ERR_MortarBattery battery, float now) { if (!Replication.IsServer() || !MissionActive() || !battery || !battery.StartupActivated || !battery.Operational() || !m_Logistics.CanSample(battery) || !ERR_MortarLogisticsRules.Need(battery.CountShells())) return false; foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != battery.Group || unit.Uplink || !unit.Radio || unit.Radio != battery.RadioOperator) continue; ERR_HQRoute route = m_Network.Route(unit.Radio); if (!route) return false; ERR_MortarSupplyStatus report = new ERR_MortarSupplyStatus(); report.Group = battery.Group; report.Gun = battery.Gun; report.Position = battery.Gun.GetOrigin(); report.Shells = battery.CountShells(); report.ObservedAt = now; report.MessageId = m_Journal.Message(); route.MessageId = report.MessageId; route.Payload = string.Format("mortar supply status; usable HE=%1; position=%2; sampled=%3", report.Shells, report.Position, report.ObservedAt); unit.PendingSupplyStatus = report; unit.Uplink = route; m_Journal.Write("SUPPLY REPORT", route.Payload, route.MessageId); return true; } return false; } void PhysicalForces(out array entities) { entities.Clear(); foreach (ERR_HQUnit unit : m_Units) { if (!unit.Group) continue; array agents = {}; unit.Group.GetAgents(agents); foreach (AIAgent agent : agents) { if (!agent || !agent.GetControlledEntity()) continue; IEntity actor = agent.GetControlledEntity(); if (!entities.Contains(actor)) entities.Insert(actor); IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(actor); if (vehicle && !entities.Contains(vehicle)) entities.Insert(vehicle); } } m_Mortars.AppendGuns(entities); } bool MortarConnected(SCR_AIGroup group) { foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group && unit.Radio) return m_Network.Route(unit.Radio) != null; return false; } ERR_HQRoute Downlink(SCR_AIGroup group, string kind, string detail, int sourceId) { foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != group || !unit.Radio) continue; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) return null; route.MessageId = m_Journal.Message(); route.Payload = string.Format("%1 from report M%2; %3", kind, sourceId, detail); m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId); return route; } return null; } void SupportContacts(out array contacts, float now) { contacts.Clear(); foreach (ERR_HQReport contact : m_Contacts) if (ERR_MortarPrediction.Fresh(contact, now)) contacts.Insert(contact.Snapshot()); } ERR_HQMemory Memory() { return m_Memory; } ERR_HQCoverage Coverage() { return m_Coverage; } ERR_HQJournal Journal() { return m_Journal; } string Status() { return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells", m_Memory.Count(), m_Coverage.Count()) + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status(); } protected void BindPhysical(ERR_HQUnit unit) { if (!unit || !unit.Group) return; ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group); if (!record || !record.Physical || record.Eliminated) return; unit.ForceSourceId = record.SourceId; if (!unit.StatusSampler.Bound()) unit.StatusSampler.Bind(unit.Group, record.SourceId, record.InitialStrength, m_Mode.EnemyFaction()); if (unit.StatusSampler.Bound()) m_Bodies.RegisterGroup(unit.Group, unit.StatusSampler); } override void RegisterGroup(SCR_AIGroup group) { if (!Replication.IsServer() || !group) return; foreach (ERR_HQUnit known : m_Units) if (known.Group == group) { BindPhysical(known); return; } if (m_Units.Count() >= 128) return; SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent)); if (!utility || group.GetFaction() != m_Mode.EnemyFaction()) return; ERR_HQUnit unit = new ERR_HQUnit(); unit.Id = ++m_NextId; unit.Group = group; unit.Utility = utility; ERR_SiteService sites = m_Mode.Sites(); ERR_SiteRuntime home; if (sites) home = sites.FindGroup(group); if (home) { unit.HomeId = home.Definition.Id; unit.HomeGuard = !home.Definition.Patrol || home.Definition.Patrol.IsEmpty(); } int phase = unit.Id % 5; unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000; m_Units.Insert(unit); m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group)); BindPhysical(unit); unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000; m_Journal.Write("REGISTER", string.Format("group %1", unit.Id)); } override void UnregisterGroup(SCR_AIGroup group) { m_Equipment.UnregisterGroup(group); foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != group) continue; ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime()); unit.Group = null; unit.Utility = null; } } protected int HuntingAssigned() { int count; foreach (ERR_HQUnit unit : m_Units) { if (!unit.Assignment && !unit.PendingOrder) continue; ERR_HQTask planned = unit.PlannedTask(); if (planned && planned.ReleaseOnly) planned = planned.ReplacesTask; if (planned && !planned.Investigation()) count++; } return count; } protected int Assigned() { int total; foreach (ERR_HQUnit unit : m_Units) if (unit.Assignment || unit.PendingOrder) total++; return total; } protected void IndexRadios() { m_Network.Clear(); foreach (ERR_HQUnit unit : m_Units) { unit.Radio = null; if (!unit.Group || unit.Group.GetPlayerCount(true) > 0 || unit.Group.GetFaction() != m_Mode.EnemyFaction()) continue; array agents = {}; unit.Group.GetAgents(agents); int best; foreach (AIAgent agent : agents) { if (!agent) continue; IEntity actor = agent.GetControlledEntity(); int capability = ERR_HQRadio.Capability(actor); if (capability <= 0) continue; m_Network.Add(actor, unit.Group, unit.Id, capability); if (capability > best) { unit.Radio = actor; best = capability; } } } m_Network.Build(); } protected void Observe(ERR_HQUnit unit, float now) { if (!unit.Utility || !unit.Radio || unit.Uplink || now < unit.NextReport) return; unit.NextReport = now + m_Mode.Settings().Get("reportIntervalSeconds") * 1000; array samples = {}; ERR_HQObservation.Gather(unit.Utility, now, samples); if (samples.IsEmpty()) return; ERR_HQVisualContact seen = unit.ReportSelection.Select(samples); if (!seen) return; ERR_HQRoute route = m_Network.Route(unit.Radio); if (!route) return; ERR_HQReport report = new ERR_HQReport(); report.MessageId = m_Journal.Message(); report.Target = seen.Entity; report.Source = unit.Group; report.Position = seen.Position; report.ObservedAt = seen.ObservedAt; ERR_HQObservation.Describe(report, samples, now); route.MessageId = report.MessageId; route.Payload = report.Description(now); unit.PendingReport = report; unit.Uplink = route; unit.ReportSelection.Queued(seen.Entity); m_Journal.Write("REPORT", route.Payload, report.MessageId); } protected void ObserveBody(ERR_HQUnit unit, float now) { if (unit.Uplink || !unit.Radio || !unit.Group) return; ERR_HQReport report; if (!m_Bodies.Observe(unit.Group, now, report)) return; ERR_HQRoute route = m_Network.Route(unit.Radio); if (!route) return; report.MessageId = m_Journal.Message(); report.Source = unit.Group; route.MessageId = report.MessageId; route.Payload = "visible casualty evidence; cause unknown; " + report.Description(now); unit.PendingReport = report.Snapshot(); unit.Uplink = route; m_Journal.Write("BODY REPORT", route.Payload, route.MessageId); } protected void StatusReport(ERR_HQUnit unit, float now) { unit.StatusSampler.ObserveDeaths(); if (unit.Uplink || !unit.Radio || now < unit.NextStatus) return; ERR_HQRoute route = m_Network.Route(unit.Radio); if (!route) return; ERR_HQStatusReport report = unit.StatusSampler.Sample(now, m_Journal.Message(), unit.Radio); if (!report) return; route.MessageId = report.MessageId; route.Payload = string.Format("status %1: %2 living, %3 fit, %4 confirmed losses; sampled=%5", report.SourceId, report.Count, report.Fit, report.ConfirmedLosses, report.ObservedAt); string ammunition = "unknown"; if (report.AmmoState && report.AmmoState.Complete) ammunition = string.Format("%1 magazine rounds, %2 chambered", report.AmmoState.SmallArmsRounds, report.AmmoState.SmallArmsChambers); route.Payload += "; small-arms ammunition=" + ammunition; unit.PendingStatus = report; unit.Uplink = route; unit.NextStatus = now + 180000; m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId); } // Due routine status and requested check-ins get the next free uplink. // Positive observations precede a negative search report. protected void QueueReports(ERR_HQUnit unit, float now) { StatusReport(unit, now); Observe(unit, now); ObserveBody(unit, now); Sweep(unit, now); } protected bool WatchTaskValid(ERR_HQTask task) { if (!task || !task.Investigation()) return false; ERR_HQWatchEntry entry = m_Watch.Find(task.WatchSourceId); return entry && !entry.Closed && entry.Investigating && entry.MessageId == task.WatchMessageId && entry.RequestId == task.WatchRequestId; } protected void FinishWatch(ERR_HQTask task, float now) { if (task && task.Investigation()) m_Watch.FinishInvestigation(task.WatchSourceId, task.WatchMessageId, task.WatchRequestId, now); } protected void ReleaseUnit(ERR_HQUnit unit, float now) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); } protected void CancelResolvedInvestigation(ERR_HQUnit unit, float now) { if (unit.PendingTask && unit.PendingTask.Investigation() && !WatchTaskValid(unit.PendingTask)) LostOrder(unit, now); if (!unit.Task || !unit.Task.Investigation() || WatchTaskValid(unit.Task) || unit.Downlink || !unit.Radio) return; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) return; ERR_HQTask cancel = new ERR_HQTask(); cancel.ReleaseOnly = true; cancel.ReplacesTask = unit.Task; cancel.QueuedAt = now; cancel.ExpiresAt = now + 30000; cancel.ApproachBearing = unit.Task.ApproachBearing; cancel.Destination = unit.Task.Destination; unit.PendingTask = cancel; unit.PendingOrder = unit.Assignment.Snapshot(); unit.Downlink = route; route.MessageId = m_Journal.Message(); route.Payload = "cancel resolved missing-patrol investigation; group " + unit.Id.ToString(); m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId); } protected void RequestCheckins(float now) { for (int i = 0; i < m_Watch.Count(); i++) { ERR_HQWatchEntry entry = m_Watch.At(i); if (!m_Watch.MarkRequested(entry.SourceId, entry.MessageId, now)) continue; m_Journal.Write("CHECK-IN OVERDUE", string.Format("%1 missed two check-ins; last reported position=%2, status M%3; cause unknown", entry.SourceId, entry.Position, entry.MessageId)); foreach (ERR_HQUnit unit : m_Units) { if (unit.ForceSourceId != entry.SourceId || unit.Downlink || !unit.Radio) continue; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) break; route.MessageId = m_Journal.Message(); route.Payload = "HQ requests current status from " + entry.SourceId; unit.PendingCheckin = true; unit.Downlink = route; m_Journal.Write("STATUS REQUEST", route.Payload, route.MessageId); break; } } } protected void Investigate(float now, int limit) { if (Assigned() >= limit) return; // One missing-patrol investigation at a time shares the same finite units. foreach (ERR_HQUnit unit : m_Units) if (unit.PlannedTask() && unit.PlannedTask().Investigation()) return; for (int i = 0; i < m_Watch.Count(); i++) { ERR_HQWatchEntry entry = m_Watch.At(i); if (!m_Watch.InvestigationDue(entry.SourceId, now)) continue; ERR_HQUnit best; ERR_HQRoute bestRoute; float distance = m_Mode.Settings().Get("responseRadiusMetres") + 1; int first = -1; int second = -1; foreach (ERR_HQUnit existing : m_Units) { ERR_HQTask planned = existing.PlannedTask(); if (!planned) continue; if (first < 0) first = planned.ApproachBearing; else if (planned.ApproachBearing != first) second = planned.ApproachBearing; } foreach (ERR_HQUnit unit : m_Units) { if (unit.ForceSourceId == entry.SourceId || !unit.Radio || !unit.PlanEligible || unit.PlanFighting || unit.Assignment || unit.PendingOrder || unit.Downlink) continue; if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue; if (unit.HomeGuard && HomeAvailable(unit) <= 1) continue; if (!ERR_HQPursuitRules.BearingAllowed(ERR_HQPursuitRules.Bearing(unit.PlanPosition, entry.Position), first, second)) continue; float candidate = vector.DistanceXZ(unit.PlanPosition, entry.Position); if (candidate >= distance) continue; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) continue; best = unit; bestRoute = route; distance = candidate; } if (!best || !m_Watch.MarkInvestigating(entry.SourceId, entry.MessageId, entry.RequestId, now)) continue; ERR_HQTask task = new ERR_HQTask(); task.WatchSourceId = entry.SourceId; task.WatchMessageId = entry.MessageId; task.WatchRequestId = entry.RequestId; task.Role = ERR_HQPursuitRole.SEARCH; task.Destination = entry.Position; task.QueuedAt = now; task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(task.Role, distance) * 1000; task.ApproachBearing = ERR_HQPursuitRules.Bearing(best.PlanPosition, entry.Position); ERR_HQReport lineage = new ERR_HQReport(); lineage.MessageId = entry.MessageId; lineage.ObservedAt = entry.ObservedAt; lineage.Position = entry.Position; lineage.Distress = true; best.PendingTask = task; best.PendingOrder = lineage; best.Downlink = bestRoute; bestRoute.MessageId = m_Journal.Message(); bestRoute.Payload = string.Format("group %1 investigate %2 at last delivered status position %3; source M%4", best.Id, entry.SourceId, entry.Position, entry.MessageId); m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId); if (best.HomeGuard) m_HomeAvailable.Set(best.HomeId, HomeAvailable(best) - 1); return; } } protected void Accept(ERR_HQReport report, float now) { if (!report || !ERR_MissionRules.ContactFresh(report.ObservedAt, now)) return; // Delivered scalar body evidence survives native corpse cleanup during radio transit. // Memory still applies its own entity-identity rules; casualty accounting needs no live target. if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY && m_Watch.IngestBody(report, now)) m_Bodies.ConfirmDelivered(report.Target); if (!m_Memory.Ingest(report, now)) return; if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position); m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId); // A reported corpse or distress call can justify investigation, never fire. if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return; for (int i = 0; i < m_Contacts.Count(); i++) { ERR_HQReport previous = m_Contacts[i]; if (previous.Target != report.Target) continue; if (previous.ObservedAt >= report.ObservedAt) return; float seconds = (report.ObservedAt - previous.ObservedAt) / 1000; if (seconds > 0 && seconds < 30 && vector.DistanceXZ(previous.Position, report.Position) < 500) { report.Velocity = (report.Position - previous.Position) / seconds; report.HasVelocity = report.Velocity.Length() <= 100; } m_Contacts[i] = report.Snapshot(); return; } if (m_Contacts.Count() >= 64) m_Contacts.Remove(0); m_Contacts.Insert(report.Snapshot()); } // Current combat suspends a sweep without deleting its native queued orders. protected bool Fighting(ERR_HQUnit unit) { return unit.Utility && unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty(); } protected int HomeAvailable(ERR_HQUnit candidate) { return m_HomeAvailable.Get(candidate.HomeId); } protected ERR_HQHuntWindow Hunt(int beliefId) { foreach (ERR_HQHuntWindow known : m_Hunts) if (known.BeliefId == beliefId) return known; if (m_Hunts.Count() >= 64) return null; ERR_HQHuntWindow created = new ERR_HQHuntWindow(); created.BeliefId = beliefId; m_Hunts.Insert(created); return created; } protected void LostOrder(ERR_HQUnit unit, float now) { ERR_HQTask task = unit.PendingTask; unit.CancelPending(); if (!task || task.ReleaseOnly) return; if (task.Investigation()) { FinishWatch(task, now); return; } foreach (ERR_HQUnit other : m_Units) { ERR_HQTask planned = other.PlannedTask(); if (planned && planned.BeliefId == task.BeliefId) return; } // A call that never arrived did not begin a hunt. Retry through the network // after a short backoff, without bypassing another unit's active hunt window. ERR_HQHuntWindow hunt = Hunt(task.BeliefId); if (hunt) hunt.NextHunt = Math.Min(hunt.NextHunt, now + 15000); } protected void DeliverOrder(ERR_HQUnit unit, float now) { ERR_HQReport order = unit.PendingOrder; ERR_HQTask task = unit.PendingTask; if (!order || !task || now >= task.ExpiresAt || !unit.Eligible(m_Mode)) { LostOrder(unit, now); return; } if (task.ReleaseOnly) { if (unit.Task == task.ReplacesTask) { ReleaseUnit(unit, now); m_Journal.Write("ORDER DELIVERED", "resolved investigation released through radio"); } else unit.CancelPending(); return; } if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) { LostOrder(unit, now); return; } if (task.Investigation()) { if (!WatchTaskValid(task)) { LostOrder(unit, now); return; } } else { ERR_HQBelief belief = m_Memory.GetById(task.BeliefId); if (!belief || belief.BeliefRevision != task.BeliefRevision || !ERR_HQPursuitRules.Actionable(belief.Confidence, (now - belief.ObservedAt) / 1000, belief.Radius)) { LostOrder(unit, now); return; } } if (Fighting(unit)) { LostOrder(unit, now); return; } int deliveryId; if (unit.Downlink) deliveryId = unit.Downlink.MessageId; AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(task.Destination))); if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; } unit.Release(); unit.Task = task; unit.Waypoint = waypoint; unit.Assignment = order; unit.Group.AddWaypointAt(waypoint, 0); ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group); if (record) { record.Duty = ERR_ForceDuty.SEARCH; record.DutyTarget = task.Destination; } m_Journal.Write("ORDER DELIVERED", string.Format("group %1 %2 from report M%3; belief=%4 destination=%5", unit.Id, task.Name(), order.MessageId, task.BeliefId, task.Destination), deliveryId); } protected void Sweep(ERR_HQUnit unit, float now) { ERR_HQTask task = unit.Task; if (!task || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return; if (Fighting(unit) || vector.DistanceXZ(unit.Group.GetOrigin(), task.Destination) > 40) { task.ArrivedAt = -1; return; } // Quiet combat state alone does not establish a negative search. Use this // physical group's native perception, also while its uplink is occupied. array samples = {}; ERR_HQObservation.Gather(unit.Utility, now, samples); if (!samples.IsEmpty()) { task.ArrivedAt = -1; return; } if (task.ArrivedAt < 0) task.ArrivedAt = now; if (now - task.ArrivedAt < 30000 || unit.Uplink || !unit.Radio) return; ERR_HQRoute route = m_Network.Route(unit.Radio); if (!route) return; ERR_HQSearchReport report = new ERR_HQSearchReport(); report.SourceId = unit.Id; report.MessageId = m_Journal.Message(); report.WatchSourceId = task.WatchSourceId; report.WatchMessageId = task.WatchMessageId; report.WatchRequestId = task.WatchRequestId; report.BeliefId = task.BeliefId; report.BeliefRevision = task.BeliefRevision; report.Position = task.Destination; report.ObservedAt = now; report.SweepCompleted = true; report.ContactFound = false; route.MessageId = report.MessageId; route.Payload = string.Format("group %1 searched belief %2 at %3; no contact", unit.Id, task.BeliefId, task.Destination); unit.PendingSweep = report; unit.Uplink = route; task.SweepQueued = true; m_Journal.Write("SEARCH REPORT", route.Payload, report.MessageId); } protected void AcceptSweep(ERR_HQSearchReport report, float now) { if (!report) return; if (!report.WatchSourceId.IsEmpty()) { if (!ERR_HQMemoryRules.DeliveryFresh(report.ObservedAt, now) || !report.SweepCompleted || report.ContactFound) return; if (m_Watch.FinishInvestigation(report.WatchSourceId, report.WatchMessageId, report.WatchRequestId, now)) m_Journal.Write("INVESTIGATION", "physical search reported no contact; missing patrol remains unconfirmed: " + report.WatchSourceId, report.MessageId); return; } ERR_HQBelief belief = m_Memory.GetById(report.BeliefId); if (!belief || belief.BeliefRevision != report.BeliefRevision) return; if (!m_Coverage.Ingest(report, now)) return; m_Memory.NegativeSearch(report.BeliefId, report.BeliefRevision, now); m_Journal.Write("AREA SWEPT", string.Format("group %1 reported no contact at %2; belief=%3", report.SourceId, report.Position, report.BeliefId), report.MessageId); } protected vector Containment(ERR_HQBelief belief, vector fallback) { array locations = {}; m_Mode.Locations(locations); float best = -1; vector result = fallback; foreach (ERR_LocationComponent location : locations) { if (!location || (location.Kind() != ERR_LocationKind.RADIO && location.Kind() != ERR_LocationKind.EXTRACTION)) continue; vector objective = location.Position(); if (!ERR_HQDoctrine.Forward(objective, belief.PredictedPosition, belief.Velocity, belief.HasVelocity)) continue; float threat = ERR_HQDoctrine.Threat(objective, belief.Position, belief.Velocity, belief.HasVelocity, belief.Confidence, belief.Radius); if (threat <= best) continue; best = threat; result = ERR_HQDoctrine.Approach(objective, belief.PredictedPosition); } return result; } void MemoryStatus(array replies) { replies.Insert(string.Format("HQ memory: %1 tracks; %2 searched cells. Predictions use delivered reports only.", m_Memory.Count(), m_Coverage.Count())); for (int i = 0; i < m_Memory.Count() && i < 6; i++) { ERR_HQBelief belief = m_Memory.At(i); replies.Insert(string.Format("Track %1: confidence %2%%, uncertainty %3m, age %4s, samples %5; %6", belief.BeliefId, Math.Round(belief.Confidence * 100), Math.Round(belief.Radius), Math.Round((belief.EvaluatedAt - belief.ObservedAt) / 1000), belief.SampleCount, belief.PredictionSource)); } } protected void Retask(float now) { foreach (ERR_HQUnit unit : m_Units) { if (!unit.Assignment || !unit.Task || unit.Task.Investigation() || unit.PendingOrder || unit.Downlink || !unit.Radio || unit.PlanFighting || !unit.PlanEligible) continue; if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue; ERR_HQBelief belief = m_Memory.GetById(unit.Task.BeliefId); if (!belief || belief.BeliefRevision == unit.Task.BeliefRevision || now - belief.ObservedAt > 45000) continue; // Nearby updates refine memory without churning native movement orders. if (vector.DistanceXZ(unit.Assignment.Position, belief.Position) < Math.Max(100, belief.Radius)) continue; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) continue; array reserved = {}; int first = -1; int second = -1; foreach (ERR_HQUnit other : m_Units) { ERR_HQTask planned = other.PlannedTask(); if (other == unit || !planned) continue; reserved.Insert(planned.Destination); if (first < 0) first = planned.ApproachBearing; else if (planned.ApproachBearing != first) second = planned.ApproachBearing; } int bearing = ERR_HQPursuitRules.Bearing(unit.Group.GetOrigin(), belief.PredictedPosition); if (!ERR_HQPursuitRules.BearingAllowed(bearing, first, second)) continue; vector destination; if (!m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue; ERR_HQTask task = new ERR_HQTask(); task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = ERR_HQPursuitRole.SEARCH; task.Destination = destination; task.QueuedAt = now; task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(task.Role, vector.DistanceXZ(unit.PlanPosition, destination)) * 1000; task.ApproachBearing = bearing; unit.PendingTask = task; unit.PendingOrder = belief.Report.Snapshot(); unit.Downlink = route; route.MessageId = m_Journal.Message(); route.Payload = string.Format("group %1 retask from report M%2; belief=%3 destination=%4", unit.Id, belief.MessageId, belief.BeliefId, destination); m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId); ERR_HQHuntWindow hunt = Hunt(belief.BeliefId); if (hunt) hunt.NextHunt = Math.Max(hunt.NextHunt, task.ExpiresAt + 180000); } } // Convoy planning reads delivered own-position status and delivered sightings. // A silent convoy keeps its last order; no target or vehicle position is queried. protected bool ConvoyPositionKnown(ERR_HQUnit unit, float now) { ERR_HQWatchEntry entry = m_Watch.Find(unit.ForceSourceId); return entry && !entry.Closed && entry.Count > 0 && entry.ObservedAt >= 0 && ERR_HQPursuitRules.Finite(now) && now >= entry.ObservedAt && now - entry.ObservedAt <= 180000; } protected ERR_HQReport ConvoyContact(ERR_HQUnit unit, float now) { if (!ConvoyPositionKnown(unit, now)) return null; ERR_HQWatchEntry entry = m_Watch.Find(unit.ForceSourceId); ERR_HQReport best; foreach (ERR_HQReport report : m_Contacts) { if (report.ObservedAt < 0 || now < report.ObservedAt || now - report.ObservedAt >= 60000 || vector.DistanceXZ(entry.Position, report.Position) > 400) continue; if (!best || report.ObservedAt > best.ObservedAt || (report.ObservedAt == best.ObservedAt && report.MessageId > best.MessageId)) best = report; } return best; } protected void PlanConvoy(float now) { if (!m_ConvoyTask || m_ConvoyTask.Stopped || !m_ConvoyTask.Ready) return; foreach (ERR_ConvoyVehicle vehicle : m_ConvoyTask.Vehicles) { if (vehicle.Released || vehicle.Arrived || !vehicle.Registered || !vehicle.Group) continue; foreach (ERR_HQUnit unit : m_Units) { if (unit.Group != vehicle.Group || !unit.ReservedForSupport || unit.Downlink || !unit.Radio) continue; if (!ConvoyPositionKnown(unit, now)) break; ERR_HQReport contact = ConvoyContact(unit, now); if (contact) { if (vehicle.Combat.Held && contact.MessageId == vehicle.Combat.ReportId) break; if (contact.ObservedAt < vehicle.Combat.ObservedAt) break; } else if (!vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) break; ERR_ConvoyCombatOrder order = new ERR_ConvoyCombatOrder(); order.CommandId = m_Journal.Message(); order.SourceId = m_ConvoyTask.Receipt.SourceId; order.VehicleIndex = vehicle.Index; order.Group = unit.Group; order.Hold = contact != null; order.IssuedAt = now; order.ExpiresAt = now + 30000; order.ReportId = vehicle.Combat.ReportId; order.ObservedAt = vehicle.Combat.ObservedAt; string action = "resume authored route"; if (contact) { order.ReportId = contact.MessageId; order.ObservedAt = contact.ObservedAt; action = "suspend travel for mounted cover"; } route.MessageId = order.CommandId; route.Payload = string.Format("convoy vehicle %1: %2; delivered sighting M%3", vehicle.Index, action, order.ReportId); unit.PendingConvoyOrder = order; unit.Downlink = route; m_Journal.Write("CONVOY ORDER QUEUED", route.Payload, route.MessageId); break; } } } protected void DeliverConvoyOrder(ERR_HQUnit unit, float now) { ERR_ConvoyCombatOrder order = unit.PendingConvoyOrder; bool accepted; if (order && order.Group == unit.Group && ConvoyPositionKnown(unit, now)) { ERR_HQReport contact = ConvoyContact(unit, now); // A newer nearby sighting still justifies HOLD: frequent reports must not // starve a slower radio downlink. New threat evidence always invalidates RESUME. bool current = !order.Hold && !contact; if (order.Hold && contact) current = contact.ObservedAt >= order.ObservedAt; if (current) accepted = m_Convoys.ApplyCombatOrder(m_ConvoyTask, order, now); } if (accepted) m_Journal.Write("CONVOY ORDER DELIVERED", "original crew applied radio travel command", order.CommandId); else if (order) m_Journal.Write("CONVOY ORDER REJECTED", "stale evidence or unavailable original crew; current order retained", order.CommandId); unit.CancelPending(); } protected void Assign(float now) { int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Get("supportSquadLimit")); int limit = ERR_HQPursuitRules.Limit(globalLimit); int radius = m_Mode.Settings().Get("responseRadiusMetres"); int assigned = Assigned(); // Snapshot bounded own-force eligibility once per five-second planning pass. m_PlanBeliefs.Clear(); for (int index = 0; index < m_Memory.Count(); index++) m_PlanBeliefs.Insert(m_Memory.At(index)); foreach (ERR_HQUnit unit : m_Units) { unit.PlanEligible = unit.Eligible(m_Mode); unit.PlanFighting = Fighting(unit); if (unit.Group) unit.PlanPosition = unit.Group.GetOrigin(); } for (int h = m_Hunts.Count() - 1; h >= 0; h--) if (!m_Memory.GetById(m_Hunts[h].BeliefId)) m_Hunts.Remove(h); foreach (ERR_HQUnit unit : m_Units) { if (!unit.Assignment && !unit.PendingOrder) continue; if (!unit.PlanEligible || assigned > globalLimit) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); assigned--; continue; } if (unit.Task && now >= unit.Task.ExpiresAt) { FinishWatch(unit.Task, now); unit.ReleaseAssignment(); } if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) { FinishWatch(unit.PendingTask, now); unit.CancelPending(); } if (unit.Task && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment(); if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending(); } assigned = Assigned(); m_HomeAvailable.Clear(); foreach (ERR_HQUnit unit : m_Units) if (unit.HomeGuard && unit.PlanEligible && !unit.Assignment && !unit.PendingOrder) m_HomeAvailable.Set(unit.HomeId, m_HomeAvailable.Get(unit.HomeId) + 1); foreach (ERR_HQUnit unit : m_Units) CancelResolvedInvestigation(unit, now); RequestCheckins(now); Retask(now); Investigate(now, globalLimit); assigned = HuntingAssigned(); array reserved = {}; int first = -1; int second = -1; foreach (ERR_HQUnit unit : m_Units) { ERR_HQTask planned = unit.PlannedTask(); if (!planned || (!unit.Assignment && !unit.PendingOrder)) continue; reserved.Insert(planned.Destination); int bearing = planned.ApproachBearing; if (first < 0) first = bearing; else if (bearing != first && second < 0) second = bearing; } // Strongest delivered track first; finite scans (64 beliefs,128 own units). array considered = {}; for (int pass = 0; pass < m_PlanBeliefs.Count() && assigned < limit && Assigned() < globalLimit; pass++) { ERR_HQBelief belief; for (int i = 0; i < m_PlanBeliefs.Count(); i++) { ERR_HQBelief candidate = m_PlanBeliefs[i]; if (!candidate || considered.Contains(candidate.BeliefId)) continue; float age = (now - candidate.ObservedAt) / 1000; if (!ERR_HQPursuitRules.Actionable(candidate.Confidence, age, candidate.Radius)) continue; if (!belief || candidate.Confidence > belief.Confidence) belief = candidate; } if (!belief) break; considered.Insert(belief.BeliefId); ERR_HQHuntWindow hunt = Hunt(belief.BeliefId); if (!hunt || now < hunt.NextHunt) continue; for (int slot = 0; slot < limit && assigned < limit && Assigned() < globalLimit; slot++) { float age = (now - belief.ObservedAt) / 1000; ERR_HQPursuitRole role = ERR_HQPursuitRules.Role(belief.Confidence, age, belief.Radius, belief.HasVelocity, slot); vector destination = ERR_HQPursuitRules.Objective(belief.PredictedPosition, belief.Velocity, belief.HasVelocity, belief.Confidence, age, belief.Radius, role, slot); if (role == ERR_HQPursuitRole.CONTAIN) destination = Containment(belief, destination); if (role == ERR_HQPursuitRole.SEARCH && !m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue; ERR_HQUnit best; ERR_HQRoute bestRoute; float bestScore = -1; int bestId; int bestBearing = -1; foreach (ERR_HQUnit unit : m_Units) { if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue; bool busy = unit.Assignment || unit.PendingOrder || unit.Downlink || unit.PlanFighting; if (!unit.Radio || !ERR_HQPursuitRules.CanCommit(unit.Group != null, unit.PlanEligible, busy, unit.HomeGuard, HomeAvailable(unit), assigned, limit)) continue; vector position = unit.PlanPosition; if (vector.DistanceXZ(position, belief.Position) > radius) continue; int bearing = ERR_HQPursuitRules.Bearing(position, belief.PredictedPosition); if (!ERR_HQPursuitRules.BearingAllowed(bearing, first, second)) continue; float score = ERR_HQPursuitRules.Score(role, belief.PredictedPosition, belief.Velocity, position, destination); if (!ERR_HQPursuitRules.Better(score, unit.Id, bestScore, bestId)) continue; ERR_HQRoute route = m_Network.Route(unit.Radio, true); if (!route) continue; best = unit; bestRoute = route; bestScore = score; bestId = unit.Id; bestBearing = bearing; } if (!best) continue; ERR_HQTask task = new ERR_HQTask(); task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = role; task.Destination = destination; task.QueuedAt = now; task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(role, vector.DistanceXZ(best.PlanPosition, destination)) * 1000; task.ApproachBearing = bestBearing; best.PendingTask = task; best.PendingOrder = belief.Report.Snapshot(); best.Downlink = bestRoute; bestRoute.MessageId = m_Journal.Message(); bestRoute.Payload = string.Format("group %1 %2; belief=%3 from report M%4; destination=%5", best.Id, task.Name(), task.BeliefId, belief.MessageId, destination); m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId); reserved.Insert(destination); if (first < 0) first = bestBearing; else if (bestBearing != first && second < 0) second = bestBearing; if (best.HomeGuard) m_HomeAvailable.Set(best.HomeId, HomeAvailable(best) - 1); hunt.NextHunt = Math.Max(hunt.NextHunt, task.ExpiresAt + 180000); assigned++; } } } override void Tick(float now) { if (!Replication.IsServer()) return; m_Memory.Tick(now); m_Equipment.Tick(now); m_Convoys.Tick(now); m_Bodies.TickBudget(now); if (now >= m_NextNetwork) { IndexRadios(); m_NextNetwork = now + 5000; } for (int i = m_Contacts.Count() - 1; i >= 0; i--) if (!ERR_MissionRules.ContactFresh(m_Contacts[i].ObservedAt, now)) m_Contacts.Remove(i); for (int i = m_Units.Count() - 1; i >= 0; i--) { ERR_HQUnit unit = m_Units[i]; if (unit.Uplink) { int delivered = unit.Uplink.Advance(now, m_Journal); if (delivered == 1) { if (unit.PendingStatus) m_Watch.Ingest(unit.PendingStatus, now); else if (unit.PendingSweep) AcceptSweep(unit.PendingSweep, now); else if (unit.PendingSupplyStatus) m_Logistics.Receive(unit.PendingSupplyStatus, now); else Accept(unit.PendingReport, now); } if (delivered < 0 && unit.PendingSweep && unit.Task && unit.PendingSweep.BeliefId == unit.Task.BeliefId && unit.PendingSweep.BeliefRevision == unit.Task.BeliefRevision && unit.PendingSweep.WatchSourceId == unit.Task.WatchSourceId && unit.PendingSweep.WatchMessageId == unit.Task.WatchMessageId && unit.PendingSweep.WatchRequestId == unit.Task.WatchRequestId) unit.Task.SweepQueued = false; if (delivered != 0) { unit.Uplink = null; unit.PendingReport = null; unit.PendingSupplyStatus = null; unit.PendingSweep = null; unit.PendingStatus = null; } } if (unit.Downlink) { int delivered = unit.Downlink.Advance(now, m_Journal); if (delivered == 1) { if (unit.PendingConvoyOrder) DeliverConvoyOrder(unit, now); else if (unit.PendingCheckin) { unit.NextStatus = now; unit.CancelPending(); } else DeliverOrder(unit, now); } else if (delivered < 0) LostOrder(unit, now); } if (!unit.Group && !unit.Uplink) { ReleaseUnit(unit, now); m_Units.Remove(i); continue; } QueueReports(unit, now); } if (now >= m_NextAssignment) { PlanConvoy(now); Assign(now); m_NextAssignment = now + 5000; } m_Logistics.Tick(now); m_Mortars.Tick(now); m_Journal.Flush(); } override void Stop() { m_Convoys.Stop(); m_Equipment.Stop(); m_Logistics.Stop(); m_Mortars.Stop(); foreach (ERR_HQUnit unit : m_Units) unit.Release(); m_Units.Clear(); m_Contacts.Clear(); m_Memory.Clear(); m_Coverage.Clear(); m_Hunts.Clear(); m_Watch.Clear(); m_Bodies.Stop(); m_PlanBeliefs.Clear(); m_HomeAvailable.Clear(); m_Network.Clear(); m_Journal.Write("STOP", "HQ stopped; tactical orders released"); m_Journal.Flush(); } } modded class ERR_GameMode { protected override ERR_CommandService CreateCommandService() { ERR_HQ hq = new ERR_HQ(this); Admin().Register(new ERR_HQAdminProvider(hq)); return hq; } }