Files
EscapistsReintroduced/addons/ai/Scripts/Game/ERR/ERR_HQ.c
T

937 lines
40 KiB
C

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;
bool Eligible(ERR_GameMode mode)
{
if (ReservedForSupport || LogisticsReservation > 0 || !Group || !Utility || Group.GetPlayerCount(true) > 0 || Group.IsSlave() || Group.GetFaction() != mode.EnemyFaction()) return false;
array<AIAgent> 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;
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<vector> m_ConvoyPositions = {};
protected ref array<vector> 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<ref ERR_HQUnit> m_Units = {};
protected ref array<ref ERR_HQReport> m_Contacts = {};
protected ref ERR_HQMemory m_Memory = new ERR_HQMemory();
protected ref ERR_HQCoverage m_Coverage = new ERR_HQCoverage();
protected ref array<ref ERR_HQHuntWindow> m_Hunts = {};
protected ref array<ref ERR_HQBelief> m_PlanBeliefs = {};
protected ref map<string, int> m_HomeAvailable = new map<string, int>();
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<IEntity> entities)
{
entities.Clear();
foreach (ERR_HQUnit unit : m_Units)
{
if (!unit.Group) continue;
array<AIAgent> 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<ref ERR_HQReport> 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);
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<AIAgent> 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<ref ERR_HQVisualContact> 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);
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 (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 (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<ref ERR_HQVisualContact> 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<ERR_LocationComponent> 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<string> 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.Radio || unit.PlanFighting || !unit.PlanEligible) 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<vector> 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);
}
}
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<vector> 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<int> 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)
{
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.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) { 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;
}
}