Deliver local sighting handoffs and preserve unsubmitted resupply attempts

This commit is contained in:
2026-09-20 10:18:18 +02:00
parent d8ec0de765
commit 81c0f060fd
48 changed files with 6994 additions and 30 deletions
+37 -4
View File
@@ -8,6 +8,7 @@ class ERR_HQUnit
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;
@@ -248,7 +249,7 @@ class ERR_HQ : ERR_CommandService
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;
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();
@@ -410,16 +411,45 @@ class ERR_HQ : ERR_CommandService
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.Radio || unit.Uplink || now < unit.NextReport) return;
if (!unit.Utility || 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 (!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();
@@ -433,6 +463,8 @@ class ERR_HQ : ERR_CommandService
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);
}
@@ -474,6 +506,7 @@ class ERR_HQ : ERR_CommandService
// Positive observations precede a negative search report.
protected void QueueReports(ERR_HQUnit unit, float now)
{
if (OriginOccupied(unit.Radio, unit)) { if (unit.Task) unit.Task.ArrivedAt = -1; return; }
StatusReport(unit, now);
Observe(unit, now);
ObserveBody(unit, now);
@@ -0,0 +1,45 @@
// Local sharing of an original squad sighting; only the ordinary radio route
// can deliver its immutable report to HQ. No observation or force is invented.
class ERR_HQLocalHandoff
{
static bool Valid(SCR_AIGroup source, ERR_HQStatusSampler roster, IEntity witness, ERR_HQRadioNode receiver)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!roster || !roster.OwnsRoster(source) || !receiver || receiver.Group == source || !receiver.Valid()) return false;
if (!ERR_EnemyEquipmentRules.Eligible(mode, source, witness) || !ERR_EnemyEquipmentRules.Eligible(mode, receiver.Group, receiver.Carrier)) return false;
int count = roster.InitialStrength();
if (count < 1 || count > 32) return false;
bool original;
for (int i = 0; i < count; i++)
if (roster.InitialMember(i) == witness && roster.CanAttribute(i, witness)) original = true;
if (!original) return false;
float distance = vector.Distance(witness.GetOrigin(), receiver.Carrier.GetOrigin());
return distance >= 0 && distance <= 100;
}
}
// Only a genuinely newer original sighting may be attempted. Old watermarks
// can expire because the observation capture itself admits at most 500ms age.
class ERR_HQObservationHistory
{
protected ref array<IEntity> m_Targets = {};
protected ref array<float> m_Times = {};
bool CanQueue(IEntity target, float observedAt, float now)
{
if (!target || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observedAt) || observedAt < 0 || now < observedAt || now - observedAt > 500) return false;
for (int i = m_Targets.Count() - 1; i >= 0; i--)
{
if (!m_Targets[i] || now - m_Times[i] > 120000) { m_Targets.Remove(i); m_Times.Remove(i); }
}
int index = m_Targets.Find(target);
if (index >= 0) return observedAt > m_Times[index];
return m_Targets.Count() < 64;
}
void Queued(IEntity target, float observedAt, float now)
{
if (!CanQueue(target, observedAt, now)) return;
int index = m_Targets.Find(target);
if (index >= 0) m_Times[index] = observedAt;
else { m_Targets.Insert(target); m_Times.Insert(observedAt); }
}
}
@@ -36,6 +36,9 @@ class ERR_HQRadioNode
class ERR_HQRoute
{
ref array<ref ERR_HQRadioNode> Nodes = {};
SCR_AIGroup LocalSource;
IEntity LocalWitness;
ref ERR_HQStatusSampler LocalRoster;
bool Downlink;
bool Originated;
float OriginDue = -1;
@@ -68,6 +71,8 @@ class ERR_HQRoute
{
if (Nodes.IsEmpty() || !Nodes[0].Valid(Nodes.Count() == 1))
return Fail(journal, "originating call lost its living owned radio carrier");
if (LocalRoster && !ERR_HQLocalHandoff.Valid(LocalSource, LocalRoster, LocalWitness, Nodes[0]))
return Fail(journal, "local sighting handoff lost original squad member, recipient or 100m reach");
if (OriginDue < 0)
{
OriginDue = now + 3000;
@@ -151,6 +156,7 @@ class ERR_HQNetwork
protected ref map<IEntity, int> m_Indices = new map<IEntity, int>();
protected ref map<string, ref array<int>> m_Cells = new map<string, ref array<int>>();
bool Overflow;
protected int m_LocalChecks;
void Clear()
{
@@ -160,6 +166,7 @@ class ERR_HQNetwork
m_Indices.Clear();
m_Cells.Clear();
Overflow = false;
m_LocalChecks = 0;
}
protected string Cell(int x, int z)
@@ -243,6 +250,30 @@ class ERR_HQNetwork
}
}
// At most 4096 local pairs across all reports per network rebuild. Indexes
// provide candidate routes only; current original actors/distance are checked.
ERR_HQRoute LocalRoute(SCR_AIGroup source, ERR_HQStatusSampler roster, array<IEntity> busy)
{
if (Overflow || !source || !roster || !roster.OwnsRoster(source) || !busy || busy.Count() > 256) return null;
int count = roster.InitialStrength();
if (count < 1 || count > 32) return null;
foreach (ERR_HQRadioNode node : m_Nodes)
{
if (!node || node.Group == source || busy.Contains(node.Carrier)) continue;
for (int i = 0; i < count; i++)
{
if (++m_LocalChecks > 4096) return null;
IEntity witness = roster.InitialMember(i);
if (!ERR_HQLocalHandoff.Valid(source, roster, witness, node)) continue;
ERR_HQRoute route = Route(node.Carrier);
if (!route) continue;
route.LocalSource = source; route.LocalWitness = witness; route.LocalRoster = roster;
return route;
}
}
return null;
}
ERR_HQRoute Route(IEntity caller, bool downlink = false)
{
int index;
@@ -174,7 +174,9 @@ class ERR_InfantryResupply
m_MoveWaypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), ERR_Actors.Ground(m_Rendezvous)));
m_HoldWaypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), ERR_Actors.Ground(m_Rendezvous)));
if (!m_MoveWaypoint || !m_HoldWaypoint) return false;
m_MoveWaypoint.SetCompletionRadius(2); m_HoldWaypoint.SetCompletionRadius(3);
// The reported rendezvous is 3 m from the donor report. A 2 m move
// radius allowed arrival 5 m away, beyond the transfer's physical reach.
m_MoveWaypoint.SetCompletionRadius(0.5); m_HoldWaypoint.SetCompletionRadius(0.5);
m_Recipient.Group.AddWaypointAt(m_HoldWaypoint, 0); m_Recipient.Group.AddWaypointAt(m_MoveWaypoint, 0);
m_Recipient.Group.ActivateAI(); return true;
}
@@ -215,7 +217,13 @@ class ERR_InfantryResupply
if (!magazine || m_Spent.Contains(magazine) || !Contains(m_Truck.Entity, magazine) || !ERR_InfantryAmmoTransfer.Compatible(actor, magazine)) continue;
m_Spent.Insert(magazine); m_Receiving = actor;
m_Move = new ERR_InfantryAmmoTransfer(m_Mode, m_Truck.Entity, m_Truck.Group, actor, m_Recipient.Group, magazine);
m_Move.Tick(now); return;
m_Move.Tick(now);
if (m_Move.Submitted()) return;
// No native operation exists yet: approach geometry, stopped state or
// capacity may improve while the same bounded rendezvous remains valid.
// Keep every submitted identity spent, including a synchronous rejection.
m_Spent.RemoveItem(magazine); m_Move = null; m_Receiving = null;
break; // At most one unsubmitted preflight per member on this slow tick.
}
}
}