Files
EscapistsReintroduced/docs/evidence/mounted-pair-2026-10-03/hq/fix.patch
T
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

503 lines
42 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedDispatch.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedDispatch.c
@@ -1,4 +1,15 @@
-// One physical escort may intercept a road ahead of delivered travel history.
+// Detached planning choice; the route remains strongly owned before admission.
+class ERR_HQMountedChoice
+{
+ ERR_HQUnit Unit;
+ ERR_ConvoyTask Task;
+ ERR_ConvoyVehicle Vehicle;
+ ref ERR_HQRoute Route;
+ vector Destination;
+ float Distance;
+}
+
+// One or two original escorts may intercept a road ahead of delivered travel history.
// This HQ-owned transport uses the ordinary radio graph and never reads players.
modded class ERR_HQ
{
@@ -7,6 +18,12 @@
protected ERR_ConvoyTask m_MountedInterceptTask;
protected ref ERR_HQRoute m_MountedInterceptRoute;
protected bool m_MountedInterceptDelivered;
+ protected ref ERR_ConvoyInterceptOrder m_MountedInterceptPartner;
+ protected ERR_HQUnit m_MountedInterceptPartnerUnit;
+ protected ref ERR_HQRoute m_MountedInterceptPartnerRoute;
+ protected bool m_MountedInterceptReceipt;
+ protected bool m_MountedInterceptPartnerReceipt;
+ protected int m_MountedTokenCount;
protected int m_MountedSurveyCursor;
protected ref array<int> m_MountedAttemptBeliefs = {};
protected ref array<int> m_MountedAttemptRevisions = {};
@@ -17,6 +34,7 @@
if (!m_MountedIntercept) return result;
string phase = "queued";
if (m_MountedInterceptDelivered) phase = "delivered";
+ if (m_MountedInterceptPartner) result += "\nPaired mounted interception (two original escorts)";
return result + "\n" + string.Format("Mounted interception: escort %1; %2; report M%3; road %4", m_MountedIntercept.VehicleIndex, phase, m_MountedIntercept.ReportId, m_MountedIntercept.Position);
}
@@ -24,18 +42,21 @@
{
int count = super.Assigned();
if (m_MountedIntercept) count++;
+ if (m_MountedInterceptPartner) count++;
return count;
}
protected override int HuntingAssigned()
{
int count = super.HuntingAssigned();
if (m_MountedIntercept) count++;
+ if (m_MountedInterceptPartner) count++;
return count;
}
protected override void ReserveUnit(ERR_HQUnit unit, array<vector> reserved, out int first, out int second)
{
super.ReserveUnit(unit, reserved, first, second);
if (m_MountedIntercept && unit == m_MountedInterceptUnit) reserved.Insert(m_MountedIntercept.Position);
+ if (m_MountedInterceptPartner && unit == m_MountedInterceptPartnerUnit) reserved.Insert(m_MountedInterceptPartner.Position);
}
protected bool MountedInterceptAttempted(int beliefId, int revision)
{
@@ -58,47 +79,85 @@
protected void ReleaseMountedIntercept(float now, string reason)
{
ref ERR_ConvoyInterceptOrder order = m_MountedIntercept;
+ ref ERR_ConvoyInterceptOrder partner = m_MountedInterceptPartner;
ERR_HQUnit unit = m_MountedInterceptUnit;
+ ERR_HQUnit partnerUnit = m_MountedInterceptPartnerUnit;
ERR_ConvoyTask task = m_MountedInterceptTask;
- // Detach first: physical release may synchronously unregister the group.
+ // Detach BOTH first: physical release may synchronously unregister a group.
m_MountedIntercept = null; m_MountedInterceptUnit = null; m_MountedInterceptTask = null;
- m_MountedInterceptRoute = null; m_MountedInterceptDelivered = false;
+ m_MountedInterceptPartner = null; m_MountedInterceptPartnerUnit = null;
+ m_MountedInterceptRoute = null; m_MountedInterceptPartnerRoute = null;
+ m_MountedInterceptDelivered = false; m_MountedInterceptReceipt = false; m_MountedInterceptPartnerReceipt = false;
if (!order) return;
if (m_Convoys) m_Convoys.ReleaseIntercept(task, order, now);
+ if (partner && m_Convoys) m_Convoys.ReleaseIntercept(task, partner, now);
if (unit && unit.LogisticsReservation == order.Token) unit.LogisticsReservation = 0;
+ if (partner && partnerUnit && partnerUnit.LogisticsReservation == partner.Token) partnerUnit.LogisticsReservation = 0;
m_Journal.Write("MOUNTED INTERCEPT END", reason, order.ReportId);
}
protected override void ReleaseUnit(ERR_HQUnit unit, float now)
{
- if (m_MountedIntercept && unit == m_MountedInterceptUnit) ReleaseMountedIntercept(now, "original formation released");
+ if (m_MountedIntercept && (unit == m_MountedInterceptUnit || unit == m_MountedInterceptPartnerUnit)) ReleaseMountedIntercept(now, "original formation released");
super.ReleaseUnit(unit, now);
}
+ protected bool MountedMemberCurrent(ERR_HQUnit unit, ERR_ConvoyInterceptOrder order)
+ {
+ if (!order || !unit || !m_Units.Contains(unit) || unit.Group != order.Group) return false;
+ if (unit.LogisticsReservation != order.Token || !unit.StatusSampler || !unit.StatusSampler.OwnsRoster(unit.Group)) return false;
+ return m_Convoys && m_Convoys.InterceptCurrent(m_MountedInterceptTask, order);
+ }
+ protected bool MountedMemberReady(ERR_HQUnit unit, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!MountedMemberCurrent(unit, order)) return false;
+ ERR_ConvoyVehicle vehicle = m_MountedInterceptTask.Vehicles[order.VehicleIndex];
+ return MountedInterceptStatus(unit, vehicle, now) && !ConvoyContact(unit, now);
+ }
protected override void TickMountedIntercept(float now)
{
if (!m_MountedIntercept || !ERR_ForceRules.Finite(now)) return;
- ERR_HQUnit unit = m_MountedInterceptUnit;
- ERR_ConvoyInterceptOrder order = m_MountedIntercept;
+ ref ERR_ConvoyInterceptOrder order = m_MountedIntercept;
+ ref ERR_ConvoyInterceptOrder partner = m_MountedInterceptPartner;
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Runtime("supportSquadLimit"));
- if (now < order.IssuedAt || now >= order.ExpiresAt
- || !MissionActive() || m_Stopping || !unit || !m_Units.Contains(unit) || unit.Group != order.Group
- || unit.LogisticsReservation != order.Token || !unit.StatusSampler || !unit.StatusSampler.OwnsRoster(unit.Group)
+ if (now < order.IssuedAt || now >= order.ExpiresAt || !MissionActive() || m_Stopping
|| Assigned() > globalLimit || HuntingAssigned() > ERR_HQPursuitRules.Limit(globalLimit)
- || !m_Convoys.InterceptCurrent(m_MountedInterceptTask, order))
+ || !MountedMemberCurrent(m_MountedInterceptUnit, order))
{ ReleaseMountedIntercept(now, "expiry, capacity or original ownership changed"); return; }
+ if (partner && !MountedMemberCurrent(m_MountedInterceptPartnerUnit, partner))
+ { ReleaseMountedIntercept(now, "partner ownership changed"); return; }
if (m_MountedInterceptDelivered) return;
ERR_HQBelief belief = m_Memory.GetById(order.BeliefId);
- if (!ERR_HQMountedInterceptRules.Current(order, belief, now) || !m_MountedInterceptRoute)
+ if (!ERR_HQMountedInterceptRules.Current(order, belief, now))
{ ReleaseMountedIntercept(now, "delivered history changed before command arrival"); return; }
- int delivered = m_MountedInterceptRoute.Advance(now, m_Journal);
- if (delivered == 0) return;
- if (delivered < 0)
- { ReleaseMountedIntercept(now, "radio interrupted; authored route retained"); return; }
- ERR_ConvoyVehicle vehicle = m_MountedInterceptTask.Vehicles[order.VehicleIndex];
- if (!MountedInterceptStatus(unit, vehicle, now) || ConvoyContact(unit, now)
- || !m_Convoys.ApplyIntercept(m_MountedInterceptTask, order, now))
+ if (partner && !ERR_HQMountedInterceptRules.Current(partner, belief, now))
+ { ReleaseMountedIntercept(now, "partner command expired"); return; }
+ if (!m_MountedInterceptReceipt)
+ {
+ if (!m_MountedInterceptRoute) { ReleaseMountedIntercept(now, "missing radio route"); return; }
+ int delivered = m_MountedInterceptRoute.Advance(now, m_Journal);
+ if (m_MountedIntercept != order) return;
+ if (delivered < 0) { ReleaseMountedIntercept(now, "radio interrupted; authored route retained"); return; }
+ if (delivered > 0) { m_MountedInterceptReceipt = true; }
+ }
+ if (partner && !m_MountedInterceptPartnerReceipt)
+ {
+ if (!m_MountedInterceptPartnerRoute) { ReleaseMountedIntercept(now, "missing partner route"); return; }
+ int partnerDelivered = m_MountedInterceptPartnerRoute.Advance(now, m_Journal);
+ if (m_MountedIntercept != order || m_MountedInterceptPartner != partner) return;
+ if (partnerDelivered < 0) { ReleaseMountedIntercept(now, "partner radio interrupted"); return; }
+ if (partnerDelivered > 0) { m_MountedInterceptPartnerReceipt = true; }
+ }
+ if (!m_MountedInterceptReceipt || (partner && !m_MountedInterceptPartnerReceipt)) return;
+ if (!MountedMemberReady(m_MountedInterceptUnit, order, now))
{ ReleaseMountedIntercept(now, "command refused by original crew"); return; }
- m_MountedInterceptRoute = null; m_MountedInterceptDelivered = true;
- m_Journal.Write("MOUNTED INTERCEPT DELIVERED", "original armed escort ordered to the remembered road ahead", order.ReportId);
+ if (partner && !MountedMemberReady(m_MountedInterceptPartnerUnit, partner, now))
+ { ReleaseMountedIntercept(now, "command refused by original partner"); return; }
+ bool applied;
+ if (partner) applied = m_Convoys.ApplyInterceptPair(m_MountedInterceptTask, order, partner, now);
+ else applied = m_Convoys.ApplyIntercept(m_MountedInterceptTask, order, now);
+ if (m_MountedIntercept != order || m_MountedInterceptPartner != partner) return;
+ if (!applied) { ReleaseMountedIntercept(now, "physical command refused"); return; }
+ m_MountedInterceptDelivered = true;
+ m_Journal.Write("MOUNTED INTERCEPT DELIVERED", "original armed escorts ordered to remembered road positions", order.ReportId);
}
protected bool MountedInterceptTaskAvailable(ERR_ConvoyTask task)
{
@@ -132,17 +191,109 @@
}
return false;
}
+ protected ERR_HQMountedChoice MountedChoice(ERR_ConvoyTask task, vector destination, ERR_HQUnit excluded, float now, ERR_ConvoyVehicle required = null)
+ {
+ if (!MountedInterceptTaskAvailable(task)) return null;
+ ref ERR_HQMountedChoice best;
+ foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
+ {
+ if (required && vehicle != required) continue;
+ if (!vehicle || (vehicle.Index != 0 && vehicle.Index != 3 && vehicle.Index != 5)
+ || vehicle.Released || vehicle.Arrived || !vehicle.Registered) continue;
+ foreach (ERR_HQUnit unit : m_Units)
+ {
+ if (unit == excluded || unit.Group != vehicle.Group || !MountedLogisticsEligible(unit)
+ || ConvoyContact(unit, now) || !MountedInterceptStatus(unit, vehicle, now)) continue;
+ ERR_HQWatchEntry own = m_Watch.Find(unit.ForceSourceId);
+ float distance = vector.DistanceXZ(own.Position, destination);
+ if (distance > m_Mode.Settings().Runtime("responseRadiusMetres") || (best && distance >= best.Distance)) continue;
+ ref ERR_HQRoute route = m_Network.Route(unit.Radio, true);
+ if (!route) continue;
+ best = new ERR_HQMountedChoice();
+ best.Unit = unit; best.Task = task; best.Vehicle = vehicle; best.Route = route;
+ best.Destination = destination; best.Distance = distance;
+ }
+ }
+ return best;
+ }
+ protected bool HasMountedPairCandidate(float now)
+ {
+ // Cheap original/status gate; no geometry or claims before both roles exist.
+ for (int t = 0; t < m_Convoys.Count(); t++)
+ {
+ ERR_ConvoyTask task = m_Convoys.At(t);
+ if (!MountedInterceptTaskAvailable(task)) continue;
+ ERR_HQUnit firstUnit;
+ foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
+ {
+ if (!vehicle || (vehicle.Index != 0 && vehicle.Index != 3 && vehicle.Index != 5)
+ || vehicle.Released || vehicle.Arrived || !vehicle.Registered) continue;
+ foreach (ERR_HQUnit unit : m_Units)
+ {
+ if (unit.Group != vehicle.Group || !MountedLogisticsEligible(unit) || ConvoyContact(unit, now)
+ || !MountedInterceptStatus(unit, vehicle, now)) continue;
+ if (firstUnit && firstUnit != unit && firstUnit.Group != unit.Group) return true;
+ firstUnit = unit;
+ }
+ }
+ }
+ return false;
+ }
+ protected ERR_ConvoyInterceptOrder MountedOrder(ERR_HQMountedChoice choice, ERR_HQBelief belief, float now, int token)
+ {
+ ERR_ConvoyInterceptOrder order = new ERR_ConvoyInterceptOrder();
+ order.Token = token; order.Group = choice.Unit.Group; order.SourceId = choice.Task.Receipt.SourceId;
+ order.VehicleIndex = choice.Vehicle.Index; order.Position = choice.Destination;
+ order.BeliefId = belief.BeliefId; order.BeliefRevision = belief.BeliefRevision;
+ order.ReportId = belief.MessageId; order.ObservedAt = belief.ObservedAt;
+ order.IssuedAt = now; order.ExpiresAt = belief.ObservedAt + 120000;
+ return order;
+ }
+ protected void QueueMountedIntercept(ERR_HQMountedChoice choice, ERR_HQMountedChoice partnerChoice, ERR_HQBelief belief, float now)
+ {
+ int members = 1;
+ if (partnerChoice) members = 2;
+ if (m_MountedTokenCount + members > 256) return;
+ ref ERR_ConvoyInterceptOrder order = MountedOrder(choice, belief, now, 400001 + m_MountedTokenCount);
+ ref ERR_ConvoyInterceptOrder partner;
+ if (partnerChoice) partner = MountedOrder(partnerChoice, belief, now, 400002 + m_MountedTokenCount);
+ if (!ERR_HQMountedInterceptRules.Current(order, belief, now)) return;
+ if (partner && !ERR_HQMountedInterceptRules.Current(partner, belief, now)) return;
+ // IDs and this revision remain spent after refusal; no post-claim fallback.
+ m_MountedTokenCount += members;
+ m_MountedAttemptBeliefs.Insert(order.BeliefId); m_MountedAttemptRevisions.Insert(order.BeliefRevision);
+ choice.Unit.LogisticsReservation = order.Token;
+ if (partnerChoice) partnerChoice.Unit.LogisticsReservation = partner.Token;
+ m_MountedIntercept = order; m_MountedInterceptUnit = choice.Unit; m_MountedInterceptTask = choice.Task;
+ m_MountedInterceptRoute = choice.Route; m_MountedInterceptDelivered = false; m_MountedInterceptReceipt = false;
+ m_MountedInterceptPartner = partner; m_MountedInterceptPartnerReceipt = false;
+ if (partnerChoice) { m_MountedInterceptPartnerUnit = partnerChoice.Unit; m_MountedInterceptPartnerRoute = partnerChoice.Route; }
+ bool admitted;
+ if (partner) admitted = m_Convoys.ReserveInterceptPair(choice.Task, order, partner, now);
+ else admitted = m_Convoys.ReserveIntercept(choice.Task, order, now);
+ if (m_MountedIntercept != order || m_MountedInterceptPartner != partner) return;
+ if (!admitted) { ReleaseMountedIntercept(now, "reservation refused"); return; }
+ choice.Route.MessageId = m_Journal.Message();
+ choice.Route.Payload = string.Format("original escort %1 to road %2; delivered track %3 revision %4 report M%5", order.VehicleIndex, order.Position, order.BeliefId, order.BeliefRevision, order.ReportId);
+ m_Journal.Write("MOUNTED INTERCEPT QUEUED", choice.Route.Payload, choice.Route.MessageId);
+ if (partnerChoice)
+ {
+ partnerChoice.Route.MessageId = m_Journal.Message();
+ partnerChoice.Route.Payload = string.Format("original partner %1 to road %2; report M%3", partner.VehicleIndex, partner.Position, partner.ReportId);
+ m_Journal.Write("MOUNTED INTERCEPT QUEUED", partnerChoice.Route.Payload, partnerChoice.Route.MessageId);
+ }
+ }
protected override void PlanMountedIntercept(float now)
{
- if (!Replication.IsServer() || m_Stopping || !MissionActive() || m_MountedIntercept || m_MountedAttemptBeliefs.Count() >= 256) return;
+ if (!Replication.IsServer() || m_Stopping || !MissionActive() || m_MountedIntercept
+ || m_MountedAttemptBeliefs.Count() >= 256 || m_MountedTokenCount >= 256) return;
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Runtime("supportSquadLimit"));
- if (Assigned() >= globalLimit || HuntingAssigned() >= ERR_HQPursuitRules.Limit(globalLimit)) return;
- if (!HasMountedInterceptCandidate(now)) return;
+ int freeGlobal = globalLimit - Assigned();
+ int freeHunt = ERR_HQPursuitRules.Limit(globalLimit) - HuntingAssigned();
+ if (freeGlobal < 1 || freeHunt < 1 || !HasMountedInterceptCandidate(now)) return;
+ bool pairPossible = freeGlobal >= 2 && freeHunt >= 2 && m_MountedTokenCount <= 254 && HasMountedPairCandidate(now);
array<vector> reserved = {}; int first = -1; int second = -1;
foreach (ERR_HQUnit known : m_Units) ReserveUnit(known, reserved, first, second);
- // At most two bounded native road queries per normal five-second HQ pass.
- // Continue the next pass after the last surveyed track, so unsuitable roads
- // for older reports cannot indefinitely hide later delivered tracks.
int count = m_Memory.Count();
if (count == 0) return;
int start = m_MountedSurveyCursor % count;
@@ -153,56 +304,43 @@
m_MountedSurveyCursor = (b + 1) % count;
ERR_HQBelief belief = m_Memory.At(b);
if (!ERR_HQMountedInterceptRules.Eligible(belief, now) || MountedInterceptAttempted(belief.BeliefId, belief.BeliefRevision)) continue;
+ if (pairPossible)
+ {
+ vector positionA; vector positionB; queries++;
+ if (ERR_HQMountedInterceptRules.SelectPair(belief, now, reserved, positionA, positionB))
+ {
+ ref ERR_HQMountedChoice bestA; ref ERR_HQMountedChoice bestB;
+ for (int t = 0; t < m_Convoys.Count(); t++)
+ {
+ ERR_ConvoyTask task = m_Convoys.At(t);
+ if (!MountedInterceptTaskAvailable(task)) continue;
+ // Enumerate the bounded original escorts: a greedy nearest first crew
+ // can be the only crew within range of the other road position.
+ foreach (ERR_ConvoyVehicle firstVehicle : task.Vehicles)
+ {
+ if (!firstVehicle || (firstVehicle.Index != 0 && firstVehicle.Index != 3 && firstVehicle.Index != 5)) continue;
+ ref ERR_HQMountedChoice candidateA = MountedChoice(task, positionA, null, now, firstVehicle);
+ if (!candidateA) continue;
+ ref ERR_HQMountedChoice candidateB = MountedChoice(task, positionB, candidateA.Unit, now);
+ if (!candidateB || candidateA.Unit.Group == candidateB.Unit.Group || candidateA.Route == candidateB.Route) continue;
+ if (bestA && candidateA.Distance + candidateB.Distance >= bestA.Distance + bestB.Distance) continue;
+ bestA = candidateA; bestB = candidateB;
+ }
+ }
+ if (bestA) { QueueMountedIntercept(bestA, bestB, belief, now); return; }
+ }
+ }
+ // Only pre-admission fallback, sharing the SAME two-survey pass budget.
+ if (queries >= 2) continue;
vector destination; queries++;
if (!ERR_HQMountedInterceptRules.Select(belief, now, reserved, destination)) continue;
- ERR_HQUnit best;
- ERR_ConvoyTask bestTask;
- ERR_ConvoyVehicle bestVehicle;
- ERR_HQRoute bestRoute;
- float bestDistance = 1e20;
+ ref ERR_HQMountedChoice best;
for (int t = 0; t < m_Convoys.Count(); t++)
{
- ERR_ConvoyTask task = m_Convoys.At(t);
- if (!MountedInterceptTaskAvailable(task)) continue;
- foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
- {
- if (!vehicle || (vehicle.Index != 0 && vehicle.Index != 3 && vehicle.Index != 5)
- || vehicle.Released || vehicle.Arrived || !vehicle.Registered || vehicle.InterceptToken > 0
- || vehicle.SupplyToken > 0 || vehicle.SupplyHeld || vehicle.Combat.Held) continue;
- foreach (ERR_HQUnit unit : m_Units)
- {
- if (unit.Group != vehicle.Group || !MountedLogisticsEligible(unit) || ConvoyContact(unit, now)
- || !MountedInterceptStatus(unit, vehicle, now)) continue;
- ERR_HQWatchEntry own = m_Watch.Find(unit.ForceSourceId);
- float distance = vector.DistanceXZ(own.Position, destination);
- if (distance > m_Mode.Settings().Runtime("responseRadiusMetres") || distance >= bestDistance) continue;
- ERR_HQRoute candidateRoute = m_Network.Route(unit.Radio, true);
- if (!candidateRoute) continue;
- best = unit; bestTask = task; bestVehicle = vehicle; bestRoute = candidateRoute; bestDistance = distance;
- }
- }
- }
- if (!best) continue;
- ERR_HQRoute route = bestRoute;
- if (!route) continue;
- ERR_ConvoyInterceptOrder order = new ERR_ConvoyInterceptOrder();
- order.Token = 400001 + m_MountedAttemptBeliefs.Count(); order.Group = best.Group;
- order.SourceId = bestTask.Receipt.SourceId; order.VehicleIndex = bestVehicle.Index;
- order.Position = destination; order.BeliefId = belief.BeliefId; order.BeliefRevision = belief.BeliefRevision;
- order.ReportId = belief.MessageId; order.ObservedAt = belief.ObservedAt;
- order.IssuedAt = now; order.ExpiresAt = belief.ObservedAt + 120000;
- if (!ERR_HQMountedInterceptRules.Current(order, belief, now)) continue;
- // The attempt stays spent after radio interruption, physical refusal or loss.
- m_MountedAttemptBeliefs.Insert(order.BeliefId); m_MountedAttemptRevisions.Insert(order.BeliefRevision);
- best.LogisticsReservation = order.Token;
- if (!m_Convoys.ReserveIntercept(bestTask, order, now))
- { if (best.LogisticsReservation == order.Token) best.LogisticsReservation = 0; return; }
- m_MountedIntercept = order; m_MountedInterceptUnit = best; m_MountedInterceptTask = bestTask;
- m_MountedInterceptRoute = route; m_MountedInterceptDelivered = false;
- route.MessageId = m_Journal.Message();
- route.Payload = string.Format("original escort %1 to road %2; delivered track %3 revision %4 report M%5", order.VehicleIndex, destination, order.BeliefId, order.BeliefRevision, order.ReportId);
- m_Journal.Write("MOUNTED INTERCEPT QUEUED", route.Payload, route.MessageId);
- return;
+ ref ERR_HQMountedChoice candidate = MountedChoice(m_Convoys.At(t), destination, null, now);
+ if (candidate && (!best || candidate.Distance < best.Distance)) best = candidate;
+ }
+ if (best) { QueueMountedIntercept(best, null, belief, now); return; }
}
}
override void Stop()
--- a/tests/test_hq_mounted_dispatch.py
+++ b/tests/test_hq_mounted_dispatch.py
@@ -3,24 +3,25 @@
from pathlib import Path
import os,re
P=Path(os.environ.get('ERR_HQ_MOUNTED_DISPATCH_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQMountedDispatch.c'))
-names=['override string Status(', 'protected override int Assigned(', 'protected override int HuntingAssigned(', 'protected override void ReserveUnit(', 'protected bool MountedInterceptAttempted(', 'protected bool MountedInterceptStatus(', 'protected void ReleaseMountedIntercept(', 'protected override void ReleaseUnit(', 'protected override void TickMountedIntercept(', 'protected bool MountedInterceptTaskAvailable(', 'protected bool HasMountedInterceptCandidate(', 'protected override void PlanMountedIntercept(', 'override void Stop(']
+names=['protected bool MountedMemberCurrent(', 'protected bool MountedMemberReady(', 'protected ERR_HQMountedChoice MountedChoice(', 'protected bool HasMountedPairCandidate(', 'protected ERR_ConvoyInterceptOrder MountedOrder(', 'protected void QueueMountedIntercept(', 'override string Status(', 'protected override int Assigned(', 'protected override int HuntingAssigned(', 'protected override void ReserveUnit(', 'protected bool MountedInterceptAttempted(', 'protected bool MountedInterceptStatus(', 'protected void ReleaseMountedIntercept(', 'protected override void ReleaseUnit(', 'protected override void TickMountedIntercept(', 'protected bool MountedInterceptTaskAvailable(', 'protected bool HasMountedInterceptCandidate(', 'protected override void PlanMountedIntercept(', 'override void Stop(']
def cpp(s):
s=s.replace('protected ','').replace('override ','').replace('ref ','').replace('out int ','int& ').replace('super.','Base::').replace('null','nullptr').replace('foreach (','for (')
- for typ in ['ERR_ConvoyInterceptOrder','ERR_HQUnit','ERR_ConvoyTask','ERR_HQRoute','ERR_ConvoyVehicle','ERR_HQWatchEntry','ERR_HQBelief']:
+ for typ in ['ERR_ConvoyInterceptOrder','ERR_HQUnit','ERR_ConvoyTask','ERR_HQRoute','ERR_ConvoyVehicle','ERR_HQWatchEntry','ERR_HQBelief','ERR_HQMountedChoice']:
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
s=s.replace('array<vector> reserved','Array<vector> reserved')
for typ in ['Replication','ERR_ForceRules','ERR_HQMemoryRules','ERR_HQPursuitRules','ERR_HQMountedInterceptRules','vector','string']:
s=s.replace(typ+'.',typ+'::')
- for var in ['unit','vehicle','entry','order','task','belief','known','best','bestTask','bestVehicle','route','candidateRoute','own','m_Mode','m_Watch','m_Memory','m_Convoys','m_Journal','m_Network','m_MountedIntercept','m_MountedInterceptTask','m_MountedInterceptRoute']:
+ for var in ['unit','vehicle','entry','order','task','belief','known','best','bestTask','bestVehicle','route','candidateRoute','own','m_Mode','m_Watch','m_Memory','m_Convoys','m_Journal','m_Network','m_MountedIntercept','m_MountedInterceptTask','m_MountedInterceptRoute','required','firstVehicle','partner','partnerUnit','firstUnit','excluded','choice','partnerChoice','bestA','bestB','candidateA','candidateB','candidate','m_MountedInterceptPartner','m_MountedInterceptPartnerUnit','m_MountedInterceptPartnerRoute']:
s=re.sub(r'\b'+var+r'\.',var+'->',s)
- for name in ['StatusSampler','MountedAmmo','Combat','Receipt']:
+ for name in ['Unit','Task','Vehicle','Route','StatusSampler','MountedAmmo','Combat','Receipt']:
s=s.replace('->'+name+'.','->'+name+'->')
s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace('Settings().','Settings()->')
s=re.sub(r'(ERR_\w+\*) (\w+);',r'\1 \2 = nullptr;',s)
+ s=s.replace('bool applied;', 'bool applied=false;').replace('bool admitted;', 'bool admitted=false;')
s=s.replace('int queries;','int queries=0;').replace('float now;','float now=0;')
s=s.replace('Array<vector> reserved,','Array<vector>& reserved,')
return s
-methods='\n'.join(cpp(body(P,n)) for n in names if n not in ('protected bool HasMountedInterceptCandidate(', 'protected bool MountedInterceptTaskAvailable(') or n in P.read_text())
+methods='\n'.join(cpp(body(P,n)) for n in names if n in P.read_text())
run(r'''
#include <cassert>
#include <vector>
@@ -47,10 +48,12 @@
struct ERR_HQRoute{int result=0,calls=0,MessageId=0;string Payload;int Advance(float,Journal*){calls++;return result;}};
struct Watch{ERR_HQWatchEntry*entry=nullptr,*second=nullptr;int SquadLimit(int n){return n;}ERR_HQWatchEntry*Find(string id){return entry&&entry->SourceId==id?entry:(second&&second->SourceId==id?second:nullptr);}};
struct Memory{Array<ERR_HQBelief*>entries;int Count(){return entries.Count();}ERR_HQBelief*At(int n){return entries[n];}ERR_HQBelief*GetById(int n){for(auto b:entries)if(b->BeliefId==n)return b;return nullptr;}};
-struct Network{ERR_HQRoute*route=nullptr;Entity*denied=nullptr;int calls=0;ERR_HQRoute*Route(Entity*radio,bool){calls++;return radio==denied?nullptr:route;}};
+struct Network{std::map<Entity*,ERR_HQRoute*>routes;ERR_HQRoute*route=nullptr;Entity*denied=nullptr;int calls=0;ERR_HQRoute*Route(Entity*radio,bool){calls++;return radio==denied?nullptr:(routes.count(radio)?routes[radio]:route);}};
struct Settings{int cap=3,radius=1000;int Runtime(string s){return s=="supportSquadLimit"?cap:radius;}};
struct Mode{struct Settings settings;struct Settings*Settings(){return &settings;}};
-struct Service{Array<ERR_ConvoyTask*>tasks;bool current=true,reserve=true,apply=true;int reserves=0,applies=0,releases=0;std::function<void()>onRelease;
+struct Service{Array<ERR_ConvoyTask*>tasks;bool current=true,reserve=true,apply=true;int pairReserves=0,pairApplies=0;std::function<void()>onReserve,onApply;int reserves=0,applies=0,releases=0;std::function<void()>onRelease;
+ bool ReserveInterceptPair(ERR_ConvoyTask*,ERR_ConvoyInterceptOrder*a,ERR_ConvoyInterceptOrder*b,float){pairReserves++;assert(a->Token!=b->Token&&a->Group!=b->Group);if(onReserve)onReserve();return reserve;}
+ bool ApplyInterceptPair(ERR_ConvoyTask*,ERR_ConvoyInterceptOrder*,ERR_ConvoyInterceptOrder*,float){pairApplies++;if(onApply)onApply();return apply;}
int Count(){return tasks.Count();}ERR_ConvoyTask*At(int n){return tasks[n];}
bool InterceptCurrent(ERR_ConvoyTask*,ERR_ConvoyInterceptOrder*){return current;}
bool ReserveIntercept(ERR_ConvoyTask*,ERR_ConvoyInterceptOrder*,float){reserves++;return reserve;}
@@ -61,14 +64,18 @@
struct ERR_ForceRules{static bool Finite(float n){return std::isfinite(n);}static bool FiniteVector(vector v){return Finite(v.x)&&Finite(v.z);}};
struct ERR_HQMemoryRules{static bool DeliveryFresh(float observed,float now){return std::isfinite(observed)&&std::isfinite(now)&&observed>=0&&now>=observed&&now-observed<=120000;}};
struct ERR_HQPursuitRules{static int Limit(int n){return std::min(n,3);}};
-struct ERR_HQMountedInterceptRules{inline static int selects=0;static bool Eligible(ERR_HQBelief*b,float){return b&&b->eligible;}
- static bool Current(ERR_ConvoyInterceptOrder*o,ERR_HQBelief*b,float now){return Eligible(b,now)&&o->BeliefId==b->BeliefId&&o->BeliefRevision==b->BeliefRevision&&now>=o->IssuedAt&&now<o->ExpiresAt;}
+struct ERR_HQMountedInterceptRules{inline static int selects=0,pairSelects=0;inline static bool pairGeometry=true;static bool Eligible(ERR_HQBelief*b,float){return b&&b->eligible;}
+ static bool Current(ERR_ConvoyInterceptOrder*o,ERR_HQBelief*b,float now){return Eligible(b,now)&&o->BeliefId==b->BeliefId&&o->BeliefRevision==b->BeliefRevision&&now>=o->IssuedAt&&now-o->IssuedAt<=30000&&now<o->ExpiresAt;}
+ static bool SelectPair(ERR_HQBelief*b,float,Array<vector>&,vector&a,vector&c){pairSelects++;a=b->destination;c={a.x+60,a.z};return b->select&&pairGeometry;}
static bool Select(ERR_HQBelief*b,float,Array<vector>&reserved,vector&dest){selects++;dest=b->destination;return b->select&&!reserved.Contains(dest);}};
struct Clock{Clock*GetWorld(){return this;}float GetWorldTime(){return 3000;}}clock_;Clock*GetGame(){return &clock_;}
struct Base{virtual string Status(){return "base HQ status";}int assigned=0,hunting=0,releaseUnits=0,stops=0;Array<vector>destinations;virtual int Assigned(){return assigned;}virtual int HuntingAssigned(){return hunting;}
virtual void ReserveUnit(ERR_HQUnit*,Array<vector>&r,int&,int&){for(auto p:destinations)r.Insert(p);}virtual void ReleaseUnit(ERR_HQUnit*,float){releaseUnits++;}virtual void Stop(){stops++;}};
+struct ERR_HQMountedChoice{ERR_HQUnit*Unit=nullptr;ERR_ConvoyTask*Task=nullptr;ERR_ConvoyVehicle*Vehicle=nullptr;ERR_HQRoute*Route=nullptr;vector Destination;float Distance=0;};
struct HQ:Base{
ERR_ConvoyInterceptOrder*m_MountedIntercept=nullptr;ERR_HQUnit*m_MountedInterceptUnit=nullptr;ERR_ConvoyTask*m_MountedInterceptTask=nullptr;ERR_HQRoute*m_MountedInterceptRoute=nullptr;bool m_MountedInterceptDelivered=false,m_Stopping=false,active=true;
+ ERR_ConvoyInterceptOrder*m_MountedInterceptPartner=nullptr;ERR_HQUnit*m_MountedInterceptPartnerUnit=nullptr;ERR_HQRoute*m_MountedInterceptPartnerRoute=nullptr;
+ bool m_MountedInterceptReceipt=false,m_MountedInterceptPartnerReceipt=false;int m_MountedTokenCount=0;
int m_MountedSurveyCursor=0;
Array<int>m_MountedAttemptBeliefs,m_MountedAttemptRevisions;Array<ERR_HQUnit*>m_Units;Watch*m_Watch;Mode*m_Mode;Service*m_Convoys;Memory*m_Memory;Journal*m_Journal;Network*m_Network;
bool MissionActive(){return active;}bool MountedLogisticsEligible(ERR_HQUnit*u){return u&&u->eligible&&!u->LogisticsReservation;}bool ConvoyContact(ERR_HQUnit*u,float){return u->contact;}
@@ -78,13 +85,17 @@
Fixture(){entry.MountedAmmo=&ammo;unit.Group=&group;unit.Radio=&radio;unit.StatusSampler=&sampler;vehicle.Group=&group;vehicle.Entity=&entity;vehicle.Combat=&combat;unused.Combat=&combat;unused.Released=true;task.Receipt=&receipt;task.Vehicles={&vehicle,&unused,&unused,&unused,&unused,&unused};service.tasks={&task};memory.entries={&belief};watch.entry=&entry;network.route=&route;h.m_Units={&unit};h.m_Watch=&watch;h.m_Mode=&mode;h.m_Convoys=&service;h.m_Memory=&memory;h.m_Journal=&journal;h.m_Network=&network;}
void plan(){h.PlanMountedIntercept(3000);}void queued(){plan();assert(h.m_MountedIntercept&&unit.LogisticsReservation==400001);assert(service.reserves==1&&service.applies==0&&route.calls==0);}
};
+struct PairFixture:Fixture{Group group2;Entity entity2,radio2;Sampler sampler2;ERR_HQUnit unit2;ERR_ConvoyVehicle vehicle2;ERR_HQWatchEntry entry2;ERR_HQRoute route2;
+ PairFixture(){unit2=unit;unit2.Group=&group2;unit2.Radio=&radio2;unit2.StatusSampler=&sampler2;unit2.ForceSourceId="partner";entry2=entry;entry2.SourceId="partner";entry2.Position={200,0};watch.second=&entry2;vehicle2=vehicle;vehicle2.Index=3;vehicle2.Group=&group2;vehicle2.Entity=&entity2;task.Vehicles[3]=&vehicle2;h.m_Units.Insert(&unit2);network.routes[&radio2]=&route2;ERR_HQMountedInterceptRules::pairGeometry=true;}
+ void pair(){plan();assert(h.m_MountedIntercept&&h.m_MountedInterceptPartner);assert(h.m_MountedInterceptTask==&task);assert(unit.LogisticsReservation==400001&&unit2.LogisticsReservation==400002);assert(service.pairReserves==1&&service.reserves==0&&service.pairApplies==0);}
+};
int main(){
{Fixture f;assert(f.h.Status()=="base HQ status");f.queued();string queued=f.h.Status();assert(queued.find("base HQ status")!=string::npos&&queued.find("|0|queued|20|100,0")!=string::npos);f.route.result=1;f.h.TickMountedIntercept(4000);assert(f.h.Status().find("|0|delivered|20|100,0")!=string::npos);f.h.Stop();assert(f.h.Status()=="base HQ status");}
{Fixture f;f.queued();assert(f.h.Assigned()==1&&f.h.HuntingAssigned()==1);Array<vector>r;int a=-1,b=-1;f.h.ReserveUnit(&f.unit,r,a,b);assert(r.Count()==1&&r[0]==f.belief.destination);f.h.PlanMountedIntercept(4000);assert(f.service.reserves==1);f.h.TickMountedIntercept(4000);assert(f.route.calls==1&&f.service.applies==0);f.route.result=1;f.h.TickMountedIntercept(5000);assert(f.service.applies==1&&f.h.m_MountedInterceptDelivered);f.belief.BeliefRevision++;f.h.TickMountedIntercept(6000);assert(f.h.m_MountedInterceptDelivered&&f.service.releases==0);f.h.TickMountedIntercept(121000);assert(!f.h.m_MountedIntercept&&f.unit.LogisticsReservation==0);}
// Task-wide logistics and queued sibling downlinks cannot consume a revision or starve a free farther convoy.
for(int kind=0;kind<5;kind++){Fixture f,far;far.unit.ForceSourceId=far.entry.SourceId="crew2";far.receipt.SourceId="convoy2";far.entry.Position={-200,0};f.watch.second=&far.entry;f.service.tasks.Insert(&far.task);f.h.m_Units.Insert(&far.unit);ERR_HQUnit sibling; sibling.Group=&f.otherGroup;f.unused.Group=&f.otherGroup;f.h.m_Units.Insert(&sibling);if(kind==0)f.unused.SupplyToken=123;if(kind==1)f.unused.SupplyHeld=true;if(kind==2)sibling.PendingConvoyOrder=true;if(kind==3)sibling.Downlink=true;if(kind==4)sibling.LogisticsReservation=123;f.plan();assert(f.h.m_MountedIntercept&&f.h.m_MountedInterceptUnit==&far.unit&&!f.unit.LogisticsReservation&&far.unit.LogisticsReservation==400001&&f.service.reserves==1);}
// A disconnected nearest escort cannot starve the farther reachable original escort.
- {Fixture f;Group group2;Entity radio2,entity2;ERR_HQUnit u2=f.unit;u2.Group=&group2;u2.Radio=&radio2;u2.ForceSourceId="crew2";ERR_HQWatchEntry entry2=f.entry;entry2.SourceId="crew2";entry2.Position={-200,0};f.watch.second=&entry2;ERR_ConvoyVehicle vehicle2=f.vehicle;vehicle2.Group=&group2;vehicle2.Entity=&entity2;vehicle2.Index=3;f.task.Vehicles[3]=&vehicle2;f.h.m_Units.Insert(&u2);f.network.denied=&f.radio;f.plan();assert(f.h.m_MountedIntercept&&f.h.m_MountedInterceptUnit==&u2&&f.h.m_MountedIntercept->VehicleIndex==3&&f.network.calls==2&&!f.unit.LogisticsReservation&&u2.LogisticsReservation==400001);}
+ {Fixture f;Group group2;Entity radio2,entity2;ERR_HQUnit u2=f.unit;u2.Group=&group2;u2.Radio=&radio2;u2.ForceSourceId="crew2";ERR_HQWatchEntry entry2=f.entry;entry2.SourceId="crew2";entry2.Position={-200,0};f.watch.second=&entry2;ERR_ConvoyVehicle vehicle2=f.vehicle;vehicle2.Group=&group2;vehicle2.Entity=&entity2;vehicle2.Index=3;f.task.Vehicles[3]=&vehicle2;f.h.m_Units.Insert(&u2);f.network.denied=&f.radio;f.plan();assert(f.h.m_MountedIntercept&&f.h.m_MountedInterceptUnit==&u2&&f.h.m_MountedIntercept->VehicleIndex==3&&f.network.calls>=2&&!f.unit.LogisticsReservation&&u2.LogisticsReservation==400001);}
// Invalid world clock must preserve the exact claim for the next finite tick.
{Fixture f;f.queued();f.h.TickMountedIntercept(std::nanf(""));assert(f.h.m_MountedIntercept&&f.unit.LogisticsReservation==400001&&f.service.releases==0&&f.route.calls==0);}
// No own vehicle/fresh crew evidence means no native road surveys.
@@ -114,6 +125,40 @@
// Full bounded memory: every pass has at most two surveys, no failed revision is consumed.
{Fixture f;ERR_HQBelief beliefs[64];f.memory.entries.clear();for(int i=0;i<64;i++){beliefs[i].BeliefId=i+1;beliefs[i].select=false;f.memory.entries.Insert(&beliefs[i]);}for(int pass=0;pass<32;pass++){int before=ERR_HQMountedInterceptRules::selects;float now=3000+pass*5000;f.entry.ObservedAt=f.entry.LastDeliveredAt=now;f.h.PlanMountedIntercept(now);assert(ERR_HQMountedInterceptRules::selects-before==2&&f.h.m_MountedSurveyCursor==((pass+1)*2)%64&&f.h.m_MountedAttemptBeliefs.empty());}assert(!f.h.m_MountedIntercept&&f.service.reserves==0);}
+ // Paired admission counts and reserves both original slots; one receipt never detours either.
+ {PairFixture f;f.pair();assert(f.h.Assigned()==2&&f.h.HuntingAssigned()==2);Array<vector>r;int a=-1,b=-1;f.h.ReserveUnit(&f.unit,r,a,b);f.h.ReserveUnit(&f.unit2,r,a,b);assert(r.Count()==2&&vector::DistanceXZ(r[0],r[1])>=40);assert(f.h.Status().find("Paired")!=string::npos);
+ f.route.result=1;f.h.TickMountedIntercept(4000);assert(f.h.m_MountedInterceptReceipt&&!f.h.m_MountedInterceptPartnerReceipt&&!f.h.m_MountedInterceptDelivered&&f.service.pairApplies==0&&f.service.applies==0);f.h.TickMountedIntercept(4500);assert(f.route.calls==1&&f.route2.calls==2&&f.h.m_MountedInterceptRoute==&f.route&&f.h.m_MountedInterceptPartnerRoute==&f.route2);
+ f.route2.result=1;f.h.TickMountedIntercept(5000);assert(f.service.pairApplies==1&&f.h.m_MountedInterceptDelivered&&f.h.m_MountedIntercept->ExpiresAt==121000&&f.h.m_MountedInterceptPartner->ExpiresAt==121000);f.h.TickMountedIntercept(6000);assert(f.service.pairApplies==1);f.h.TickMountedIntercept(121000);assert(!f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner&&!f.unit.LogisticsReservation&&!f.unit2.LogisticsReservation&&f.service.releases==2);}
+ // Either receipt may arrive first; interrupted/expired/revised/changed ownership never creates half a pair.
+ for(int kind=0;kind<9;kind++){PairFixture f;f.pair();f.route2.result=1;f.h.TickMountedIntercept(4000);assert(f.h.m_MountedInterceptPartnerReceipt&&f.service.pairApplies==0);float now=5000;
+ if(kind==0)f.route.result=-1;
+ if(kind==1)now=121000;
+ if(kind==2)f.belief.BeliefRevision++;
+ if(kind==3)f.unit2.LogisticsReservation=999;
+ if(kind==4)f.unit2.contact=true;
+ if(kind==5)f.entry2.Fit--;
+ if(kind==6)f.sampler2.bound=false;
+ if(kind==7)f.h.assigned=2;
+ if(kind==8)now=34000;
+ if(kind!=0)f.route.result=1;
+ f.h.TickMountedIntercept(now);assert(!f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner&&f.service.pairApplies==0&&f.service.applies==0&&!f.unit.LogisticsReservation);assert(f.unit2.LogisticsReservation==(kind==3?999:0));}
+ // Native preparation failure settles BOTH claims. No retry or singleton replacement after commitment.
+ {PairFixture f;f.pair();f.route.result=f.route2.result=1;f.service.apply=false;f.h.TickMountedIntercept(5000);assert(f.service.pairApplies==1&&!f.h.m_MountedIntercept&&!f.unit.LogisticsReservation&&!f.unit2.LogisticsReservation);f.plan();assert(f.service.pairReserves==1&&f.service.reserves==0);}
+ {PairFixture f;f.service.reserve=false;f.plan();assert(f.service.pairReserves==1&&f.service.reserves==0&&!f.unit.LogisticsReservation&&!f.unit2.LogisticsReservation&&f.h.m_MountedTokenCount==2);f.service.reserve=true;f.plan();assert(f.service.pairReserves==1);f.belief.BeliefRevision++;f.plan();assert(f.h.m_MountedIntercept->Token==400003&&f.h.m_MountedInterceptPartner->Token==400004);}
+ // Stop/reentrant unregister detach both owned handles before external release; unrelated claims survive.
+ {PairFixture f;f.pair();f.unit2.LogisticsReservation=999;f.service.onRelease=[&]{assert(!f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner);f.h.ReleaseUnit(&f.unit2,5000);};f.h.Stop();f.h.Stop();assert(f.service.releases==2&&!f.unit.LogisticsReservation&&f.unit2.LogisticsReservation==999);}
+ {PairFixture f;f.pair();f.route.result=f.route2.result=1;f.service.onApply=[&]{f.h.Stop();};f.h.TickMountedIntercept(5000);assert(!f.h.m_MountedIntercept&&!f.h.m_MountedInterceptDelivered&&!f.unit.LogisticsReservation&&!f.unit2.LogisticsReservation);}
+ // Two free slots and two IDs are required for pair. Singleton fallback occurs only before claims.
+ for(int kind=0;kind<5;kind++){PairFixture f;if(kind==0)f.h.assigned=2;if(kind==1)f.h.hunting=2;if(kind==2)f.h.m_MountedTokenCount=255;if(kind==3)f.network.denied=&f.radio2;if(kind==4)ERR_HQMountedInterceptRules::pairGeometry=false;int before=ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects;f.plan();assert(f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner&&f.service.reserves==1&&f.service.pairReserves==0);assert(ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects-before<=2);}
+ {PairFixture f;f.h.m_MountedTokenCount=256;int before=ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects;f.plan();assert(!f.h.m_MountedIntercept&&ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects==before);}
+ // A valid reversed assignment must win over greedy singleton fallback (same bounded road survey).
+ {PairFixture f;f.mode.settings.radius=800;f.belief.destination={0,0};f.entry.Position={0,0};f.entry2.Position={-800,0};int before=ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects;f.plan();assert(f.h.m_MountedInterceptPartner&&f.h.m_MountedInterceptUnit==&f.unit2&&f.h.m_MountedInterceptPartnerUnit==&f.unit);assert(ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects-before==1);}
+ // Stop during reservation cannot resurrect a job after external callback returns.
+ {PairFixture f;f.service.onReserve=[&]{f.h.Stop();};f.plan();assert(!f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner&&!f.unit.LogisticsReservation&&!f.unit2.LogisticsReservation&&f.service.releases==2);}
+ // No mixed-convoy pair; both geometric failures still cost at most two queries, no attempts/tokens.
+ {PairFixture f;ERR_ConvoyTask other=f.task;other.Vehicles[0]=&f.unused;f.task.Vehicles[3]=&f.unused;f.service.tasks.Insert(&other);f.plan();assert(f.h.m_MountedIntercept&&!f.h.m_MountedInterceptPartner&&f.service.pairReserves==0);}
+ {PairFixture f;ERR_HQBelief b2;b2.BeliefId=2;f.belief.select=b2.select=false;f.memory.entries.Insert(&b2);int before=ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects;f.plan();assert(ERR_HQMountedInterceptRules::pairSelects+ERR_HQMountedInterceptRules::selects-before==2&&!f.h.m_MountedIntercept&&f.h.m_MountedAttemptBeliefs.empty()&&f.h.m_MountedTokenCount==0);}
+
}
''')
print('actual mounted HQ dispatch planning, radio, ownership and lifecycle checks passed')