Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
1796 lines
84 KiB
C
1796 lines
84 KiB
C
class ERR_HQUnit
|
|
{
|
|
int Id;
|
|
SCR_AIGroup Group;
|
|
SCR_AIGroupUtilityComponent Utility;
|
|
SCR_AIGroupPerception Detection;
|
|
float NextDetectionLog;
|
|
ref ERR_HQLocalAwareness LocalAwareness = new ERR_HQLocalAwareness();
|
|
float NextContact;
|
|
ref ERR_InfantryCombat InfantryCombat = new ERR_InfantryCombat();
|
|
IEntity Radio;
|
|
float NextReport;
|
|
ref ERR_HQRoute Uplink;
|
|
ref ERR_HQReport PendingReport;
|
|
ref ERR_HQReportSelection ReportSelection = new ERR_HQReportSelection();
|
|
ref ERR_HQObservationHistory SentObservations = new ERR_HQObservationHistory();
|
|
ref ERR_HQRoute Downlink;
|
|
ref ERR_HQReport PendingOrder;
|
|
ref ERR_HQReport Assignment;
|
|
AIWaypoint Waypoint;
|
|
ref ERR_HQTask Task;
|
|
ref ERR_HQTask PendingTask;
|
|
ref ERR_EngineerMine EngineerMine;
|
|
ref ERR_HQSearchReport PendingSweep;
|
|
ref ERR_HQStatusReport PendingStatus;
|
|
ref ERR_HQStatusSampler StatusSampler = new ERR_HQStatusSampler();
|
|
ref ERR_HQAmmoReporting AmmoReporting = new ERR_HQAmmoReporting();
|
|
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<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;
|
|
PendingConvoyOrder = null;
|
|
PendingCheckin = false;
|
|
PendingTask = null;
|
|
Downlink = null;
|
|
}
|
|
ERR_HQTask PlannedTask()
|
|
{
|
|
if (PendingTask) return PendingTask;
|
|
return Task;
|
|
}
|
|
void ReleaseAssignment()
|
|
{
|
|
if (EngineerMine) EngineerMine.Stop();
|
|
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 ref ERR_AttackSpace m_AttackSpace = new ERR_AttackSpace();
|
|
ERR_AttackSpace AttackSpace() { return m_AttackSpace; }
|
|
protected void ReserveAttackSpace(ERR_HQUnit unit, float now)
|
|
{
|
|
if (unit.PendingTask) m_AttackSpace.Claim(unit.Group, unit.PendingTask.Destination, now);
|
|
else if (unit.Task) m_AttackSpace.Claim(unit.Group, unit.Task.Destination, now);
|
|
}
|
|
|
|
protected ERR_GameMode m_Mode;
|
|
protected ref ERR_MortarSystem m_Mortars;
|
|
protected ref ERR_EnemyEquipment m_Equipment;
|
|
protected ref ERR_HunterService m_Hunters;
|
|
protected ref ERR_ObjectiveDefence m_ObjectiveDefence;
|
|
protected ref ERR_TownGarrisons m_Towns;
|
|
protected ref ERR_Roadblocks m_Roadblocks;
|
|
ERR_Roadblocks Roadblocks() { return m_Roadblocks; }
|
|
protected ref array<SCR_AICombatComponent> m_SpecialSkills = {};
|
|
ERR_HunterService Hunters() { return m_Hunters; }
|
|
protected ref ERR_ScoutSystem m_Scouts;
|
|
protected ref ERR_AirfieldSupport m_Airfield;
|
|
ERR_AirfieldSupport Airfield() { return m_Airfield; }
|
|
protected ref ERR_BodyRecovery m_Recovery;
|
|
ERR_BodyRecovery Recovery() { return m_Recovery; }
|
|
protected ref ERR_AirAssaultSystem m_AirAssault;
|
|
ERR_AirAssaultSystem AirAssault() { return m_AirAssault; }
|
|
protected ref ERR_AirReconSystem m_Recon;
|
|
ERR_AirReconSystem Recon() { return m_Recon; }
|
|
ERR_ScoutSystem Scouts() { return m_Scouts; }
|
|
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 ERR_InfantrySupplyPool m_Resupply;
|
|
protected bool m_Stopping;
|
|
protected int m_NextResupply;
|
|
protected ref ERR_ConvoyStartup m_ConvoyStartup;
|
|
protected ref ERR_SupplyConvoySystem m_SupplyConvoys;
|
|
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<IEntity> m_EngineerMineAttempts = {};
|
|
protected ref array<ref ERR_EngineerMine> m_EngineerMineJobs = {};
|
|
protected ref ERR_EngineerMinePosts m_EngineerMinePosts = new ERR_EngineerMinePosts();
|
|
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_Hunters = new ERR_HunterService(mode, null);
|
|
m_Hunters.ConfigureOpening(mode.Settings().Get("hunterSpawnDistanceMetres"));
|
|
m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this);
|
|
m_Towns = new ERR_TownGarrisons(mode);
|
|
m_Roadblocks = new ERR_Roadblocks(mode);
|
|
m_Scouts = new ERR_ScoutSystem(mode);
|
|
m_Recon = new ERR_AirReconSystem(mode);
|
|
m_AirAssault = new ERR_AirAssaultSystem(this, mode);
|
|
m_Airfield = new ERR_AirfieldSupport(this, mode); m_Recovery = new ERR_BodyRecovery(this, mode);
|
|
bool legacyFixture;
|
|
#ifdef WORKBENCH
|
|
// These pre-existing diagnostic scenarios specifically exercise the retained
|
|
// finite-route controller. Ordinary Workbench sessions use the full fleet.
|
|
string fixtureEnabled, fixtureOutcome;
|
|
legacyFixture = System.GetCLIParam("errSmoke", fixtureEnabled) && fixtureEnabled == "1"
|
|
&& System.GetCLIParam("errSmokeOutcome", fixtureOutcome) && (fixtureOutcome == "convoy" || fixtureOutcome == "hq-memory");
|
|
#endif
|
|
if (legacyFixture)
|
|
{
|
|
m_ConvoyPool = new ERR_ConvoyPool(mode, 1);
|
|
m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool);
|
|
}
|
|
else
|
|
{
|
|
int convoyCount = mode.Settings().Runtime("convoyCount");
|
|
m_ConvoyPool = new ERR_ConvoyPool(mode, convoyCount, true);
|
|
ERR_SupplyConvoyService supply = new ERR_SupplyConvoyService(mode, m_ConvoyPool);
|
|
m_Convoys = supply;
|
|
m_SupplyConvoys = new ERR_SupplyConvoySystem(mode, m_ConvoyPool, supply, convoyCount, mode.Settings().Get("automaticConvoyEnabled") == 1);
|
|
}
|
|
m_Mortars = new ERR_MortarSystem(this);
|
|
m_Logistics = new ERR_MortarLogistics(this, mode);
|
|
m_Resupply = new ERR_InfantrySupplyPool(this, mode);
|
|
m_ConvoyStartup = new ERR_ConvoyStartup(mode.Settings().Get("automaticConvoyEnabled") == 1);
|
|
}
|
|
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_ConvoyStartup ConvoyStartup() { return m_ConvoyStartup; }
|
|
ERR_ConvoyPool ConvoyPool() { return m_ConvoyPool; }
|
|
ERR_ConvoyService Convoys() { return m_Convoys; }
|
|
ERR_ConvoyTask ConvoyTask() { return m_ConvoyTask; }
|
|
ERR_SupplyConvoySystem SupplyConvoys() { return m_SupplyConvoys; }
|
|
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;
|
|
}
|
|
protected bool ConvoyAdmissionReady()
|
|
{
|
|
if (m_Units.IsEmpty() || !m_Mode.Forces()) return false;
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
ERR_MortarBattery support = m_Mortars.FindGroup(unit.Group);
|
|
// Failed optional support is terminal, not an outstanding force admission.
|
|
if (support && (support.Stopped() || !support.SpawnFailure.IsEmpty())) continue;
|
|
if (unit.Group && !m_Mode.Forces().FindGroup(unit.Group)) return false;
|
|
}
|
|
array<ref ERR_MortarBattery> batteries = {}; m_Mortars.Batteries(batteries);
|
|
foreach (ERR_MortarBattery battery : batteries)
|
|
if (battery && !battery.Stopped() && battery.SpawnFailure.IsEmpty() && !battery.StartupActivated) return false;
|
|
return true;
|
|
}
|
|
protected void AutomaticConvoy(float now)
|
|
{
|
|
if (!Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
|
|
if (m_SupplyConvoys)
|
|
{
|
|
m_SupplyConvoys.Tick(now, ConvoyAdmissionReady());
|
|
if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0);
|
|
return;
|
|
}
|
|
if (!m_ConvoyStartup || m_ConvoyStartup.Finished()) return;
|
|
string previous = m_ConvoyStartup.Status();
|
|
if (m_ConvoyStartup.Decide(now, m_ConvoyTask != null, ConvoyAdmissionReady()))
|
|
m_ConvoyStartup.Complete(StartScenarioConvoy());
|
|
if (previous != m_ConvoyStartup.Status())
|
|
m_Journal.Write("CONVOY STARTUP", m_ConvoyStartup.Status() + "; " + m_ConvoyFailure);
|
|
}
|
|
bool StartScenarioConvoy()
|
|
{
|
|
if (m_SupplyConvoys)
|
|
{
|
|
if (!ConvoyAdmissionReady()) return false;
|
|
bool started = m_SupplyConvoys.StartOne();
|
|
if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0);
|
|
return started;
|
|
}
|
|
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_SupplyConvoys) detail += "; " + m_SupplyConvoys.Status();
|
|
else if (m_ConvoyStartup) detail += "; automatic startup: " + m_ConvoyStartup.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;
|
|
m_Journal.Write("ROLE RESERVED", string.Format("group=%1 force=%2 home=%3; excluded from general HQ retasking", unit.Id, unit.ForceSourceId, unit.HomeId));
|
|
}
|
|
}
|
|
void Dedicate(SCR_AIGroup group, string siteId)
|
|
{
|
|
ReserveGroup(group);
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
if (unit.Group == group) { unit.HomeId = siteId; unit.HomeGuard = true; }
|
|
}
|
|
protected void Detected(SCR_AIGroup group, SCR_AITargetInfo target)
|
|
{
|
|
if (!Replication.IsServer() || !m_Mode.Active() || m_Mode.Phase() <= ERR_Phase.PRISON || !group || !target || !target.m_Entity) return;
|
|
float now = GetGame().GetWorld().GetWorldTime();
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit.Group != group) continue;
|
|
unit.NextReport = 0;
|
|
// Native combat reacts locally now; remote orders still require radio delivery.
|
|
group.ActivateAI();
|
|
if (now >= unit.NextDetectionLog)
|
|
{
|
|
unit.NextDetectionLog = now + 10000;
|
|
m_Journal.Write("CONTACT LOCAL", string.Format("group=%1 force=%2 observed=%3 home=%4 dedicated=%5; native combat active, radio report due", unit.Id, unit.ForceSourceId, target.m_vWorldPos, unit.HomeId, unit.ReservedForSupport));
|
|
}
|
|
}
|
|
}
|
|
// Temporary reservations never alter the permanent support reservation.
|
|
void ReleaseMortarCrew(SCR_AIGroup group)
|
|
{
|
|
ERR_MortarBattery battery = m_Mortars.FindGroup(group);
|
|
if (!battery) return;
|
|
if (battery.Gun)
|
|
{
|
|
DamageManagerComponent damage = DamageManagerComponent.Cast(battery.Gun.FindComponent(DamageManagerComponent));
|
|
if (!damage || !damage.IsDestroyed()) return;
|
|
}
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
if (unit.Group == group) unit.ReservedForSupport = false;
|
|
}
|
|
int LogisticsToken(SCR_AIGroup group)
|
|
{
|
|
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.LogisticsReservation;
|
|
return 0;
|
|
}
|
|
bool MineSupplyReady(SCR_AIGroup group, IEntity actor, bool replacement = false)
|
|
{
|
|
return Replication.IsServer() && MissionActive() && m_Equipment && m_Equipment.MineSupplyReady(group, actor, replacement);
|
|
}
|
|
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;
|
|
}
|
|
// Unlike foot-patrol reservations, this pair permits the original seated
|
|
// support crews. Validate both first; never overwrite either existing claim.
|
|
protected bool MountedLogisticsEligible(ERR_HQUnit unit)
|
|
{
|
|
if (!unit || !m_Mode || !m_Mode.Forces() || !unit.Group || !unit.Utility || !unit.Radio || !unit.StatusSampler) return false;
|
|
if (!m_Units.Contains(unit) || !unit.ReservedForSupport || unit.HomeGuard || unit.LogisticsReservation != 0) return false;
|
|
if (unit.Assignment || unit.Task || unit.PendingTask || unit.PendingOrder || unit.PendingConvoyOrder || unit.PendingCheckin
|
|
|| unit.Downlink || unit.Waypoint) return false;
|
|
if (unit.Group.IsSlave() || unit.Group.GetPlayerCount(true) > 0 || unit.Group.GetFaction() != m_Mode.EnemyFaction()
|
|
|| !unit.StatusSampler.OwnsRoster(unit.Group)) return false;
|
|
if (unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty()) return false;
|
|
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
|
|
return record && record.Group == unit.Group && record.SourceId == unit.ForceSourceId
|
|
&& record.Kind == ERR_ForceKind.CONVOY && record.Physical && !record.Eliminated
|
|
&& unit.StatusSampler.InitialSourceId() == unit.ForceSourceId;
|
|
}
|
|
bool ReserveMountedLogistics(ERR_HQUnit donor, ERR_HQUnit recipient, int token)
|
|
{
|
|
if (!Replication.IsServer() || m_Stopping || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()
|
|
|| token <= 200000 || token > 202560 || donor == recipient || !MountedLogisticsEligible(donor)
|
|
|| !MountedLogisticsEligible(recipient) || donor.Group == recipient.Group) return false;
|
|
donor.LogisticsReservation = token; recipient.LogisticsReservation = token;
|
|
return true;
|
|
}
|
|
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 || OriginOccupied(unit.Radio, unit)) 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());
|
|
}
|
|
// A ground search takes longer than a mortar fire solution remains valid.
|
|
// Read delivered memory, including its decaying confidence/uncertainty, never
|
|
// the target entity's current position. A five-minute old sector is the limit.
|
|
void HunterContacts(out array<ref ERR_HQReport> contacts, float now)
|
|
{
|
|
contacts.Clear();
|
|
for (int index = 0; index < m_Memory.Count(); index++)
|
|
{
|
|
ERR_HQBelief belief = m_Memory.At(index);
|
|
if (!belief || !belief.Report || !belief.Actionable() || belief.TravelMode == "aircraft" || belief.Radius > 500) continue;
|
|
if (belief.ObservedAt < 0 || now < belief.ObservedAt || now - belief.ObservedAt > 300000) continue;
|
|
contacts.Insert(belief.Report.Snapshot());
|
|
}
|
|
}
|
|
|
|
ERR_HQWatch Watch() { return m_Watch; }
|
|
ERR_InfantryResupply Resupply()
|
|
{
|
|
ERR_InfantryResupply first;
|
|
if (m_Resupply) first = m_Resupply.First();
|
|
return first;
|
|
}
|
|
ERR_HQMemory Memory() { return m_Memory; }
|
|
ERR_HQCoverage Coverage() { return m_Coverage; }
|
|
ERR_HQJournal Journal() { return m_Journal; }
|
|
string Status()
|
|
{
|
|
return string.Format("Enemy HQ: %1 registered groups; %2 received sightings; %3 assigned responses.", m_Units.Count(), m_Contacts.Count(), Assigned())
|
|
+ string.Format("\nHQ memory: %1 tracked reports; %2 searched areas.\nAggression: %3 of 30, based on %4 identified bodies (one level per three bodies).", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies())
|
|
+ "\n" + m_Hunters.Status() + "\n" + m_Mode.Forces().Status() + "\n" + m_Mortars.Status()
|
|
+ "\n" + m_Logistics.Status() + "\n" + m_Resupply.Status() + "\n" + m_Recon.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);
|
|
}
|
|
// Admission supplies the original physical root; HQ never discovers a replacement.
|
|
bool BindVehicle(SCR_AIGroup group, IEntity vehicle)
|
|
{
|
|
if (!Replication.IsServer() || !m_Mode || !group || !vehicle) return false;
|
|
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
|
|
if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY) return false;
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit.Group != group) continue;
|
|
BindPhysical(unit);
|
|
return unit.StatusSampler.BindVehicle(vehicle);
|
|
}
|
|
return false;
|
|
}
|
|
// Read-only lookup of an already admitted original root; never first-binds it.
|
|
bool ValidateBoundVehicle(SCR_AIGroup group, IEntity vehicle)
|
|
{
|
|
if (!Replication.IsServer() || !m_Mode || !group || !vehicle) return false;
|
|
for (int i = 0; i < m_Units.Count() && i < 256; i++)
|
|
{
|
|
ERR_HQUnit unit = m_Units[i];
|
|
if (unit && unit.Group == group && unit.StatusSampler)
|
|
return unit.StatusSampler.OwnsRoster(group) && unit.StatusSampler.ValidateBoundVehicle(vehicle);
|
|
}
|
|
return false;
|
|
}
|
|
protected void BindSpecialSkills(SCR_AIGroup group)
|
|
{
|
|
array<AIAgent> specialists = {}; group.GetAgents(specialists);
|
|
foreach (AIAgent specialist : specialists)
|
|
{
|
|
if (!specialist || !specialist.GetControlledEntity()) continue;
|
|
SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(specialist.GetControlledEntity().FindComponent(SCR_AICombatComponent));
|
|
if (!combat) continue;
|
|
combat.ERR_BindEnemySkill(m_Mode, group);
|
|
if (!m_SpecialSkills.Contains(combat)) m_SpecialSkills.Insert(combat);
|
|
}
|
|
}
|
|
protected void AuditGroup(ERR_HQUnit unit)
|
|
{
|
|
SCR_AIGroup group = unit.Group;
|
|
string factionKey;
|
|
if (group.GetFaction()) factionKey = group.GetFaction().GetFactionKey();
|
|
m_Journal.Write("REGISTER", string.Format("group=%1 force=%2 faction=%3 position=%4 members=%5 home=%6 dedicated=%7; policy=maximum skill/regular aim with ten percent less dispersion/alert recognition", unit.Id, unit.ForceSourceId, factionKey, group.GetOrigin(), m_Mode.ForceLiving(group), unit.HomeId, unit.ReservedForSupport));
|
|
array<AIAgent> auditAgents = {}; group.GetAgents(auditAgents);
|
|
foreach (AIAgent auditAgent : auditAgents)
|
|
{
|
|
if (!auditAgent || !auditAgent.GetControlledEntity()) continue;
|
|
IEntity actor = auditAgent.GetControlledEntity();
|
|
string prefab; if (actor.GetPrefabData()) prefab = actor.GetPrefabData().GetPrefabName();
|
|
m_Journal.Write("SPAWN MEMBER", string.Format("group=%1 force=%2 actor=%3 prefab=%4 position=%5", unit.Id, unit.ForceSourceId, actor, prefab, actor.GetOrigin()));
|
|
}
|
|
}
|
|
|
|
override void RegisterGroup(SCR_AIGroup group)
|
|
{
|
|
if (!Replication.IsServer() || !group) return;
|
|
// Other hostile factions retain their own intelligence, but receive the same
|
|
// scoped skill/aim profile. Do not share enemy HQ knowledge across hostile sides.
|
|
if (m_Mode.IsMissionEnemy(group.GetFaction()) && group.GetFaction() != m_Mode.EnemyFaction()) m_Equipment.RegisterGroup(group, 0, false);
|
|
foreach (ERR_HQUnit known : m_Units) if (known.Group == group) { BindPhysical(known); return; }
|
|
if (m_Units.Count() >= 256) 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;
|
|
// Objective security is permanent; it can report contacts but cannot be tasked
|
|
// with searches, reinforcements, resupply or courier work away from its site.
|
|
unit.ReservedForSupport = group == home.Group && ERR_ObjectiveDefence.Objective(home.Definition.Kind);
|
|
unit.HomeGuard = group == home.Group && (!home.Definition.Patrol || home.Definition.Patrol.IsEmpty());
|
|
}
|
|
int phase = unit.Id % 5;
|
|
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
|
|
m_Units.Insert(unit);
|
|
// Scouts keep their observation post and authored sniper/radio loadout.
|
|
if ((m_Scouts && m_Scouts.Contains(group)) || (m_Recon && m_Recon.Owns(group)) || (m_Airfield && m_Airfield.Cargo() && m_Airfield.Cargo().Crew() == group))
|
|
{
|
|
unit.ReservedForSupport = true;
|
|
BindSpecialSkills(group);
|
|
}
|
|
else
|
|
{
|
|
int equipmentRadioId = unit.Id;
|
|
// Widely separated HQ hunters each need a physical manpack uplink.
|
|
if (m_Hunters && m_Hunters.Contains(group)) equipmentRadioId = 1;
|
|
if (m_Towns && m_Towns.Contains(group)) equipmentRadioId = 1;
|
|
if (m_Roadblocks && m_Roadblocks.Contains(group)) equipmentRadioId = 1;
|
|
m_Equipment.RegisterGroup(group, equipmentRadioId, !m_Mortars || !m_Mortars.FindGroup(group));
|
|
}
|
|
BindPhysical(unit);
|
|
unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000;
|
|
unit.AmmoReporting.Initialize(unit.Id, GetGame().GetWorld().GetWorldTime());
|
|
AuditGroup(unit);
|
|
}
|
|
|
|
// End only an admitted convoy's dedicated travel role. Keep its original
|
|
// reporter, equipment state and finite roster; never register a replacement.
|
|
bool ReleaseConvoySurvivors(SCR_AIGroup group)
|
|
{
|
|
if (!Replication.IsServer() || m_Stopping || !m_Mode || m_Mode != ERR_GameMode.Get()) return false;
|
|
if (!m_Mode.Active() || m_Mode.Command() != this || !m_Mode.Forces() || !group) return false;
|
|
if (group.GetPlayerCount(true) > 0 || group.IsSlave() || group.GetFaction() != m_Mode.EnemyFaction()) return false;
|
|
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
|
|
if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY) return false;
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit.Group != group) continue;
|
|
if (!unit.Utility || unit.ForceSourceId != record.SourceId || !unit.StatusSampler) return false;
|
|
if (!unit.StatusSampler.OwnsRoster(group) || unit.StatusSampler.InitialSourceId() != record.SourceId) return false;
|
|
int count = unit.StatusSampler.InitialStrength();
|
|
if (count < 1 || count > 32 || count != record.InitialStrength) return false;
|
|
array<AIAgent> agents = {}; group.GetAgents(agents);
|
|
if (agents.Count() > 32) return false;
|
|
array<IEntity> present = {};
|
|
foreach (AIAgent agent : agents)
|
|
{
|
|
if (!agent || !agent.GetControlledEntity()) return false;
|
|
IEntity member = agent.GetControlledEntity();
|
|
if (ERR_Actors.Player(member) || present.Contains(member)) return false;
|
|
present.Insert(member);
|
|
}
|
|
array<IEntity> originals = {};
|
|
bool living = false;
|
|
for (int slot = 0; slot < count; slot++)
|
|
{
|
|
IEntity actor = unit.StatusSampler.InitialMember(slot);
|
|
if (!actor) continue;
|
|
originals.Insert(actor);
|
|
if (!ERR_Actors.Alive(actor)) continue;
|
|
if (!present.Contains(actor) || !unit.StatusSampler.CanAttribute(slot, actor, false)) return false;
|
|
living = true;
|
|
}
|
|
if (!living) return false;
|
|
foreach (IEntity presentMember : present) if (!originals.Contains(presentMember)) return false;
|
|
// Logistics owners retain their exact tokens until their own submitted
|
|
// inventory operations settle. Removing that quarantine here is unsafe.
|
|
if (unit.PendingConvoyOrder) unit.CancelPending();
|
|
unit.ReservedForSupport = false;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
override void UnregisterGroup(SCR_AIGroup group)
|
|
{
|
|
m_Equipment.UnregisterGroup(group);
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit.Group != group) continue;
|
|
unit.LocalAwareness.Clear();
|
|
if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Remove(Detected); unit.Detection.ERR_BindAwareness(null); unit.Detection = null; }
|
|
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 bool OriginOccupied(IEntity caller, ERR_HQUnit owner)
|
|
{
|
|
if (!caller) return false;
|
|
foreach (ERR_HQUnit other : m_Units)
|
|
{
|
|
if (other == owner || !other.Uplink || other.Uplink.Failed || other.Uplink.Originated || other.Uplink.Nodes.IsEmpty()) continue;
|
|
if (other.Uplink.Nodes[0].Carrier == caller) return true;
|
|
}
|
|
return false;
|
|
}
|
|
protected ERR_HQRoute ObservationRoute(ERR_HQUnit unit)
|
|
{
|
|
ERR_HQRoute own;
|
|
if (unit.Radio && !OriginOccupied(unit.Radio, unit)) own = m_Network.Route(unit.Radio);
|
|
if (own && !own.Nodes.IsEmpty() && own.Nodes[0].Valid(own.Nodes.Count() == 1)) return own;
|
|
array<IEntity> busy = {};
|
|
foreach (ERR_HQUnit other : m_Units)
|
|
{
|
|
if (!other.Uplink || other.Uplink.Failed) continue;
|
|
if (other.Radio && !busy.Contains(other.Radio)) busy.Insert(other.Radio);
|
|
if (!other.Uplink.Originated && !other.Uplink.Nodes.IsEmpty())
|
|
{
|
|
IEntity caller = other.Uplink.Nodes[0].Carrier;
|
|
if (caller && !busy.Contains(caller)) busy.Insert(caller);
|
|
}
|
|
}
|
|
return m_Network.LocalRoute(unit.Group, unit.StatusSampler, busy);
|
|
}
|
|
|
|
protected void Observe(ERR_HQUnit unit, float now)
|
|
{
|
|
if (!unit.Utility || unit.Uplink || now < unit.NextReport) return;
|
|
unit.NextReport = now + m_Mode.Settings().Runtime("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 || !unit.SentObservations.CanQueue(seen.Entity, seen.ObservedAt, now)) return;
|
|
ERR_HQRoute route = ObservationRoute(unit);
|
|
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);
|
|
unit.SentObservations.Queued(seen.Entity, seen.ObservedAt, now);
|
|
if (route.LocalSource) m_Journal.Write("LOCAL HANDOFF", "original squad sighting queued for nearby physical radio caller; three-second origin hold required", report.MessageId);
|
|
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) return;
|
|
bool routine = now >= unit.NextStatus;
|
|
if (!routine && !unit.AmmoReporting.ScanDue(now)) return;
|
|
unit.AmmoReporting.Scanned(now);
|
|
ERR_HQRoute route = m_Network.Route(unit.Radio);
|
|
if (!route) return;
|
|
// A local sample reserves an identifier; a healthy early sample creates no frame.
|
|
ERR_HQStatusReport report = unit.StatusSampler.Sample(now, m_Journal.Message(), unit.Radio);
|
|
if (!report || (!routine && !unit.AmmoReporting.Urgent(report, now))) 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;
|
|
string mines = "unknown/incomplete";
|
|
if (report.AmmoState && report.AmmoState.MineComplete && report.AmmoState.MineMembers == report.Count)
|
|
mines = string.Format("%1 available unarmed PMN4", report.AmmoState.ReadyMines);
|
|
route.Payload += "; carried mines=" + mines;
|
|
if (report.MountedAmmo)
|
|
{
|
|
string mounted = "unknown/incomplete";
|
|
if (report.MountedAmmo.Complete)
|
|
mounted = string.Format("%1 weapons, %2/%3 magazine rounds, %4 chambered, %5 low magazines", report.MountedAmmo.Weapons, report.MountedAmmo.Rounds, report.MountedAmmo.Capacity, report.MountedAmmo.Chambers, report.MountedAmmo.LowMagazines);
|
|
route.Payload += "; mounted ammunition=" + mounted;
|
|
}
|
|
unit.PendingStatus = report; unit.Uplink = route; unit.NextStatus = now + 180000;
|
|
unit.AmmoReporting.Queued(now);
|
|
m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId);
|
|
}
|
|
// One captured contact every five seconds at most. Existing transmissions
|
|
// finish normally; overdue status gets a slot before more urgent contacts.
|
|
protected void ContactReport(ERR_HQUnit unit, float now)
|
|
{
|
|
if (unit.Uplink || now < unit.NextContact || !unit.Group) return;
|
|
ERR_HQReport report = unit.LocalAwareness.Peek(now, unit.SentObservations);
|
|
if (!report) return;
|
|
unit.NextContact = now + 5000;
|
|
ERR_HQRoute route = ObservationRoute(unit);
|
|
if (!route) return;
|
|
report.MessageId = m_Journal.Message();
|
|
route.MessageId = report.MessageId; route.Payload = report.Description(now);
|
|
unit.PendingReport = report; unit.Uplink = route;
|
|
unit.LocalAwareness.Queued(report, now);
|
|
if (report.Target) unit.SentObservations.Queued(report.Target, report.ObservedAt, now);
|
|
unit.NextReport = now + m_Mode.Settings().Runtime("reportIntervalSeconds") * 1000;
|
|
string kind = "CONTACT REPORT";
|
|
if (report.TravelMode == "heard gunfire") kind = "GUNFIRE REPORT";
|
|
if (report.TravelMode == "heard vehicle alarm") kind = "ALARM REPORT";
|
|
m_Journal.Write(kind, route.Payload, report.MessageId);
|
|
}
|
|
protected void QueueReports(ERR_HQUnit unit, float now)
|
|
{
|
|
if (OriginOccupied(unit.Radio, unit)) { if (unit.Task) unit.Task.ArrivedAt = -1; return; }
|
|
if (ERR_HQLocalAwareness.Priority(now, unit.NextContact, unit.NextStatus)) ContactReport(unit, now);
|
|
StatusReport(unit, now);
|
|
ContactReport(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)
|
|
{
|
|
if (unit.Assignment) RememberCompletedHunt(unit.Task, 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().Runtime("responseRadiusMetres") + 1;
|
|
int first = -1;
|
|
int second = -1;
|
|
array<vector> reserved = {};
|
|
foreach (ERR_HQUnit existing : m_Units) ReserveUnit(existing, reserved, first, second);
|
|
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); m_Recovery.ReportBody(report);
|
|
m_Journal.Write("AGGRESSION", string.Format("identified source=%1 slot=%2 bodies=%3 aggression=%4/30 hunter allowance=%5/15", report.CasualtySourceId, report.CasualtySlot, m_Watch.IdentifiedBodies(), m_Watch.Aggression(), m_Watch.HunterTarget()), report.MessageId);
|
|
}
|
|
if (!m_Memory.Ingest(report, now)) return;
|
|
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position, report.ObservedAt);
|
|
m_NextAssignment = Math.Min(m_NextAssignment, now);
|
|
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;
|
|
// Fire support uses the same delivered history as pursuit. A separate pair
|
|
// estimate could invent motion across mode changes or contradictory sightings.
|
|
ERR_HQBelief belief = m_Memory.Get(report.Target);
|
|
ERR_HQReport contact = report.Snapshot();
|
|
contact.HasVelocity = belief && belief.HasVelocity;
|
|
contact.Velocity = vector.Zero;
|
|
if (contact.HasVelocity) contact.Velocity = belief.Velocity;
|
|
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;
|
|
m_Contacts[i] = contact;
|
|
return;
|
|
}
|
|
if (m_Contacts.Count() >= 64) m_Contacts.Remove(0);
|
|
m_Contacts.Insert(contact);
|
|
}
|
|
|
|
// Recent native contacts and the quiet grace period block HQ retasking.
|
|
protected bool Fighting(ERR_HQUnit unit)
|
|
{
|
|
return unit.InfantryCombat.Held || ERR_HunterCombat.Active(unit.Group);
|
|
}
|
|
protected void UpdateInfantryCombat(ERR_HQUnit unit, float now)
|
|
{
|
|
bool eligible = unit.Eligible(m_Mode);
|
|
int changed = unit.InfantryCombat.Update(unit, eligible, eligible && ERR_HunterCombat.Active(unit.Group), now);
|
|
if (!changed) return;
|
|
string detail = "contact ended; no current mobile search to resume";
|
|
if (changed == 1)
|
|
{
|
|
unit.Group.ActivateAI();
|
|
array<AIAgent> reacting = {}; unit.Group.GetAgents(reacting);
|
|
foreach (AIAgent actor : reacting)
|
|
if (actor && ERR_Actors.CanFight(actor.GetControlledEntity())) actor.ActivateAI();
|
|
detail = "enemy contact; native combat takes priority; defensive positions retained";
|
|
if (unit.InfantryCombat.Paused()) detail = "enemy contact; HQ search paused; fire at will";
|
|
// Preserve status/convoy transports. Only superseded infantry task orders stop.
|
|
if (unit.PendingTask && !unit.PendingTask.Maneuver) LostOrder(unit, now);
|
|
}
|
|
else if (changed == 2) detail = "contact quiet for 15 seconds; existing HQ search resumed";
|
|
m_Journal.Write("INFANTRY COMBAT", string.Format("Squad %1 (%2): %3", unit.Id, unit.ForceSourceId, detail));
|
|
}
|
|
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;
|
|
}
|
|
// Only real surviving pursuit commitments can retain a failed order's hold.
|
|
protected float HuntCommitment(ERR_HQTask task, int beliefId, float now)
|
|
{
|
|
if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty() || task.Investigation()) return now + 15000;
|
|
if (task.BeliefId != beliefId || !(task.ExpiresAt > now)) return now + 15000;
|
|
return task.ExpiresAt + 180000;
|
|
}
|
|
// Completion is historical: later loss of another squad must not erase this
|
|
// belief's completed-search quiet interval after the original task is cleared.
|
|
protected void RememberCompletedHunt(ERR_HQTask task, float now)
|
|
{
|
|
if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty() || task.Investigation()) return;
|
|
if (!(task.ExpiresAt <= now)) return;
|
|
ERR_HQHuntWindow hunt = Hunt(task.BeliefId);
|
|
if (hunt) hunt.CompletedUntil = Math.Max(hunt.CompletedUntil, task.ExpiresAt + 180000);
|
|
}
|
|
// Releasing lost physical work and losing a radio order use the same finite
|
|
// surviving-commitment horizon. This never extends an already shorter hold.
|
|
protected void ReconsiderHunt(ERR_HQTask task, float now)
|
|
{
|
|
if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty() || task.Investigation()) return;
|
|
float nextHunt = now + 15000;
|
|
foreach (ERR_HQUnit other : m_Units)
|
|
{
|
|
if (other.Assignment)
|
|
{
|
|
if (other.Task && other.Task.BeliefId == task.BeliefId) RememberCompletedHunt(other.Task, now);
|
|
nextHunt = Math.Max(nextHunt, HuntCommitment(other.Task, task.BeliefId, now));
|
|
}
|
|
if (other.PendingOrder) nextHunt = Math.Max(nextHunt, HuntCommitment(other.PendingTask, task.BeliefId, now));
|
|
}
|
|
// Keep both active and pending survivors' deadlines, or the short retry delay.
|
|
ERR_HQHuntWindow hunt = Hunt(task.BeliefId);
|
|
if (hunt) hunt.NextHunt = Math.Min(hunt.NextHunt, Math.Max(nextHunt, hunt.CompletedUntil));
|
|
}
|
|
// The original physical group is no longer available to fulfil its order.
|
|
protected void ReleaseLostForce(ERR_HQUnit unit, float now)
|
|
{
|
|
ref ERR_HQTask lostActive = unit.Task;
|
|
ref ERR_HQTask lostPending = unit.PendingTask;
|
|
ReleaseUnit(unit, now);
|
|
if (lostActive && lostActive.ExpiresAt > now) ReconsiderHunt(lostActive, now);
|
|
ReconsiderHunt(lostPending, now);
|
|
}
|
|
protected void LostOrder(ERR_HQUnit unit, float now)
|
|
{
|
|
ref ERR_HQTask task = unit.PendingTask;
|
|
unit.CancelPending();
|
|
if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty()) return;
|
|
if (task.Investigation()) { FinishWatch(task, now); return; }
|
|
ReconsiderHunt(task, now);
|
|
}
|
|
// Several squads may hear the same alarm. Reserve only one mobile search
|
|
// across all alarm beliefs, including orders still travelling over radio.
|
|
protected bool AlarmCommitted(ERR_HQUnit exceptUnit = null)
|
|
{
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit == exceptUnit) continue;
|
|
if (unit.PendingTask && unit.PendingOrder && unit.PendingOrder.TravelMode == "heard vehicle alarm") return true;
|
|
if (unit.Task && unit.Assignment && unit.Assignment.TravelMode == "heard vehicle alarm") return true;
|
|
}
|
|
return false;
|
|
}
|
|
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 if (task.SecuritySite.IsEmpty())
|
|
{
|
|
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 (belief.Kind == ERR_HQMemoryKind.ALARM
|
|
&& (unit.HomeGuard || task.Role != ERR_HQPursuitRole.SEARCH || task.RoadIntercept || AlarmCommitted(unit)))
|
|
{ LostOrder(unit, now); return; }
|
|
// Radio transit may invalidate directed containment just as it can a road
|
|
// interception. Keep the old assignment; a later plan may choose SEARCH.
|
|
if (task.Role == ERR_HQPursuitRole.CONTAIN && ERR_HQPursuitRules.Role(belief.Confidence,
|
|
(now - belief.ObservedAt) / 1000, belief.Radius,
|
|
ERR_HQPursuitRules.Heading(belief.Velocity, belief.HasVelocity), 2) != ERR_HQPursuitRole.CONTAIN)
|
|
{ LostOrder(unit, now); return; }
|
|
if (task.RoadIntercept && !ERR_HQRoadIntercept.Eligible(belief, now)) { LostOrder(unit, now); return; }
|
|
}
|
|
if (task.Maneuver && now - order.ObservedAt > 60000) { LostOrder(unit, now); return; }
|
|
if (Fighting(unit) && !task.Maneuver) { LostOrder(unit, now); return; }
|
|
bool memorySearch = task.Role == ERR_HQPursuitRole.SEARCH && !task.Investigation()
|
|
&& !task.RoadIntercept && task.SecuritySite.IsEmpty();
|
|
if (memorySearch && (!ERR_HQPursuitRules.Finite(task.SearchRadiusMetres)
|
|
|| task.SearchRadiusMetres < 45 || task.SearchRadiusMetres > 110)) { LostOrder(unit, now); return; }
|
|
int deliveryId;
|
|
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
|
|
ResourceName waypointResource = ERR_Resources.SearchWaypoint();
|
|
if (task.RoadIntercept || !task.SecuritySite.IsEmpty()) waypointResource = ERR_Resources.DefendWaypoint();
|
|
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(waypointResource, ERR_Actors.Ground(task.Destination)));
|
|
if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
|
|
if (memorySearch) waypoint.SetCompletionRadius(task.SearchRadiusMetres);
|
|
if (task.Maneuver) ERR_SupplyConvoyTactics.Command(waypoint);
|
|
// Native IsPositionAllowed uses the defend waypoint's 3D radius. Its stock
|
|
// 30m envelope excluded valid 25..60m engineer flank sites before movement.
|
|
// Individual mine work remains bounded to 60m and retains friendly clearance.
|
|
if (task.RoadIntercept) waypoint.SetCompletionRadius(70);
|
|
if (unit.Assignment) RememberCompletedHunt(unit.Task, now);
|
|
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);
|
|
}
|
|
// A native group origin (or average of scattered members) is not evidence
|
|
// that anyone physically searched this area. Require an original fit searcher
|
|
// on foot inside the same arrival radius, without adopting replacement bodies.
|
|
protected bool SweepPresent(ERR_HQUnit unit, vector destination)
|
|
{
|
|
if (!unit || !unit.StatusSampler || !unit.StatusSampler.OwnsRoster(unit.Group)
|
|
|| !ERR_HQPursuitRules.PositionValid(destination)) return false;
|
|
int count = unit.StatusSampler.InitialStrength();
|
|
if (count < 1 || count > 32) return false;
|
|
for (int slot = 0; slot < count; slot++)
|
|
{
|
|
IEntity actor = unit.StatusSampler.InitialMember(slot);
|
|
if (!ERR_Actors.CanFight(actor) || !unit.StatusSampler.CanAttribute(slot, actor, false)
|
|
|| CompartmentAccessComponent.GetVehicleIn(actor)) continue;
|
|
vector position = actor.GetOrigin();
|
|
if (ERR_HQPursuitRules.PositionValid(position) && vector.DistanceXZ(position, destination) <= 40) return true;
|
|
}
|
|
return false;
|
|
}
|
|
protected void Sweep(ERR_HQUnit unit, float now)
|
|
{
|
|
ERR_HQTask task = unit.Task;
|
|
if (!task || !task.SecuritySite.IsEmpty() || task.RoadIntercept || !unit.Assignment || task.SweepQueued || !unit.Waypoint) return;
|
|
if (!unit.Eligible(m_Mode) || Fighting(unit) || !SweepPresent(unit, task.Destination))
|
|
{ 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));
|
|
}
|
|
}
|
|
// A radio replacement does not revoke the delivered order. Reserve both
|
|
// destinations/sectors until delivery commits or interruption discards pending.
|
|
protected void ReserveTask(ERR_HQTask task, array<vector> reserved, out int first, out int second, bool reserveDestination = true)
|
|
{
|
|
if (!task || task.ReleaseOnly) return;
|
|
if (reserveDestination) reserved.Insert(task.Destination);
|
|
int bearing = task.ApproachBearing;
|
|
if (first == 4) return;
|
|
// Site-security orders have no pursuit bearing; retain their cell only.
|
|
if (bearing < 0 || bearing > 3) return;
|
|
if (first < 0) first = bearing;
|
|
else if (bearing != first && second < 0) second = bearing;
|
|
else if (bearing != first && bearing != second) first = 4;
|
|
}
|
|
protected void ReserveUnit(ERR_HQUnit unit, array<vector> reserved, out int first, out int second)
|
|
{
|
|
if (!unit) return;
|
|
if (unit.Assignment) ReserveTask(unit.Task, reserved, first, second);
|
|
if (unit.PendingOrder && (!unit.Assignment || unit.PendingTask != unit.Task)) ReserveTask(unit.PendingTask, reserved, first, second);
|
|
}
|
|
// Each distinct delivered road post may use one original carried mine. This is local
|
|
// execution of its existing HQ order; no separate response or new force.
|
|
protected void MaintainEngineerMines(float now)
|
|
{
|
|
if (!Replication.IsServer() || m_Stopping || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
|
|
int active;
|
|
foreach (ERR_EngineerMine job : m_EngineerMineJobs)
|
|
{
|
|
job.Tick(now);
|
|
if (!job.Finished()) active++;
|
|
}
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (active >= 2 || m_EngineerMineJobs.Count() >= 32 || m_EngineerMinePosts.Count() >= 32 || m_EngineerMineAttempts.Count() >= 256) break;
|
|
if (!unit.Task || !unit.Assignment || !unit.Task.RoadIntercept) continue;
|
|
if (unit.PendingTask || unit.PendingOrder || unit.Downlink || now >= unit.Task.ExpiresAt) continue;
|
|
if (!unit.Eligible(m_Mode) || Fighting(unit)) continue;
|
|
vector groupPosition = unit.Group.GetOrigin();
|
|
float arrivalDistance = vector.DistanceXZ(groupPosition, unit.Task.Destination);
|
|
if (!ERR_ForceRules.FiniteVector(groupPosition) || !ERR_ForceRules.Finite(arrivalDistance) || arrivalDistance > 20) continue;
|
|
if (!m_Mode.Forces() || !unit.StatusSampler || !unit.StatusSampler.OwnsRoster(unit.Group)) continue;
|
|
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
|
|
if (!record || record.Group != unit.Group || record.InitialStrength != unit.StatusSampler.InitialStrength()) continue;
|
|
bool blocked;
|
|
foreach (ERR_EngineerMine previous : m_EngineerMineJobs)
|
|
if (previous.BlocksNextPost(record)) { blocked = true; break; }
|
|
if (blocked) continue;
|
|
// Snapshot old sites before adding this attempt/site. Refusals consume quota;
|
|
// registering the original group again cannot reset the mission ledger.
|
|
array<vector> priorSites = {}; m_EngineerMinePosts.PriorSites(priorSites);
|
|
int attempt = m_EngineerMinePosts.Consume(record, unit.ForceSourceId, unit.Task, unit.Assignment, now);
|
|
if (attempt < 0) continue;
|
|
vector position;
|
|
if (!ERR_EngineerMineSite.Select(unit.Task.Destination, position))
|
|
{ ERR_HQ.Event("ENGINEER MINE", "no safe mine site beside delivered road post; attempt closed"); break; }
|
|
if (!m_EngineerMinePosts.RecordSite(attempt, position)) break;
|
|
unit.EngineerMine = new ERR_EngineerMine(m_EngineerMineAttempts);
|
|
m_EngineerMineJobs.Insert(unit.EngineerMine);
|
|
if (unit.EngineerMine.Begin(m_Mode, unit, unit.Task, position, now, priorSites)) active++;
|
|
else { ERR_HQ.Event("ENGINEER MINE", "placement refused: no eligible quiet original soldier with a usable carried mine and clear route"); unit.EngineerMine.Stop(); }
|
|
break; // One new site query/admission per HQ tick, including refusals.
|
|
}
|
|
}
|
|
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;
|
|
if (belief.Kind == ERR_HQMemoryKind.ALARM && (unit.HomeGuard || AlarmCommitted(unit))) 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)
|
|
{
|
|
if (other == unit)
|
|
{
|
|
// Its old cell may be replaced, but its delivered approach remains
|
|
// committed until this new downlink actually succeeds.
|
|
ReserveTask(other.Task, reserved, first, second, false);
|
|
continue;
|
|
}
|
|
ReserveUnit(other, reserved, first, second);
|
|
}
|
|
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.SearchRadiusMetres = ERR_HQPursuitRules.SearchRadius(belief.Radius);
|
|
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;
|
|
}
|
|
// One new trip per planning cadence, rotating among the existing finite trucks.
|
|
protected void PlanResupply(float now)
|
|
{
|
|
if (!m_Resupply) return;
|
|
int count = m_Resupply.Count();
|
|
if (count < 1 || count > 20) return;
|
|
for (int checked = 0; checked < count; checked++)
|
|
{
|
|
if (m_NextResupply >= count) m_NextResupply = 0;
|
|
ERR_InfantryResupply supply = m_Resupply.At(m_NextResupply++);
|
|
if (PlanResupplyTask(supply, now)) return;
|
|
}
|
|
}
|
|
protected bool PlanResupplyTask(ERR_InfantryResupply supply, float now)
|
|
{
|
|
if (!supply || supply.State() != 1) return false;
|
|
ERR_ConvoyTask task = supply.Task();
|
|
if (!task || task.Stopped || !task.Ready || task.Vehicles.Count() != 6 || !task.Vehicles[1]) return false;
|
|
ERR_HQUnit donor;
|
|
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == task.Vehicles[1].Group) donor = unit;
|
|
if (!donor || ConvoyContact(donor, now)) return false;
|
|
ERR_HQWatchEntry donorStatus = m_Watch.Find(donor.ForceSourceId);
|
|
foreach (ERR_HQUnit recipient : m_Units)
|
|
if (supply.TryPlan(recipient, donor, m_Watch.Find(recipient.ForceSourceId), donorStatus, now)) return true;
|
|
// Existing infantry demand keeps priority; mounted recipients use the same
|
|
// finite truck and delivered radio entries only after that scan fails.
|
|
foreach (ERR_HQUnit mounted : m_Units)
|
|
if (!ConvoyContact(mounted, now) && supply.TryPlanMounted(mounted, donor, m_Watch.Find(mounted.ForceSourceId), donorStatus, now)) return true;
|
|
// Supported depleted launcher magazines follow rifle/mounted demand and
|
|
// share the same finite stock, radio replay and physical rendezvous service.
|
|
foreach (ERR_HQUnit launcherRecipient : m_Units)
|
|
if (supply.TryPlanLauncher(launcherRecipient, donor, m_Watch.Find(launcherRecipient.ForceSourceId), donorStatus, now)) return true;
|
|
// Optional finite mines are last: only a delivered known one-mine formation
|
|
// may top up an existing carrier; no new issue or engineer-task reset.
|
|
foreach (ERR_HQUnit mineRecipient : m_Units)
|
|
if (supply.TryPlanMines(mineRecipient, donor, m_Watch.Find(mineRecipient.ForceSourceId), donorStatus, now)) return true;
|
|
return false;
|
|
}
|
|
// Base declarations allow native script compilation to resolve the optional
|
|
// mounted-dispatch extension through ordinary virtual method overrides.
|
|
protected void TickMountedIntercept(float now) {}
|
|
protected void PlanMountedIntercept(float now) {}
|
|
protected void PlanConvoy(float now)
|
|
{
|
|
for (int i = 0; i < m_Convoys.Count(); i++) PlanConvoyTask(m_Convoys.At(i), now);
|
|
}
|
|
protected void PlanConvoyTask(ERR_ConvoyTask task, float now)
|
|
{
|
|
if (!task || task.Stopped || !task.Ready) return;
|
|
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
|
|
{
|
|
if (vehicle.Released || vehicle.Arrived || vehicle.SupplyToken > 0 || !vehicle.Registered || !vehicle.Group) continue;
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
if (unit.Group != vehicle.Group || !unit.ReservedForSupport || unit.LogisticsReservation > 0 || 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 (!m_Convoys.CanResumeCombat(task) || vehicle.Combat.ReportId <= 0 || !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 = task.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)
|
|
{
|
|
if (contact && order.Hold) order.Position = contact.Position;
|
|
accepted = m_Convoys.ApplyCombatOrder(m_Convoys.Find(order.SourceId), 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 bool FlankPosition(ERR_HQUnit unit, vector reported, int slot, float now, out vector destination)
|
|
{
|
|
if (!unit.Group || !unit.Utility || unit.HomeGuard) return false;
|
|
vector direction = reported - unit.PlanPosition; direction[1] = 0;
|
|
if (direction.Length() < 1) return false;
|
|
direction.Normalize();
|
|
vector desired = ERR_HunterAttackRules.Offset(reported, direction, slot, 1);
|
|
for (int attempt = 0; attempt < 8; attempt++)
|
|
{
|
|
vector point = desired;
|
|
if (attempt > 0) point += Vector(Math.RandomFloat(-15, 15), 0, Math.RandomFloat(-15, 15));
|
|
point = ERR_Actors.Ground(point);
|
|
if (!m_AttackSpace.Free(unit.Group, point, now) || !unit.Utility.IsPositionAllowed(point)) continue;
|
|
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue;
|
|
if (SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { destination = point; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
protected void Assign(float now)
|
|
{
|
|
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Runtime("supportSquadLimit"));
|
|
int limit = ERR_HQPursuitRules.Limit(globalLimit);
|
|
int radius = m_Mode.Settings().Runtime("responseRadiusMetres");
|
|
int assigned = Assigned();
|
|
int roadQueries;
|
|
// 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)
|
|
{
|
|
if (!unit.PlanEligible) ReleaseLostForce(unit, now);
|
|
else ReleaseUnit(unit, now); // Capacity trimming preserves the ordinary hold.
|
|
assigned--; continue;
|
|
}
|
|
if (unit.Task && now >= unit.Task.ExpiresAt) { if (unit.Assignment) RememberCompletedHunt(unit.Task, now); FinishWatch(unit.Task, now); unit.ReleaseAssignment(); }
|
|
if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) LostOrder(unit, now);
|
|
if (unit.Task && unit.Task.SecuritySite.IsEmpty() && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment();
|
|
if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && unit.PendingTask.SecuritySite.IsEmpty() && !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();
|
|
// Retask/investigation and cleanup above have settled commitments for this pass.
|
|
int totalAssigned = Assigned();
|
|
array<vector> reserved = {};
|
|
int first = -1;
|
|
int second = -1;
|
|
foreach (ERR_HQUnit unit : m_Units)
|
|
{
|
|
ReserveUnit(unit, reserved, first, second);
|
|
ReserveAttackSpace(unit, now);
|
|
}
|
|
// Strongest delivered track first; finite scans (64 beliefs,128 own units).
|
|
array<int> considered = {};
|
|
for (int pass = 0; pass < m_PlanBeliefs.Count() && assigned < limit && totalAssigned < 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);
|
|
if (belief.Kind == ERR_HQMemoryKind.ALARM && AlarmCommitted()) continue;
|
|
ERR_HQHuntWindow hunt = Hunt(belief.BeliefId);
|
|
if (!hunt || now < hunt.NextHunt) continue;
|
|
for (int slot = 0; slot < limit && assigned < limit && totalAssigned < globalLimit; slot++)
|
|
{
|
|
// One mobile search squad for uncertain sound; never empty objectives for it.
|
|
if ((belief.Kind == ERR_HQMemoryKind.GUNFIRE || belief.Kind == ERR_HQMemoryKind.ALARM) && slot > 0) break;
|
|
float age = (now - belief.ObservedAt) / 1000;
|
|
ERR_HQPursuitRole role = ERR_HQPursuitRules.Role(belief.Confidence, age, belief.Radius,
|
|
ERR_HQPursuitRules.Heading(belief.Velocity, belief.HasVelocity), slot);
|
|
if (belief.Kind == ERR_HQMemoryKind.ALARM) role = ERR_HQPursuitRole.SEARCH;
|
|
vector destination = ERR_HQPursuitRules.Objective(belief.PredictedPosition, belief.Velocity, belief.HasVelocity, belief.Confidence, age, belief.Radius, role, slot);
|
|
bool roadIntercept;
|
|
if (role == ERR_HQPursuitRole.INTERCEPT)
|
|
{
|
|
if (roadQueries < 2) { roadQueries++; roadIntercept = ERR_HQRoadIntercept.Select(belief, now, reserved, destination); }
|
|
if (!roadIntercept) role = ERR_HQPursuitRole.SEARCH;
|
|
}
|
|
if (role == ERR_HQPursuitRole.CONTAIN) destination = Containment(belief, destination);
|
|
bool manoeuvre = role == ERR_HQPursuitRole.SEARCH && ERR_HQPursuitRules.Maneuver(belief.Kind == ERR_HQMemoryKind.SIGHTING, belief.Confidence, age, belief.Radius, slot);
|
|
if (role == ERR_HQPursuitRole.SEARCH && !manoeuvre && !m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue;
|
|
if (!manoeuvre && !m_AttackSpace.Free(null, destination, now)) 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;
|
|
if (belief.Kind == ERR_HQMemoryKind.ALARM && unit.HomeGuard) continue;
|
|
if (manoeuvre && unit.HomeGuard) continue;
|
|
bool busy = unit.Assignment || unit.PendingOrder || unit.Downlink || (unit.PlanFighting && !manoeuvre);
|
|
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;
|
|
if (manoeuvre && !FlankPosition(best, belief.PredictedPosition, slot, now, destination)) continue;
|
|
ERR_HQTask task = new ERR_HQTask();
|
|
task.Maneuver = manoeuvre;
|
|
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = role; task.RoadIntercept = roadIntercept;
|
|
if (role == ERR_HQPursuitRole.SEARCH && !roadIntercept) task.SearchRadiusMetres = ERR_HQPursuitRules.SearchRadius(belief.Radius);
|
|
task.Destination = destination; task.QueuedAt = now;
|
|
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(role, vector.DistanceXZ(best.PlanPosition, destination)) * 1000;
|
|
if (manoeuvre && task.ExpiresAt > now + 240000) task.ExpiresAt = now + 240000;
|
|
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);
|
|
if (belief.Kind == ERR_HQMemoryKind.GUNFIRE) bestRoute.Payload += "; investigate heard gunfire, enemy identity unknown";
|
|
if (belief.Kind == ERR_HQMemoryKind.ALARM) bestRoute.Payload += "; investigate heard vehicle alarm, cause and identity unknown";
|
|
m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId);
|
|
m_AttackSpace.Claim(best.Group, destination, now);
|
|
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++;
|
|
totalAssigned++;
|
|
}
|
|
}
|
|
}
|
|
|
|
override void Maintain(float now)
|
|
{
|
|
// Aircraft already airborne keep flying even when the server is empty.
|
|
m_Recon.Tick(now);
|
|
m_AirAssault.Maintain(now);
|
|
m_Airfield.Maintain(now);
|
|
}
|
|
override void Tick(float now)
|
|
{
|
|
if (!Replication.IsServer()) return;
|
|
if (m_Stopping) { if (m_Resupply) m_Resupply.Tick(now); return; }
|
|
ERR_Performance perf = m_Mode.Performance();
|
|
int perfStamp;
|
|
if (perf) { perfStamp = System.GetTickCount(); perf.Groups(m_Units.Count()); }
|
|
m_Memory.Tick(now);
|
|
m_Equipment.Tick(now);
|
|
for (int skillIndex = m_SpecialSkills.Count() - 1; skillIndex >= 0; skillIndex--)
|
|
{
|
|
SCR_AICombatComponent specialSkill = m_SpecialSkills[skillIndex];
|
|
if (!specialSkill) { m_SpecialSkills.Remove(skillIndex); continue; }
|
|
SCR_AIGroup specialGroup = ERR_Actors.Group(specialSkill.GetOwner());
|
|
if (ERR_EnemyEquipmentRules.Eligible(m_Mode, specialGroup, specialSkill.GetOwner())) specialSkill.ERR_BindEnemySkill(m_Mode, specialGroup);
|
|
else specialSkill.ERR_ReleaseEnemySkill(m_Mode);
|
|
}
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_PREP, perfStamp);
|
|
m_Hunters.Tick(now);
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_HUNTERS, perfStamp);
|
|
m_ObjectiveDefence.Tick(now);
|
|
m_Towns.Tick(now);
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_GARRISONS, perfStamp);
|
|
TickMountedIntercept(now);
|
|
m_Convoys.Tick(now);
|
|
AutomaticConvoy(now);
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_CONVOYS, perfStamp);
|
|
m_Bodies.TickBudget(now);
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_BODIES, perfStamp);
|
|
if (now >= m_NextNetwork)
|
|
{
|
|
IndexRadios(); m_NextNetwork = now + 5000;
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_RADIO_INDEX, perfStamp);
|
|
}
|
|
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];
|
|
UpdateInfantryCombat(unit, now);
|
|
if (!unit.Detection && unit.Utility && unit.Group)
|
|
{
|
|
unit.Detection = unit.Utility.m_Perception;
|
|
if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Insert(Detected); unit.Detection.ERR_BindAwareness(unit.LocalAwareness); }
|
|
}
|
|
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) { ReleaseLostForce(unit, now); m_Units.Remove(i); continue; }
|
|
QueueReports(unit, now);
|
|
}
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_REPORTS, perfStamp);
|
|
if (now >= m_NextAssignment)
|
|
{
|
|
PlanConvoy(now); PlanResupply(now); PlanMountedIntercept(now); Assign(now); m_NextAssignment = now + 5000;
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_PLANNER, perfStamp);
|
|
}
|
|
m_Roadblocks.MineTick(m_Units, m_EngineerMineAttempts, now);
|
|
MaintainEngineerMines(now);
|
|
m_Resupply.Tick(now);
|
|
m_Logistics.Tick(now);
|
|
m_Mortars.Tick(now);
|
|
m_AirAssault.Tick(now);
|
|
m_Airfield.Tick(now); m_Recovery.Tick(now);
|
|
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_SUPPORT, perfStamp);
|
|
m_Journal.Flush();
|
|
if (perf) perf.Mark(ERR_PerfMetric.HQ_JOURNAL, perfStamp);
|
|
}
|
|
|
|
// Round teardown stops the normal command tick. Only late proof/cleanup may
|
|
// continue while this exact HQ is still owned by the current game mode.
|
|
protected void CancelStoppedResupply()
|
|
{
|
|
if (GetGame() && GetGame().GetCallqueue()) GetGame().GetCallqueue().Remove(PollStoppedResupply);
|
|
}
|
|
void ~ERR_HQ() { CancelStoppedResupply(); }
|
|
protected void PollStoppedResupply()
|
|
{
|
|
CancelStoppedResupply();
|
|
if (!m_Stopping || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get()
|
|
|| m_Mode.Command() != this || !m_Resupply || !GetGame() || !GetGame().GetWorld() || !GetGame().GetCallqueue()) return;
|
|
m_Resupply.Tick(GetGame().GetWorld().GetWorldTime());
|
|
if (m_Resupply.Unresolved()) GetGame().GetCallqueue().CallLater(PollStoppedResupply, 1000, false);
|
|
}
|
|
|
|
override void Stop()
|
|
{
|
|
if (m_Stopping) return;
|
|
m_Stopping = true; m_AttackSpace.Clear();
|
|
m_Hunters.Stop(); m_ObjectiveDefence.Stop();
|
|
m_Towns.Stop();
|
|
m_Roadblocks.Stop();
|
|
foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode);
|
|
m_SpecialSkills.Clear();
|
|
foreach (ERR_HQUnit detector : m_Units)
|
|
{
|
|
detector.LocalAwareness.Clear();
|
|
if (detector.Detection) { detector.Detection.GetOnEnemyDetected().Remove(Detected); detector.Detection.ERR_BindAwareness(null); }
|
|
}
|
|
m_Airfield.Stop(); m_Recovery.Stop();
|
|
m_AirAssault.Stop();
|
|
m_Recon.Stop();
|
|
m_Scouts.Stop();
|
|
m_ConvoyStartup.Stop();
|
|
if (m_SupplyConvoys) m_SupplyConvoys.Stop();
|
|
m_Resupply.Stop();
|
|
PollStoppedResupply();
|
|
m_Convoys.Stop();
|
|
m_Equipment.Stop();
|
|
m_Logistics.Stop();
|
|
m_Mortars.Stop();
|
|
foreach (ERR_HQUnit unit : m_Units) unit.Release();
|
|
foreach (ERR_EngineerMine job : m_EngineerMineJobs) job.Stop();
|
|
m_EngineerMineJobs.Clear();
|
|
m_Units.Clear();
|
|
m_EngineerMineAttempts.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;
|
|
}
|
|
}
|