Capture current mission, AI, map and administration development

Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
@@ -0,0 +1,11 @@
Physical motorized interception ownership hooks, proposal only.
fix.patch: four production files (ConvoyService, SupplyConvoyService, SupplyConvoyColumn, SupplyConvoyTactics) and four existing regression programs. No registration needed. Requires root-owned ERR_ConvoyInterceptOrder and HQ dispatch integration. Public ReserveIntercept/ApplyIntercept/ReleaseIntercept(task,order,now); InterceptCurrent(task,order). HQ must set canonical LogisticsReservation to400001..400256 before Reserve. Last token monotonically increases per original vehicle, preventing replay after refusal/release.
Pending claim leaves ordinary route waypoint/index/deadline unchanged. Delivered claim freezes current route index, replaces only ordinary route waypoint with a separate owned detour move waypoint, and removes that exact detour at own-vehicle distance<=15m. Original seated crew stays held without further move waypoint until fixed report-derived expiry. Release/Stop cleans only exact order/token/owned waypoint and does not actively issue a route command. Normal active convoy Tick resumes afterward; only delivered pause resets travel deadline. Original task receipt, route, force record, entity/group/driver/member/seat identities retained; native agents must identify each original member exactly once. No actors/stock/vehicles spawned; only native move waypoint.
Supply column excludes delivered detours from leader/follower/gap traversal, including watchdog timing. Pending units remain ordinary column members. On return, stable partition places detached escorts behind surviving non-detached column members. A surviving current leader stays leader; if it dies on the return tick, a surviving non-detached follower leads instead, so an older saved index cannot rewind everyone. Escort follows/rejoins without changing its own saved index merely for return. If the interceptor is sole surviving vehicle, the column stays alive and it resumes its own saved index. Subsequent ordinary travel/arrival semantics remain canonical. Native driving/rejoining/hold effectiveness unqualified; no virtual movement or teleport.
Intercept admission refuses any logistics/combat hold across original task and any active tactics. Reverse HQ logistics guards are root-owned. Radio combat order refuses an active/pending intercept. Native local combat Start cancels exact owned intercept before taking any route/tactics orders, and refuses takeover of foreign token. Crew loss/capture/arrival/expiry cancels appropriately; actual waypoint cleanup never deletes a substituted foreign InterceptWaypoint handle.
Six focused programs pass (final-results.json/logs), including actual full vehicle and service methods, complete native combat Start, two-point rejoin and sole-survivor cases. Pending deadline mutation compiles then fails runtime assertion, demonstrating preserved pending route semantics. Before-working/index SHA256 recorded in baseline.json; final source-manifest.json and successful apply-check.log provided. No target writes/staging/native run.
@@ -0,0 +1,34 @@
{
"addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c": {
"working_sha256": "e1d5dff5584f1bf12c146df510c860c3e0e046c989ff758def88ecf6b49c6392",
"index_sha256": "e1d5dff5584f1bf12c146df510c860c3e0e046c989ff758def88ecf6b49c6392"
},
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c": {
"working_sha256": "dca3fc09fc11cbc719eed38edaf9b141ba86f440b4c5c28579699fbb296d0b6c",
"index_sha256": "dca3fc09fc11cbc719eed38edaf9b141ba86f440b4c5c28579699fbb296d0b6c"
},
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c": {
"working_sha256": "827550ab1b3fd749414f064d20849e23b4ff0ac2d39585cc8d4388df581ca6f4",
"index_sha256": "827550ab1b3fd749414f064d20849e23b4ff0ac2d39585cc8d4388df581ca6f4"
},
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c": {
"working_sha256": "78e6b60596189dfe1ee64c9471c62aae5c494ebd9942cb11520babab7c01873c",
"index_sha256": "78e6b60596189dfe1ee64c9471c62aae5c494ebd9942cb11520babab7c01873c"
},
"tests/test_convoy_column.py": {
"working_sha256": "a626be183761fb823cbb78e287cb410583500e37fd7d0ee96041d539a0af36e2",
"index_sha256": "a626be183761fb823cbb78e287cb410583500e37fd7d0ee96041d539a0af36e2"
},
"tests/test_convoy_combat.py": {
"working_sha256": "b31239028d6fa0b43812c67eb3532ff1b3f4a87b43092dfed6b98de90f8b3a68",
"index_sha256": "b31239028d6fa0b43812c67eb3532ff1b3f4a87b43092dfed6b98de90f8b3a68"
},
"tests/test_convoy_lifecycle.py": {
"working_sha256": "1f1a7f938bbb618c21889199c48cb512fb3e21a5087708fe8361400762fb1472",
"index_sha256": "1f1a7f938bbb618c21889199c48cb512fb3e21a5087708fe8361400762fb1472"
},
"tests/test_supply_convoys.py": {
"working_sha256": "19309aff2b38f59eae2f5637960f133b78957962ad2dd95527a847692f127e74",
"index_sha256": "19309aff2b38f59eae2f5637960f133b78957962ad2dd95527a847692f127e74"
}
}
@@ -0,0 +1,8 @@
{
"test_convoy_lifecycle.py": 0,
"test_convoy_combat.py": 0,
"test_convoy_column.py": 0,
"test_supply_convoys.py": 0,
"test_convoy_admission.py": 0,
"test_convoy_dispatch.py": 0
}
@@ -0,0 +1,2 @@
PASS: actual native readiness exact membership, one-time per-vehicle ledger admission/reservation, partial readiness retry without duplicate records and one-pass56-item cargo allocation without replenishment
PASS: actual per-vehicle readiness reasons, native agent/activity counts, intended-seat diagnostics, five-second throttle and read-only observation
@@ -0,0 +1,6 @@
PASS: production convoy spacing, physical arrival, stable follower targets, supply ownership, leader promotion and bounded stuck recovery
PASS: convoy combat keeps native manoeuvres for unchanged contacts and refreshes when contact moves
PASS: native convoy proof excludes pre-departure movement and ends via explicit fixture reset, not prohibited player death
PASS: boarding retries only after approach/seat progress stops, capped at three, with delayed native reissue and completed-team preservation
PASS: slow only owned vehicles near fit dismounted infantry; ignore passengers, casualties and released teams/vehicles
PASS: restore missing native driving registration only for healthy owned seated crews; preserve existing association and refuse invalid/released vehicles
@@ -0,0 +1 @@
PASS: actual delivered cover freshness/lineage/replay and canonical executor; owned waypoint-only hold, one resume at preserved route index, invalid ownership/crew/ledger refusal and fail-closed native recreation
@@ -0,0 +1,2 @@
PASS: actual convoy dispatch validates before receipt consumption, consumes once before native mutation, retains all12 partial-failure paths, accepts the selected non-USSR enemy, and allocates exact19 bodies without capacity shrinking
PASS: own memberless prefab disables automatic spawn before initialization; actual admission rejects populated, immediate or authored-slot templates before finite crew creation
@@ -0,0 +1 @@
PASS: actual native seated-group arrival outside35m, native waypoint removal, idempotent advance, and convoy waypoint idempotence/arrival/deadline, missing/dead/captured/reparented crew refusal, failed native waypoint release, original vehicle/member preservation and no replacement
@@ -0,0 +1,4 @@
PASS: actual supply route cycling/arrival/stops, finite startup bounds, contact freshness, dismount hold, quiet interval, interrupted boarding, timeout and lost-truck release
PASS: moving-convoy perception boundaries and scoped individual/group abandonment priorities
PASS: actual supply startup reasons expose unchanged body budget/requirement, journal only failure changes, preserve retry cadence/caps and report the true final refusal
PASS: actual combat start releases only exact intercept claims before owning route/orders; foreign claim defers combat takeover
@@ -0,0 +1,581 @@
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
@@ -119,10 +119,108 @@
ref ERR_ConvoyCombatState Combat = new ERR_ConvoyCombatState();
int SupplyToken;
bool SupplyHeld;
+ int InterceptToken;
+ bool InterceptActive;
+ AIWaypoint InterceptWaypoint;
+ ref ERR_ConvoyInterceptOrder InterceptOrder;
+ ERR_ConvoyReceipt InterceptReceipt;
+ ERR_ForceRecord InterceptRecord;
+ ERR_ForceRoute InterceptRoute;
+ protected AIWaypoint m_InterceptWaypoint;
+ protected Vehicle m_InterceptEntity;
+ protected SCR_AIGroup m_InterceptGroup;
+ protected IEntity m_InterceptDriver;
+ protected ref array<IEntity> m_InterceptMembers = {};
+ protected ref array<BaseCompartmentSlot> m_InterceptSeats = {};
+ protected int m_InterceptToken;
+ protected int m_LastInterceptToken;
+ protected vector m_InterceptPosition;
+ protected float m_InterceptExpiresAt;
+ protected int m_InterceptRouteIndex;
+
bool Arrived;
float LegDeadline;
float NextReadinessNote;
+ // Pending downlink owns a claim only; normal authored travel continues.
+ bool InterceptCrew(ERR_GameMode mode)
+ {
+ if (!mode || !CrewValid(mode) || !Entity || !Group || ERR_ConvoyRules.Captured(Entity, Group)) return false;
+ if (Members.IsEmpty() || Members.Count() > 8 || Seats.Count() != Members.Count()) return false;
+ if (InterceptOrder && (Members.Count() != m_InterceptMembers.Count() || Seats.Count() != m_InterceptSeats.Count())) return false;
+ array<AIAgent> agents = {}; Group.GetAgents(agents);
+ if (agents.Count() != Members.Count()) return false;
+ for (int i = 0; i < Members.Count(); i++)
+ {
+ if (!Members[i] || !ERR_Actors.CanFight(Members[i]) || !Seats[i] || Seats[i].GetOccupant() != Members[i]) return false;
+ int attributed;
+ foreach (AIAgent agent : agents)
+ {
+ if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;
+ if (agent.GetControlledEntity() == Members[i]) attributed++;
+ }
+ if (attributed != 1) return false;
+ if (InterceptOrder && (Members[i] != m_InterceptMembers[i] || Seats[i] != m_InterceptSeats[i])) return false;
+ }
+ if (InterceptOrder && (Entity != m_InterceptEntity || Group != m_InterceptGroup || Driver != m_InterceptDriver)) return false;
+ return true;
+ }
+ bool InterceptMatches(ERR_ConvoyInterceptOrder order)
+ {
+ return order && InterceptOrder == order && InterceptToken == m_InterceptToken && m_InterceptToken != 0;
+ }
+ bool ClaimIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order)
+ {
+ if (!order || order.Token <= m_LastInterceptToken || InterceptToken != 0 || InterceptOrder || InterceptWaypoint || !InterceptCrew(mode)) return false;
+ m_InterceptEntity = Entity; m_InterceptGroup = Group; m_InterceptDriver = Driver;
+ m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
+ foreach (IEntity member : Members) m_InterceptMembers.Insert(member);
+ foreach (BaseCompartmentSlot seat : Seats) m_InterceptSeats.Insert(seat);
+ m_InterceptToken = order.Token; m_LastInterceptToken = order.Token; InterceptToken = order.Token; InterceptOrder = order;
+ m_InterceptPosition = order.Position; m_InterceptExpiresAt = order.ExpiresAt;
+ return true;
+ }
+ void ClearIntercept(ERR_ConvoyInterceptOrder order)
+ {
+ if (!InterceptMatches(order)) return;
+ // Delete only the exact waypoint this owner created, never an adopted handle.
+ if (m_InterceptWaypoint && m_InterceptGroup) m_InterceptGroup.RemoveWaypoint(m_InterceptWaypoint);
+ if (m_InterceptWaypoint) SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
+ if (InterceptWaypoint == m_InterceptWaypoint) InterceptWaypoint = null;
+ m_InterceptWaypoint = null; InterceptToken = 0; m_InterceptToken = 0;
+ InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null;
+ m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
+ }
+ bool DeliverIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptMatches(order) || order.Token != m_InterceptToken || InterceptActive || !InterceptCrew(mode)) return false;
+ if (order.Position != m_InterceptPosition || order.ExpiresAt != m_InterceptExpiresAt || now >= m_InterceptExpiresAt) return false;
+ AIWaypoint detour = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), m_InterceptPosition));
+ if (!detour) return false;
+ detour.SetCompletionRadius(15);
+ ClearOrder(); m_InterceptRouteIndex = RouteIndex;
+ m_InterceptWaypoint = detour; InterceptWaypoint = detour; InterceptActive = true;
+ Group.AddWaypoint(detour);
+ return true;
+ }
+ // True suppresses ordinary travel only after actual radio delivery. No route
+ // index is consumed by arrival at the detour; removal leaves the seated group held.
+ bool TickIntercept(ERR_GameMode mode, float now)
+ {
+ if (!InterceptOrder) return false;
+ if (!InterceptMatches(InterceptOrder)) return InterceptActive;
+ if (Released || Arrived || now >= m_InterceptExpiresAt || !InterceptCrew(mode) || Combat.Held || SupplyHeld || SupplyToken != 0)
+ { bool delivered = InterceptActive; ClearIntercept(InterceptOrder); if (delivered) LegDeadline = now + 600000; return false; }
+ if (!InterceptActive) return false;
+ if (RouteIndex != m_InterceptRouteIndex || InterceptWaypoint != m_InterceptWaypoint)
+ { ClearIntercept(InterceptOrder); return false; }
+ if (m_InterceptWaypoint && vector.DistanceXZ(Entity.GetOrigin(), m_InterceptPosition) <= 15)
+ {
+ Group.RemoveWaypoint(m_InterceptWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
+ m_InterceptWaypoint = null; InterceptWaypoint = null;
+ }
+ return true;
+ }
void ClearOrder()
{
if (Waypoint && Group) Group.RemoveWaypoint(Waypoint);
@@ -132,6 +230,7 @@
void Release(ERR_GameMode mode)
{
if (Released) return;
+ ClearIntercept(InterceptOrder);
Released = true;
Combat.Held = false; SupplyToken = 0; SupplyHeld = false;
ClearOrder();
@@ -159,7 +258,7 @@
}
bool Order(ERR_ForceRoute route, float now)
{
- if (Released || Arrived || Combat.Held || SupplyHeld || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
+ if (Released || Arrived || InterceptActive || Combat.Held || SupplyHeld || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
if (Waypoint) return true;
Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), route.At(RouteIndex)));
if (!Waypoint) return false;
@@ -170,6 +269,7 @@
}
void Tick(ERR_GameMode mode, ERR_ForceRoute route, float now)
{
+ if (TickIntercept(mode, now)) return;
if (Released || Arrived) return;
if (!CrewValid(mode)) { Release(mode); return; }
if (Combat.Held || SupplyHeld) return;
@@ -417,9 +517,61 @@
if (!pending) task.Stop(m_Mode, "finite authored road route ended or all vehicle orders released");
}
}
+ protected bool InterceptEligible(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
+ {
+ if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
+ if (!m_Mode.Forces()) return false;
+ ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
+ if (!hq || hq.LogisticsToken(order.Group) != order.Token) return false;
+ if (order.Token < 400001 || order.Token > 400256 || order.SourceId != task.Receipt.SourceId) return false;
+ if (order.VehicleIndex != 0 && order.VehicleIndex != 3 && order.VehicleIndex != 5) return false;
+ if (order.VehicleIndex >= task.Vehicles.Count() || order.VehicleIndex >= task.Receipt.Records.Count()) return false;
+ ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
+ if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group) return false;
+ if (!unit.InterceptCrew(m_Mode) || !task.Route || unit.RouteIndex < 0 || unit.RouteIndex >= task.Route.Count()) return false;
+ ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
+ if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY || record.Id != task.Receipt.Records[unit.Index]) return false;
+ if (record.Group != unit.Group || record.SourceId != task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString()) return false;
+ if (unit.InterceptOrder && (unit.InterceptReceipt != task.Receipt || unit.InterceptRecord != record || unit.InterceptRoute != task.Route)) return false;
+ foreach (ERR_ConvoyVehicle other : task.Vehicles)
+ if (!other || other.SupplyToken != 0 || other.SupplyHeld || other.Combat.Held) return false;
+ return true;
+ }
+ bool InterceptCurrent(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
+ {
+ return InterceptEligible(task, order) && task.Vehicles[order.VehicleIndex].InterceptToken == order.Token
+ && task.Vehicles[order.VehicleIndex].InterceptMatches(order);
+ }
+ bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptEligible(task, order) || !ERR_ForceRules.Finite(now) || now < 0) return false;
+ if (!ERR_ForceRules.Finite(order.ObservedAt) || !ERR_ForceRules.Finite(order.IssuedAt) || !ERR_ForceRules.Finite(order.ExpiresAt)) return false;
+ if (order.ObservedAt < 0 || order.IssuedAt < order.ObservedAt || now < order.IssuedAt || now >= order.ExpiresAt) return false;
+ if (order.ExpiresAt > order.ObservedAt + 120000 || !ERR_ForceRules.FiniteVector(order.Position)) return false;
+ if (order.BeliefId <= 0 || order.BeliefRevision <= 0 || order.ReportId <= 0) return false;
+ foreach (ERR_ConvoyTask existing : m_Tasks)
+ foreach (ERR_ConvoyVehicle claimed : existing.Vehicles) if (claimed && claimed.InterceptToken != 0) return false;
+ ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
+ if (!unit.ClaimIntercept(m_Mode, order)) return false;
+ unit.InterceptReceipt = task.Receipt; unit.InterceptRecord = m_Mode.Forces().FindGroup(unit.Group); unit.InterceptRoute = task.Route;
+ return true;
+ }
+ bool ApplyIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!InterceptCurrent(task, order) || !ERR_ForceRules.Finite(now) || now < order.IssuedAt || now >= order.ExpiresAt) return false;
+ return task.Vehicles[order.VehicleIndex].DeliverIntercept(m_Mode, order, now);
+ }
+ void ReleaseIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ if (!task || !order || !m_Tasks.Contains(task) || !ERR_ForceRules.Finite(now)) return;
+ foreach (ERR_ConvoyVehicle unit : task.Vehicles)
+ if (unit && unit.InterceptMatches(order))
+ { bool delivered = unit.InterceptActive; unit.ClearIntercept(order); if (delivered) unit.LegDeadline = now + 600000; }
+ }
bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
+ foreach (ERR_ConvoyVehicle intercept : task.Vehicles) if (intercept && intercept.InterceptToken != 0) return false;
if (order.VehicleIndex < 0 || order.VehicleIndex >= task.Vehicles.Count() || order.SourceId != task.Receipt.SourceId) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group || !unit.CrewValid(m_Mode)) return false;
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c
@@ -40,6 +40,7 @@
class ERR_ConvoyColumnVehicle
{
bool Holding;
+ bool InterceptDetached;
vector Progress;
float LastProgress = -1;
float NextRetry;
@@ -135,22 +136,38 @@
}
bool Tick(float now)
{
- int leader = -1, previous = -1; float largestGap; bool supplyHold;
+ // Rejoining escorts follow the surviving leader; their older saved route
+ // index must not rewind the rest of the formation. A sole survivor keeps its own.
+ bool rejoining;
+ for (int returning = 0; returning < m_State.Count(); returning++)
+ if (m_State[returning].InterceptDetached && !m_Task.Vehicles[returning].InterceptActive) rejoining = true;
+ if (rejoining)
+ {
+ array<int> continuingOrder = {};
+ foreach (int continuing : m_Order)
+ if (!m_State[continuing].InterceptDetached) continuingOrder.Insert(continuing);
+ foreach (int detached : m_Order)
+ if (m_State[detached].InterceptDetached) continuingOrder.Insert(detached);
+ m_Order = continuingOrder;
+ }
+ int leader = -1, previous = -1; float largestGap; bool supplyHold; bool interceptLive;
for (int slot = 0; slot < m_Order.Count(); slot++)
{
int i = m_Order[slot];
ERR_ConvoyVehicle scan = m_Task.Vehicles[i];
if (scan.Released || !scan.Entity) continue;
+ if (scan.InterceptActive) { m_State[i].InterceptDetached = true; interceptLive = true; continue; }
if (leader < 0) leader = i;
if (previous >= 0) largestGap = Math.Max(largestGap, vector.DistanceXZ(scan.Entity.GetOrigin(), m_Task.Vehicles[previous].Entity.GetOrigin()));
if (scan.SupplyHeld) supplyHold = true;
previous = i;
}
- if (leader < 0) return false;
+ if (leader < 0) return interceptLive;
if (m_Leader != leader)
{
ERR_ConvoyVehicle promoted = m_Task.Vehicles[leader]; promoted.ClearOrder(); promoted.Arrived = false;
- promoted.RouteIndex = m_RouteIndex;
+ if (m_State[leader].InterceptDetached) m_RouteIndex = promoted.RouteIndex;
+ else promoted.RouteIndex = m_RouteIndex;
m_Leader = leader;
ERR_HQ.Event("CONVOY LEADER", string.Format("%1: vehicle %2 leads surviving column", m_Task.Receipt.SourceId, leader + 1));
}
@@ -164,7 +181,9 @@
int index = m_Order[ordinal];
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
if (vehicle.Released || !vehicle.Entity) continue;
+ if (vehicle.InterceptActive) { m_State[index].LastProgress = -1; m_State[index].NextTarget = now; continue; }
ERR_ConvoyColumnVehicle state = m_State[index];
+ state.InterceptDetached = false;
if (vehicle.SupplyHeld || vehicle.Combat.Held)
{
// The external owner controls its own waypoint. Only suspend our watchdog.
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
@@ -234,7 +234,7 @@
if (!task.Column || !task.Column.Tick(now)) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; }
bool pending;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
- if (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held)) pending = true;
+ if (!vehicle.Released && (!vehicle.Arrived || vehicle.SupplyHeld || vehicle.Combat.Held || vehicle.InterceptToken != 0)) pending = true;
if (pending) return;
if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; }
if (now < task.Departure || task.Bases.Count() < 2) return;
@@ -270,9 +270,16 @@
if (task.MineStock) task.MineStock.Tick(now, MountedStockOwner(task, 1, ERR_HQ.Cast(m_Mode.Command())));
foreach (ERR_ConvoyVehicle checked : task.Vehicles)
if (!checked.Released && !checked.CrewValid(m_Mode)) checked.Release(m_Mode);
+ foreach (ERR_ConvoyVehicle intercept : task.Vehicles) intercept.TickIntercept(m_Mode, now);
if (task.Tactics && task.Tactics.Tick(now)) continue;
Circuit(task, now);
}
+ }
+ override bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
+ {
+ ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
+ if (!supply || !supply.Tactics || supply.Tactics.State() != 0) return false;
+ return super.ReserveIntercept(task, order, now);
}
override bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
@@ -369,6 +369,13 @@
}
protected void Start(vector contact, float now)
{
+ // A local threat takes precedence, but may release only this exact detour owner.
+ foreach (ERR_ConvoyVehicle intercept : m_Task.Vehicles)
+ {
+ if (intercept.InterceptToken == 0) continue;
+ intercept.ClearIntercept(intercept.InterceptOrder);
+ if (intercept.InterceptToken != 0) return;
+ }
Clear(); m_State = 1; m_Contact = contact; m_TruckWithdrawn = false; m_OrdersIssued = false;
if (m_Task.Column) m_Task.Column.Reset(now);
m_Task.Waiting = false;
--- a/tests/test_convoy_column.py
+++ b/tests/test_convoy_column.py
@@ -1,21 +1,25 @@
"""Run the production column controller against physical-position/native-order doubles."""
from policy_harness import ROOT, body, run
-import re
-p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'
+import re,os
+from pathlib import Path
+p=Path(os.environ.get('ERR_SUPPLY_COLUMN_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'))
def cpp(s):
s=re.sub(r'//[^\n]*', '', s).replace('protected ', '').replace('out ', '')
s=s.replace('foreach (', 'for (').replace('null','nullptr')
for typ in ('ERR_ConvoyVehicle','ERR_ConvoyColumnVehicle'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
- for obj in ('m_Task','vehicle','state','scan','promoted','ahead','m_State[i]','m_Task->Vehicles[previous]'):
+ for obj in ('m_Task','vehicle','state','scan','promoted','ahead','m_State[i]','currentLeader','m_Task->Vehicles[previous]'):
s=s.replace(obj+'.',obj+'->')
for obj in ('Entity','Group','Waypoint','Route','Receipt','Combat'):
s=s.replace(obj+'.',obj+'->')
for cls in ('ERR_ConvoyColumnRules','ERR_ConvoyDriving','ERR_SupplyConvoyTactics','ERR_HQ','Math','vector','string'):
s=s.replace(cls+'.',cls+'::')
+ s=s.replace('array<int>', 'Array<int>').replace('m_State[continuing].', 'm_State[continuing]->').replace('m_State[detached].', 'm_State[detached]->')
+ s=s.replace('bool rejoining;', 'bool rejoining=false;').replace('m_State[returning].','m_State[returning]->').replace('m_Task->Vehicles[returning].','m_Task->Vehicles[returning]->')
+ s=s.replace('m_State[index].', 'm_State[index]->').replace('m_State[leader].', 'm_State[leader]->').replace('m_State[survivor].', 'm_State[survivor]->')
s=s.replace('m_Task->Vehicles[index].', 'm_Task->Vehicles[index]->').replace('(index + 1).ToString()', 'std::to_string(index + 1)')
- s=s.replace('float largestGap; bool supplyHold;', 'float largestGap=0; bool supplyHold=false;')
+ s=s.replace('float largestGap; bool supplyHold; bool interceptLive;', 'float largestGap=0; bool supplyHold=false; bool interceptLive=false;')
return s
rules='\n'.join(cpp(body(p, x)) for x in ('static bool WaitForRear(', 'static bool TooClose(', 'static float Speed('))
methods='\n'.join(cpp(body(p,x)) for x in ('protected float DistanceToTarget(', 'protected void Reorder(', 'void Reset(', 'protected void Hold(', 'protected bool Progress(', 'bool Tick('))
@@ -28,7 +32,7 @@
struct vector{float x=0;static float DistanceXZ(vector a,vector b){return std::abs(a.x-b.x);}};
namespace Math{float Max(float a,float b){return a>b?a:b;}int Min(int a,int b){return a<b?a:b;}float Round(float x){return std::round(x);}}
namespace ERR_HQ{int stranded=0,retries=0;void Event(string type,string){if(type=="CONVOY STRANDED")stranded++;if(type=="CONVOY ROUTE RETRY")retries++;}}
-template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}};
+template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}void Insert(T value){this->push_back(value);}};
struct ERR_ConvoyColumnRules{'''+rules+r'''};
struct Entity{vector position;vector GetOrigin(){return position;}};
struct Waypoint:Entity{};
@@ -36,7 +40,7 @@
struct Route{Array<vector>points={{0},{400}};int Count(){return points.Count();}vector At(int i){return points[i];}};
struct Combat{bool Held=false;};
struct ERR_ConvoyVehicle{
- int Index=0,RouteIndex=0,clears=0;bool Released=false,Arrived=false,SupplyHeld=false;
+ int Index=0,RouteIndex=0,clears=0;bool InterceptActive=false,Released=false,Arrived=false,SupplyHeld=false;
struct Entity entity;struct Entity* Entity=&entity;struct Group group;struct Group* Group=&group;
struct Combat combat;struct Combat* Combat=&combat;struct Waypoint* Waypoint=nullptr;
void ClearOrder(){if(Waypoint)clears++;delete Waypoint;Waypoint=nullptr;}
@@ -48,13 +52,28 @@
namespace ERR_SupplyConvoyTactics{Waypoint* Move(vector p,float){auto w=new Waypoint();w->position=p;return w;}}
struct Receipt{string SourceId="test";};
struct ERR_SupplyConvoyTask{Array<ERR_ConvoyVehicle*>Vehicles;struct Route route;struct Route* Route=&route;int Legs=0;struct Receipt receipt;struct Receipt* Receipt=&receipt;};
-struct ERR_ConvoyColumnVehicle{bool Holding=false;vector Progress;float LastProgress=-1,NextRetry=0,NextTarget=0,Speed=-1;int Ahead=-1;};
+struct ERR_ConvoyColumnVehicle{bool InterceptDetached=false,Holding=false;vector Progress;float LastProgress=-1,NextRetry=0,NextTarget=0,Speed=-1;int Ahead=-1;};
struct Column{
void* m_Mode=nullptr;ERR_SupplyConvoyTask* m_Task;Array<ERR_ConvoyColumnVehicle*>m_State;
Array<int>m_Order={0,1,2};int m_Leader=-1,m_RouteIndex=0;bool m_RearWait=false;float m_NextLog=0;
void Speed(ERR_ConvoyVehicle*,ERR_ConvoyColumnVehicle* state,float speed){state->Speed=speed;}
'''+methods+r'''};
int main(){
+ {ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;
+ ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
+ column.m_RouteIndex=1;a.RouteIndex=0;a.entity.position={-20};b.RouteIndex=1;b.entity.position={100};c.RouteIndex=1;c.entity.position={40};assert(column.Tick(0));assert(column.m_Leader==1);
+ a.InterceptActive=false;b.Released=true;assert(column.Tick(1000));assert(column.m_Leader==2&&c.RouteIndex==1&&a.RouteIndex==0&&column.m_RouteIndex==1);}
+
+ {ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;c.Released=true;
+ ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
+ column.m_RouteIndex=1;a.RouteIndex=0;a.entity.position={-20};b.RouteIndex=1;b.entity.position={100};assert(column.Tick(0));assert(column.m_Leader==1&&b.RouteIndex==1);
+ a.InterceptActive=false;assert(column.Tick(1000));assert(column.m_Leader==1&&b.RouteIndex==1&&a.RouteIndex==0&&column.m_RouteIndex==1&&b.Waypoint->position.x==400);}
+
+ {ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;b.Released=c.Released=true;
+ ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
+ a.RouteIndex=0;a.entity.position={-200};assert(column.Tick(0));assert(sa.InterceptDetached&&a.RouteIndex==0&&!a.Waypoint);
+ column.m_RouteIndex=1;a.InterceptActive=false;assert(column.Tick(1000));assert(a.RouteIndex==0&&a.Waypoint&&column.m_RouteIndex==0);}
+
assert(!ERR_ConvoyColumnRules::WaitForRear(160,false));assert(ERR_ConvoyColumnRules::WaitForRear(161,false));
assert(ERR_ConvoyColumnRules::WaitForRear(91,true));assert(!ERR_ConvoyColumnRules::WaitForRear(90,true));
assert(ERR_ConvoyColumnRules::TooClose(39,false));assert(!ERR_ConvoyColumnRules::TooClose(40,false));
--- a/tests/test_convoy_combat.py
+++ b/tests/test_convoy_combat.py
@@ -39,7 +39,7 @@
for cls in ['ERR_ConvoyTask','ERR_ConvoyCombatOrder','ERR_ConvoyVehicle','ERR_ForceRecord','AIAgent']:
code=re.sub(r'\b'+cls+r' ([A-Za-z_]\w*)',cls+r'* \1',code)
code=code.replace('array<AIAgent>','Array<AIAgent*>').replace('foreach (','for (')
-for name in ['task','order','unit','record','agent','m_Mode']:code=code.replace(name+'.',name+'->')
+for name in ['task','order','unit','record','agent','m_Mode','intercept']:code=code.replace(name+'.',name+'->')
code=code.replace('Receipt.','Receipt->').replace('Combat.','Combat->').replace('Route.','Route->').replace('Group.','Group->').replace('Forces().','Forces()->')
for cls in ['ERR_ConvoyCombatRules','ERR_ConvoyRules','ERR_ForceKind','ERR_HQ']:code=code.replace(cls+'.',cls+'::')
code=code.replace('string.Format','Format')
@@ -52,7 +52,7 @@
struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(Group*){return &record;}};
struct Mode{Ledger ledger;Ledger* Forces(){return &ledger;}};
struct ERR_ConvoyReceipt{bool Consumed=true;string SourceId="convoy-1";Array<int>Records{3};};
-struct ERR_ConvoyVehicle{IEntity vehicle;IEntity* Entity=&vehicle;bool SupplyHeld=false,Registered=true,Released=false,Arrived=false,valid=true,waypoint=true,failOrder=false;int Index=0,RouteIndex=2,clears=0,orders=0,releases=0;struct Group* Group;Array<IEntity*>Members;ERR_ConvoyCombatState state;ERR_ConvoyCombatState* Combat=&state;bool CrewValid(Mode*){return valid;}void ClearOrder(){clears++;waypoint=false;}bool Order(ERR_ForceRoute*,float){if(waypoint)return true;orders++;if(failOrder)return false;waypoint=true;return true;}void Release(Mode*){releases++;Released=true;Combat->Held=false;}};
+struct ERR_ConvoyVehicle{IEntity vehicle;IEntity* Entity=&vehicle;bool SupplyHeld=false,Registered=true,Released=false,Arrived=false,valid=true,waypoint=true,failOrder=false;int InterceptToken=0,Index=0,RouteIndex=2,clears=0,orders=0,releases=0;struct Group* Group;Array<IEntity*>Members;ERR_ConvoyCombatState state;ERR_ConvoyCombatState* Combat=&state;bool CrewValid(Mode*){return valid;}void ClearOrder(){clears++;waypoint=false;}bool Order(ERR_ForceRoute*,float){if(waypoint)return true;orders++;if(failOrder)return false;waypoint=true;return true;}void Release(Mode*){releases++;Released=true;Combat->Held=false;}};
struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array<ERR_ConvoyVehicle*>Vehicles;};
namespace ERR_ConvoyRules{bool captured=false;bool Captured(IEntity*,Group*){return captured;}};
struct ERR_ConvoyCombatRules{'''+accept+r'''};
--- a/tests/test_convoy_lifecycle.py
+++ b/tests/test_convoy_lifecycle.py
@@ -1,50 +1,107 @@
"""Actual native convoy ownership/waypoint policy with small engine API doubles."""
import os,re
from pathlib import Path
-from policy_harness import ROOT,run
+from policy_harness import ROOT,run,body
path=Path(os.environ.get('ERR_CONVOY_SERVICE',str(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c')))
source=path.read_text();code=source[source.index('class ERR_ConvoyVehicle'):source.index('class ERR_ConvoyCargoCallback')]
-code=re.sub(r'//[^\n]*','',code).replace('class ERR_ConvoyVehicle','struct ERR_ConvoyVehicle').replace('ref ','')
+code=re.sub(r'//[^\n]*','',code).replace('class ERR_ConvoyVehicle','struct ERR_ConvoyVehicle').replace('ref ','').replace('protected ','')
code=code.replace('array<IEntity>','Array<IEntity*>').replace('array<BaseCompartmentSlot>','Array<BaseCompartmentSlot*>').replace('array<AIAgent>','Array<AIAgent*>')
-for typ in ['Vehicle','SCR_AIGroup','IEntity','BaseCompartmentSlot','AIWaypoint','ERR_GameMode','ERR_ForceRoute','ERR_ForceRecord','AIAgent','ERR_ConvoyCombatState']:
+for typ in ['Vehicle','SCR_AIGroup','IEntity','BaseCompartmentSlot','AIWaypoint','ERR_GameMode','ERR_ForceRoute','ERR_ForceRecord','AIAgent','ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder','ERR_ConvoyReceipt']:
code=re.sub(r'\b'+typ+r' ([A-Za-z_]\w*)',typ+r'* \1',code)
-for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat']:
+for obj in ['Waypoint','Group','Entity','mode','record','agent','member','route','Combat','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder']:
code=re.sub(r'\b'+obj+r'\.',obj+'->',code)
for cls in ['SCR_EntityHelper','ERR_Actors','ERR_ConvoyRules','ERR_Resources','CompartmentAccessComponent','vector']:
code=code.replace(cls+'.',cls+'::')
-code=code.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(').replace('Forces().','Forces()->').replace('Command().','Command()->').replace('foreach (','for (').replace('null','nullptr').replace('\n}\n','\n};\n')
+code=code.replace('AIWaypoint.Cast(', 'static_cast<AIWaypoint*>(').replace('Seats[i].','Seats[i]->').replace('Forces().','Forces()->').replace('Command().','Command()->').replace('foreach (','for (').replace('null','nullptr').replace('\n}\n','\n};\n')
+code=code.replace('int attributed;', 'int attributed=0;')
code=re.sub(r'\b(bool|int|float|[A-Za-z_]\w*\*) ([A-Za-z_]\w*);',r'\1 \2 {};',code)
+service='\n'.join(body(path, x) for x in ('protected bool InterceptEligible(', 'bool InterceptCurrent(', 'bool ReserveIntercept(', 'bool ApplyIntercept(', 'void ReleaseIntercept('))
+service=service.replace('protected ','').replace('foreach (','for (').replace('null','nullptr')
+for typ in ['ERR_ConvoyTask','ERR_ConvoyInterceptOrder','ERR_ConvoyVehicle','ERR_ForceRecord']:
+ service=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',service)
+for obj in ['task','order','unit','record','other','existing','claimed','m_Mode','hq']:
+ service=service.replace(obj+'.',obj+'->')
+for obj in ['Receipt','Route','Combat']:
+ service=service.replace(obj+'.',obj+'->')
+service=service.replace('ERR_HQ.Cast(m_Mode->Command())','m_Mode->Command()').replace('unit->Index.ToString()', 'std::to_string(unit->Index)')
+service=service.replace('Forces().','Forces()->').replace('task->Vehicles[order->VehicleIndex].','task->Vehicles[order->VehicleIndex]->')
+service=service.replace('ERR_ForceRules.','ERR_ForceRules::').replace('ERR_ForceKind.','ERR_ForceKind::')
+service='struct ERR_ConvoyReceipt{bool Consumed=true;std::string SourceId="original";Array<int> Records={7,8,9,10,11,12};}; struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array<ERR_ConvoyVehicle*> Vehicles;};struct Service{ERR_GameMode* m_Mode;bool current=true;Array<ERR_ConvoyTask*>m_Tasks;bool Current(){return current;}'+service+'};'
run(r'''
#include <cassert>
#include <vector>
#include <algorithm>
#include <cmath>
-struct vector{float x=0,y=0,z=0;static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
-template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;void Insert(T v){this->push_back(v);}void Clear(){this->clear();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}};
+#include <string>
+struct vector{float x=0,y=0,z=0;bool operator!=(vector b){return x!=b.x||y!=b.y||z!=b.z;}static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
+template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Insert(T v){this->push_back(v);}void Clear(){this->clear();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}};
struct SCR_AIGroup;struct Vehicle;struct ERR_ConvoyCombatState{bool Held=false;};
struct IEntity{bool alive=true,player=false,awake=true;Vehicle* vehicle=nullptr;SCR_AIGroup* group=nullptr;IEntity* parent=nullptr;IEntity* GetParent(){return parent;}};
-struct AIAgent{int lod=0;void SetPermanentLOD(int value){lod=value;}};
+struct AIAgent{IEntity* actor=nullptr;IEntity* GetControlledEntity(){return actor;}int lod=0;void SetPermanentLOD(int value){lod=value;}};
struct AIWaypoint:IEntity{float radius=0;void SetCompletionRadius(float value){radius=value;}bool IsWithinCompletionRadius(SCR_AIGroup* group);};
struct SCR_AIGroup:IEntity{bool within=false;AIWaypoint* current=nullptr;int lod=0,faction=1,removes=0,adds=0;Array<AIAgent*> agents;int GetFaction(){return faction;}void SetPermanentLOD(int value){lod=value;}void GetAgents(Array<AIAgent*>& out){out=agents;}void RemoveWaypoint(AIWaypoint*){removes++;}void AddWaypoint(AIWaypoint*){adds++;}};
bool AIWaypoint::IsWithinCompletionRadius(SCR_AIGroup* group){return group->within;}
struct Vehicle:IEntity{vector position;vector GetOrigin(){return position;}};
-struct BaseCompartmentSlot{};
-struct ERR_ForceRecord{bool Pinned=true;};
+struct BaseCompartmentSlot{IEntity* occupant=nullptr;IEntity* GetOccupant(){return occupant;}};
+namespace ERR_ForceKind{const int CONVOY=3;}
+struct ERR_ForceRecord{SCR_AIGroup* Group=nullptr;std::string SourceId="original-vehicle-0";int Id=7,Kind=3;bool Physical=true,Eliminated=false,Pinned=true;};
struct Ledger{ERR_ForceRecord record;ERR_ForceRecord* FindGroup(SCR_AIGroup*){return &record;}};
-struct Command{int unregistrations=0;void UnregisterGroup(SCR_AIGroup*){unregistrations++;}};
+struct Command{int token=400001;int LogisticsToken(SCR_AIGroup*){return token;}int unregistrations=0;void UnregisterGroup(SCR_AIGroup*){unregistrations++;}};
+using ERR_HQ=Command*;struct ERR_ConvoyReceipt;
struct ERR_GameMode{struct Ledger ledger;struct Command command;int EnemyFaction(){return 1;}struct Ledger* Forces(){return &ledger;}struct Command* Command(){return &command;}};
struct ERR_ForceRoute{Array<vector> points;int Count(){return points.size();}vector At(int i){assert(i>=0&&i<Count());return points[i];}};
namespace ERR_Actors{int spawns=0;bool fail=false;bool Alive(IEntity* a){return a&&a->alive;}bool CanFight(IEntity* a){return Alive(a)&&a->awake;}bool Player(IEntity* a){return a&&a->player;}SCR_AIGroup* Group(IEntity* a){return a?a->group:nullptr;}IEntity* Spawn(int,vector){spawns++;return fail?nullptr:new AIWaypoint();}}
namespace ERR_Resources{int MoveWaypoint(){return 1;}}
namespace CompartmentAccessComponent{IEntity* GetVehicleIn(IEntity* a){return a?a->vehicle:nullptr;}}
-namespace ERR_ConvoyRules{bool PartOf(IEntity* e,IEntity* root){int depth=0;while(e&&depth++<32){if(e==root)return true;e=e->GetParent();}return false;}}
+namespace ERR_ConvoyRules{inline bool captured=false;bool Captured(Vehicle*,SCR_AIGroup*){return captured;}bool PartOf(IEntity* e,IEntity* root){int depth=0;while(e&&depth++<32){if(e==root)return true;e=e->GetParent();}return false;}}
namespace SCR_EntityHelper{int deletes=0;void DeleteEntityAndChildren(AIWaypoint* p){deletes++;delete p;}}
-''' + code + r'''
+namespace ERR_ForceRules{bool Finite(float n){return std::isfinite(n);}bool FiniteVector(vector p){return Finite(p.x)&&Finite(p.y)&&Finite(p.z);}}
+struct ERR_ConvoyInterceptOrder{int Token=400001,VehicleIndex=0,BeliefId=1,BeliefRevision=1,ReportId=1;SCR_AIGroup* Group=nullptr;std::string SourceId="original";vector Position{700,0,0};float ObservedAt=1000,IssuedAt=1000,ExpiresAt=121000;};
+''' + code + service + r'''
struct Case{
- ERR_GameMode mode;Vehicle vehicle;SCR_AIGroup group;IEntity driver;AIAgent agent;ERR_ConvoyVehicle unit;ERR_ForceRoute route;
- Case(){unit.Entity=&vehicle;unit.Group=&group;unit.Driver=&driver;unit.Members.Insert(&driver);driver.vehicle=&vehicle;driver.group=&group;group.agents.Insert(&agent);route.points={{100,0,0},{300,0,0}};}
+ ERR_GameMode mode;Vehicle vehicle;SCR_AIGroup group;IEntity driver;AIAgent agent;BaseCompartmentSlot seat;ERR_ConvoyVehicle unit;ERR_ForceRoute route;
+ Case(){mode.ledger.record.Group=&group;agent.actor=&driver;seat.occupant=&driver;unit.Seats.Insert(&seat);unit.Registered=true;unit.Entity=&vehicle;unit.Group=&group;unit.Driver=&driver;unit.Members.Insert(&driver);driver.vehicle=&vehicle;driver.group=&group;group.agents.Insert(&agent);route.points={{100,0,0},{300,0,0}};}
+};
+struct InterceptCase:Case{Service service;ERR_ConvoyReceipt receipt;ERR_ConvoyTask task;ERR_ConvoyInterceptOrder order;
+ InterceptCase(){service.m_Mode=&mode;task.Receipt=&receipt;task.Route=&route;task.Vehicles={&unit};service.m_Tasks={&task};order.Group=&group;}
+ bool Reserve(){return service.ReserveIntercept(&task,&order,1000);}bool Apply(){return service.ApplyIntercept(&task,&order,2000);}
};
int main(){
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;auto original=f.unit.Waypoint;assert(f.Reserve());f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.Waypoint==original&&f.unit.LegDeadline==deadline);}
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(f.unit.LegDeadline==deadline);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,5000);assert(f.unit.LegDeadline==605000&&!f.unit.Waypoint);}
+
+ {InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);auto route=f.unit.Waypoint;assert(f.Reserve());assert(f.unit.Waypoint==route&&!f.unit.InterceptActive);
+ f.unit.Tick(&f.mode,&f.route,1500);assert(f.unit.Waypoint==route&&f.unit.RouteIndex==0);assert(f.Apply());assert(!f.unit.Waypoint&&f.unit.InterceptWaypoint&&f.unit.InterceptActive);
+ assert(!f.Apply());f.group.within=true;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.RouteIndex==0);f.vehicle.position=f.order.Position;
+ f.unit.Tick(&f.mode,&f.route,4000);assert(f.unit.InterceptActive&&!f.unit.InterceptWaypoint&&f.unit.RouteIndex==0);
+ f.unit.Tick(&f.mode,&f.route,120999);assert(!f.unit.Waypoint);f.group.within=false;f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint&&f.unit.RouteIndex==0);assert(!f.Reserve());}
+ {InterceptCase f;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint);}
+ {InterceptCase f;assert(f.Reserve());f.group.within=true;f.unit.Tick(&f.mode,&f.route,1100);f.unit.Tick(&f.mode,&f.route,1200);assert(f.unit.RouteIndex==1);assert(f.Apply());assert(f.unit.RouteIndex==1);}
+ for(int bad=0;bad<14;bad++){InterceptCase f;
+ if(bad==0)f.order.Token=300001;
+ if(bad==1)f.order.VehicleIndex=1;
+ if(bad==2)f.order.ExpiresAt=121001;
+ if(bad==3)f.order.Position.x=NAN;
+ if(bad==4)f.order.IssuedAt=1001;
+ if(bad==5)f.unit.Arrived=true;
+ if(bad==6)f.unit.SupplyToken=100001;
+ if(bad==7)f.unit.SupplyHeld=true;
+ if(bad==8)f.unit.Combat->Held=true;
+ if(bad==9)f.seat.occupant=nullptr;
+ if(bad==10)f.mode.ledger.record.Id=99;
+ if(bad==11)f.agent.actor=nullptr;
+ if(bad==12)f.service.current=false;
+ if(bad==13)f.mode.command.token=0;
+ assert(!f.Reserve()&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.driver.alive=false;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.Released&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());f.unit.Members.Clear();f.unit.Tick(&f.mode,&f.route,3000);assert(!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
+ {InterceptCase f;assert(f.Reserve());auto foreign=f.order;foreign.Token=400002;f.service.ReleaseIntercept(&f.task,&foreign,2000);assert(f.unit.InterceptToken==400001);
+ f.unit.InterceptToken=400002;f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.InterceptToken==400002);}
+ {InterceptCase f;assert(f.Reserve());assert(f.Apply());AIWaypoint foreign;f.unit.InterceptWaypoint=&foreign;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(f.unit.InterceptWaypoint==&foreign&&!f.unit.InterceptToken);}
+ {InterceptCase f;assert(f.Reserve());f.order.Position.x++;assert(!f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken&&!f.unit.Waypoint);}
+ {InterceptCase f;assert(f.Reserve());ERR_Actors::fail=true;assert(!f.Apply()&&!f.unit.InterceptActive);ERR_Actors::fail=false;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken);}
+
Case travel;travel.unit.Tick(&travel.mode,&travel.route,1000);assert(travel.group.adds==1&&travel.unit.Waypoint&&travel.unit.LegDeadline==601000);
travel.unit.Tick(&travel.mode,&travel.route,2000);assert(travel.group.adds==1&&travel.unit.LegDeadline==601000);
travel.vehicle.position={100,0,0};travel.group.within=true;travel.unit.Tick(&travel.mode,&travel.route,3000);assert(travel.unit.RouteIndex==1&&travel.group.adds==2&&travel.group.removes==1);
--- a/tests/test_supply_convoys.py
+++ b/tests/test_supply_convoys.py
@@ -42,13 +42,13 @@
struct Receipt{string SourceId="test";};
struct Combat{bool Held=false;};
struct Column{bool live=true;int resets=0;bool Tick(float){return live;}void Reset(float,bool){resets++;}};
-struct ERR_ConvoyVehicle{bool Released=false,Arrived=false,SupplyHeld=false;struct Combat combat;struct Combat* Combat=&combat;int cleared=0,RouteIndex=3;struct Entity entity;struct Entity* Entity=&entity;
+struct ERR_ConvoyVehicle{int InterceptToken=0;bool Released=false,Arrived=false,SupplyHeld=false;struct Combat combat;struct Combat* Combat=&combat;int cleared=0,RouteIndex=3;struct Entity entity;struct Entity* Entity=&entity;
void ClearOrder(){cleared++;}void Tick(void*,ERR_ForceRoute*,float){}};
struct ERR_SupplyConvoyTask{struct Column column;struct Column* Column=&column;Array<ERR_ConvoyVehicle*> Vehicles;float TravelDeadline=1000,Departure=0;bool Waiting=false,stopped=false;int LastBase=-1,Legs=0;
Array<vector> Bases={{0},{1000},{2000}};ERR_ForceRoute* Route=nullptr;struct Receipt receipt;struct Receipt* Receipt=&receipt;
void Stop(void*,string){stopped=true;}};
struct Service{void* m_Mode=nullptr;
-'''+cpp(body(D/'ERR_SupplyConvoyService.c','protected void Circuit('))+r'''};
+'''+cpp(body(Path(os.environ.get('ERR_SUPPLY_SERVICE_SOURCE',D/'ERR_SupplyConvoyService.c')),'protected void Circuit('))+r'''};
int main(){
for(int last=0;last<5;last++)for(int roll=0;roll<4;roll++){int next=ERR_SupplyConvoyRules::NextBase(5,last,roll);assert(next>=0&&next<5&&next!=last);}
assert(ERR_SupplyConvoyRules::NextBase(1,0,0)==-1);assert(ERR_SupplyConvoyRules::NextBase(3,3,0)==-1);
@@ -352,3 +352,33 @@
}
''')
print('PASS: actual supply startup reasons expose unchanged body budget/requirement, journal only failure changes, preserve retry cadence/caps and report the true final refusal')
+# Execute the entire actual combat-start method: exact detour cancellation must
+# precede ordinary waypoint/tactics ownership; foreign claims prevent takeover.
+start=cpp(body(TACTICS,'protected void Start(')).replace('intercept.','intercept->')
+start=start.replace('"0 0 1"','vector(0,0,1)').replace('Vector(','vector(')
+run(r'''
+#include <cassert>
+#include <vector>
+#include <string>
+#include <cmath>
+using string=std::string;
+struct vector{float v[3]={};vector(float x=0,float y=0,float z=0):v{x,y,z}{}float&operator[](int i){return v[i];}vector operator-(vector b){return {v[0]-b[0],v[1]-b[1],v[2]-b[2]};}float Length(){return std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);}void Normalize(){float n=Length();for(auto&x:v)x/=n;}};
+template<class T>using Array=std::vector<T>;
+struct Order{};struct Combat{bool Held=false;};struct Entity{vector GetOrigin(){return {};}};
+struct ERR_ConvoyVehicle{int InterceptToken=0,clears=0,cancels=0;bool own=true,Released=false;Order order;Order*InterceptOrder=&order;struct Combat combat;struct Combat*Combat=&combat;struct Entity entity;struct Entity*Entity=&entity;
+ void ClearIntercept(Order*p){cancels++;if(own&&p==&order)InterceptToken=0;}void ClearOrder(){clears++;}};
+struct Column{int resets=0;void Reset(float){resets++;}};struct Receipt{string SourceId="original";};
+struct Task{bool Waiting=true;Array<ERR_ConvoyVehicle*>Vehicles;struct Column column;struct Column*Column=&column;struct Receipt receipt;struct Receipt*Receipt=&receipt;};
+namespace ERR_HQ{int events=0;void Event(string,string){events++;}}
+struct Tactics{Task*m_Task;int m_State=0,m_DismountAttempts=0,m_NextDismount=0,clears=0,orders=0;bool m_TruckWithdrawn=true,m_OrdersIssued=true;vector m_Contact,m_Forward,m_Left;
+ void Clear(){clears++;}void Orders(bool,float){orders++;}
+'''+start+r'''
+};
+int main(){
+ ERR_ConvoyVehicle a,b;Task task;task.Vehicles={&a,&b};Tactics tactics{&task};
+ a.InterceptToken=400001;b.InterceptToken=400002;b.own=false;
+ tactics.Start({100,0,0},1000);assert(a.InterceptToken==0&&b.InterceptToken==400002&&tactics.clears==0&&task.column.resets==0&&tactics.orders==0&&!a.combat.Held);
+ b.own=true;tactics.Start({100,0,0},2000);assert(b.InterceptToken==0&&tactics.clears==1&&task.column.resets==1&&tactics.orders==1&&a.combat.Held&&b.combat.Held&&a.clears==1);
+}
+''')
+print('PASS: actual combat start releases only exact intercept claims before owning route/orders; foreign claim defers combat takeover')
@@ -0,0 +1,628 @@
// Independent physical adaptation of the owner's supply-convoy extension.
// HQ must dispatch an authored route using a finite canonical pool receipt.
class ERR_ConvoyResources
{
static ResourceName VehiclePrefab(int index)
{
if (index == 1) return "{3336BE330C4F355B}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_ammo.et";
if (index == 2) return "{16C1F16C9B053801}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_transport.et";
if (index == 4) return "{1DC01AAACFFA8B63}Prefabs/Vehicles/Tracked/BMP1/BMP1_Base.et";
if (index == 0 || index == 3 || index == 5) return "{0B4DEA8078B78A9B}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469_PKM.et";
return "";
}
static ResourceName GroupPrefab() { return "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et"; }
static ResourceName Soldier()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.FactionSelection() && mode.FactionSelection().Ready() && mode.FactionSelection().Active("enemy") != "ussr") return mode.FactionSelection().Soldier();
return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; }
static ResourceName Rifle() { return "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et"; }
static ResourceName Magazine() { return "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et"; }
static bool Available()
{
array<ResourceName> required = {GroupPrefab(), Soldier(), Rifle(), Magazine(), ERR_Resources.MoveWaypoint()};
for (int i = 0; i < 6; i++) required.Insert(VehiclePrefab(i));
foreach (ResourceName prefab : required)
{
Resource resource = Resource.Load(prefab);
if (!resource || !resource.IsValid()) return false;
}
return true;
}
}
class ERR_ConvoyRules
{
static bool EmptyCrewTemplate(SCR_AIGroup group)
{
// The vanilla faction base authors six queued soldiers. Only our memberless
// template may receive finite seat occupants; never retire another spawn job.
return group && !group.GetSpawnImmediately() && group.GetAgentsCount() == 0
&& (!group.m_aUnitPrefabSlots || group.m_aUnitPrefabSlots.IsEmpty());
}
static bool Plan(array<vector> positions, array<vector> angles, ERR_ForceRoute route)
{
if (!positions || !angles || positions.Count() != 6 || angles.Count() != 6 || !route || route.Looped() || route.Count() < 2 || !route.Seal()) return false;
for (int i = 0; i < 6; i++)
{
if (!ERR_ForceRules.FiniteVector(positions[i]) || !ERR_ForceRules.FiniteVector(angles[i])) return false;
for (int j = 0; j < i; j++) if (vector.DistanceXZ(positions[i], positions[j]) < 18) return false;
}
return true;
}
static bool GroundClear(array<vector> positions, array<vector> angles)
{
if (!positions || !angles || positions.Count() != 6 || angles.Count() != 6) return false;
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int i = 0; i < positions.Count(); i++) if (!placement.Clear(positions[i], angles[i][0])) return false;
return true;
}
static bool PartOf(IEntity entity, IEntity root)
{
int depth;
while (entity && depth++ < 32)
{
if (entity == root) return true;
entity = entity.GetParent();
}
return false;
}
static bool Captured(Vehicle vehicle, SCR_AIGroup group)
{
if (group && group.GetPlayerCount(true) > 0) return true;
if (!vehicle) return false;
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
foreach (int id : ids)
{
IEntity player = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
if (player && PartOf(CompartmentAccessComponent.GetVehicleIn(player), vehicle)) return true;
}
return false;
}
static bool CollectSeats(IEntity root, array<BaseCompartmentSlot> seats)
{
array<IEntity> pending = {root};
int cursor;
while (cursor < pending.Count() && cursor < 128)
{
IEntity entity = pending[cursor++];
if (!entity) return false;
SCR_BaseCompartmentManagerComponent manager = SCR_BaseCompartmentManagerComponent.Cast(entity.FindComponent(SCR_BaseCompartmentManagerComponent));
if (manager)
{
array<BaseCompartmentSlot> local = {}; manager.GetCompartments(local);
foreach (BaseCompartmentSlot seat : local) if (seat && !seats.Contains(seat)) seats.Insert(seat);
}
IEntity child = entity.GetChildren();
while (child)
{
if (pending.Count() >= 128 || pending.Contains(child)) return false;
pending.Insert(child); child = child.GetSibling();
}
}
return cursor == pending.Count();
}
}
class ERR_ConvoyVehicle
{
int Index;
Vehicle Entity;
SCR_AIGroup Group;
IEntity Driver;
ref array<IEntity> Members = {};
ref array<BaseCompartmentSlot> Seats = {};
AIWaypoint Waypoint;
int RouteIndex;
bool Released;
bool Registered;
ref ERR_ConvoyCombatState Combat = new ERR_ConvoyCombatState();
int SupplyToken;
bool SupplyHeld;
int InterceptToken;
bool InterceptActive;
AIWaypoint InterceptWaypoint;
ref ERR_ConvoyInterceptOrder InterceptOrder;
ERR_ConvoyReceipt InterceptReceipt;
ERR_ForceRecord InterceptRecord;
ERR_ForceRoute InterceptRoute;
protected AIWaypoint m_InterceptWaypoint;
protected Vehicle m_InterceptEntity;
protected SCR_AIGroup m_InterceptGroup;
protected IEntity m_InterceptDriver;
protected ref array<IEntity> m_InterceptMembers = {};
protected ref array<BaseCompartmentSlot> m_InterceptSeats = {};
protected int m_InterceptToken;
protected int m_LastInterceptToken;
protected vector m_InterceptPosition;
protected float m_InterceptExpiresAt;
protected int m_InterceptRouteIndex;
bool Arrived;
float LegDeadline;
float NextReadinessNote;
// Pending downlink owns a claim only; normal authored travel continues.
bool InterceptCrew(ERR_GameMode mode)
{
if (!mode || !CrewValid(mode) || !Entity || !Group || ERR_ConvoyRules.Captured(Entity, Group)) return false;
if (Members.IsEmpty() || Members.Count() > 8 || Seats.Count() != Members.Count()) return false;
if (InterceptOrder && (Members.Count() != m_InterceptMembers.Count() || Seats.Count() != m_InterceptSeats.Count())) return false;
array<AIAgent> agents = {}; Group.GetAgents(agents);
if (agents.Count() != Members.Count()) return false;
for (int i = 0; i < Members.Count(); i++)
{
if (!Members[i] || !ERR_Actors.CanFight(Members[i]) || !Seats[i] || Seats[i].GetOccupant() != Members[i]) return false;
int attributed;
foreach (AIAgent agent : agents)
{
if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;
if (agent.GetControlledEntity() == Members[i]) attributed++;
}
if (attributed != 1) return false;
if (InterceptOrder && (Members[i] != m_InterceptMembers[i] || Seats[i] != m_InterceptSeats[i])) return false;
}
if (InterceptOrder && (Entity != m_InterceptEntity || Group != m_InterceptGroup || Driver != m_InterceptDriver)) return false;
return true;
}
bool InterceptMatches(ERR_ConvoyInterceptOrder order)
{
return order && InterceptOrder == order && InterceptToken == m_InterceptToken && m_InterceptToken != 0;
}
bool ClaimIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order)
{
if (!order || order.Token <= m_LastInterceptToken || InterceptToken != 0 || InterceptOrder || InterceptWaypoint || !InterceptCrew(mode)) return false;
m_InterceptEntity = Entity; m_InterceptGroup = Group; m_InterceptDriver = Driver;
m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
foreach (IEntity member : Members) m_InterceptMembers.Insert(member);
foreach (BaseCompartmentSlot seat : Seats) m_InterceptSeats.Insert(seat);
m_InterceptToken = order.Token; m_LastInterceptToken = order.Token; InterceptToken = order.Token; InterceptOrder = order;
m_InterceptPosition = order.Position; m_InterceptExpiresAt = order.ExpiresAt;
return true;
}
void ClearIntercept(ERR_ConvoyInterceptOrder order)
{
if (!InterceptMatches(order)) return;
// Delete only the exact waypoint this owner created, never an adopted handle.
if (m_InterceptWaypoint && m_InterceptGroup) m_InterceptGroup.RemoveWaypoint(m_InterceptWaypoint);
if (m_InterceptWaypoint) SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
if (InterceptWaypoint == m_InterceptWaypoint) InterceptWaypoint = null;
m_InterceptWaypoint = null; InterceptToken = 0; m_InterceptToken = 0;
InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null;
m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
}
bool DeliverIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptMatches(order) || order.Token != m_InterceptToken || InterceptActive || !InterceptCrew(mode)) return false;
if (order.Position != m_InterceptPosition || order.ExpiresAt != m_InterceptExpiresAt || now >= m_InterceptExpiresAt) return false;
AIWaypoint detour = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), m_InterceptPosition));
if (!detour) return false;
detour.SetCompletionRadius(15);
ClearOrder(); m_InterceptRouteIndex = RouteIndex;
m_InterceptWaypoint = detour; InterceptWaypoint = detour; InterceptActive = true;
Group.AddWaypoint(detour);
return true;
}
// True suppresses ordinary travel only after actual radio delivery. No route
// index is consumed by arrival at the detour; removal leaves the seated group held.
bool TickIntercept(ERR_GameMode mode, float now)
{
if (!InterceptOrder) return false;
if (!InterceptMatches(InterceptOrder)) return InterceptActive;
if (Released || Arrived || now >= m_InterceptExpiresAt || !InterceptCrew(mode) || Combat.Held || SupplyHeld || SupplyToken != 0)
{ ClearIntercept(InterceptOrder); LegDeadline = now + 600000; return false; }
if (!InterceptActive) return false;
if (RouteIndex != m_InterceptRouteIndex || InterceptWaypoint != m_InterceptWaypoint)
{ ClearIntercept(InterceptOrder); return false; }
if (m_InterceptWaypoint && vector.DistanceXZ(Entity.GetOrigin(), m_InterceptPosition) <= 15)
{
Group.RemoveWaypoint(m_InterceptWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
m_InterceptWaypoint = null; InterceptWaypoint = null;
}
return true;
}
void ClearOrder()
{
if (Waypoint && Group) Group.RemoveWaypoint(Waypoint);
if (Waypoint) SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
Waypoint = null;
}
void Release(ERR_GameMode mode)
{
if (Released) return;
ClearIntercept(InterceptOrder);
Released = true;
Combat.Held = false; SupplyToken = 0; SupplyHeld = false;
ClearOrder();
if (mode && mode.Forces())
{
ERR_ForceRecord record = mode.Forces().FindGroup(Group);
if (record) record.Pinned = false;
}
if (Group)
{
if (mode && mode.Command()) mode.Command().UnregisterGroup(Group);
Group.SetPermanentLOD(-1);
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
}
// Vehicle, cargo, casualties and surviving crew remain physical and lootable.
}
bool CrewValid(ERR_GameMode mode)
{
if (!Group || !Entity || !ERR_Actors.Alive(Entity) || Group.GetFaction() != mode.EnemyFaction() || !ERR_Actors.CanFight(Driver)) return false;
if (ERR_Actors.Player(Driver) || !ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(Driver), Entity)) return false;
foreach (IEntity member : Members)
if (!ERR_Actors.Alive(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group) return false;
return true;
}
bool Order(ERR_ForceRoute route, float now)
{
if (Released || Arrived || InterceptActive || Combat.Held || SupplyHeld || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
if (Waypoint) return true;
Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), route.At(RouteIndex)));
if (!Waypoint) return false;
Waypoint.SetCompletionRadius(35);
Group.AddWaypoint(Waypoint);
LegDeadline = now + 600000;
return true;
}
void Tick(ERR_GameMode mode, ERR_ForceRoute route, float now)
{
if (TickIntercept(mode, now)) return;
if (Released || Arrived) return;
if (!CrewValid(mode)) { Release(mode); return; }
if (Combat.Held || SupplyHeld) return;
// Native completion measures the seated group, not the vehicle origin.
// Keep the same predicate after native removal of the completed waypoint.
if (Waypoint && Waypoint.IsWithinCompletionRadius(Group))
{
ClearOrder(); RouteIndex++;
if (RouteIndex >= route.Count()) { Arrived = true; return; }
}
if (Waypoint && now >= LegDeadline) { Release(mode); return; }
if (!Order(route, now)) Release(mode);
}
}
class ERR_ConvoyCargoCallback : ScriptedInventoryOperationCallback
{
ResourceName Prefab;
bool Finished;
bool Succeeded;
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
protected override void OnComplete() { Finish(true); }
protected override void OnFailed() { Finish(false); }
protected override void OnAllComplete() { Finish(true); }
protected override void OnAllFailed() { Finish(false); }
}
class ERR_ConvoyTask
{
ref ERR_ConvoyReceipt Receipt;
ref ERR_ForceRoute Route;
ref array<ref ERR_ConvoyVehicle> Vehicles = {};
ref array<ref ERR_ConvoyCargoCallback> Cargo = {};
bool Stopped;
bool Ready;
bool CargoIssued;
float Deadline;
float NextCheck;
string Reason;
void Stop(ERR_GameMode mode, string reason)
{
if (Stopped) return;
Stopped = true; Reason = reason;
foreach (ERR_ConvoyVehicle vehicle : Vehicles) vehicle.Release(mode);
ERR_HQ.Event("CONVOY STOP", reason + "; surviving entities and cargo preserved");
}
}
class ERR_ConvoyService
{
protected ERR_GameMode m_Mode;
protected ERR_ConvoyPool m_Pool;
protected ref array<ref ERR_ConvoyTask> m_Tasks = {};
protected bool m_Stopped;
void ERR_ConvoyService(ERR_GameMode mode, ERR_ConvoyPool pool) { m_Mode = mode; m_Pool = pool; }
protected bool Current()
{
return !m_Stopped && Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Pool;
}
protected ERR_ConvoyTask NewTask() { return new ERR_ConvoyTask(); }
protected ERR_ConvoyVehicle NewVehicle() { return new ERR_ConvoyVehicle(); }
ERR_ConvoyTask At(int index)
{
if (index < 0 || index >= m_Tasks.Count()) return null;
return m_Tasks[index];
}
ERR_ConvoyTask Find(string source)
{
foreach (ERR_ConvoyTask task : m_Tasks) if (task.Receipt && task.Receipt.SourceId == source) return task;
return null;
}
protected bool FillCrew(ERR_ConvoyVehicle unit)
{
array<BaseCompartmentSlot> all = {};
if (!ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false;
array<BaseCompartmentSlot> drivers = {}, turrets = {}, cargo = {};
foreach (BaseCompartmentSlot slot : all)
{
if (!slot.IsCompartmentAccessible() || slot.IsOccupied()) continue;
if (slot.GetType() == ECompartmentType.PILOT) drivers.Insert(slot);
else if (slot.GetType() == ECompartmentType.TURRET) turrets.Insert(slot);
else if (slot.GetType() == ECompartmentType.CARGO) cargo.Insert(slot);
}
int requiredTurrets = 1;
if (unit.Index == 1 || unit.Index == 2) requiredTurrets = 0;
if (unit.Index == 4) requiredTurrets = 2;
if (drivers.Count() != 1 || turrets.Count() < requiredTurrets || (unit.Index == 2 && cargo.Count() < 8)) return false;
unit.Seats.Insert(drivers[0]);
for (int i = 0; i < requiredTurrets; i++) unit.Seats.Insert(turrets[i]);
if (unit.Index == 2) for (int i = 0; i < 8; i++) unit.Seats.Insert(cargo[i]);
// Exact planned population is known before any character is created.
if (unit.Seats.Count() != ERR_ConvoyPool.CrewCount(unit.Index)) return false;
AIGroup nativeGroup = unit.Group;
foreach (BaseCompartmentSlot seat : unit.Seats)
{
IEntity actor = seat.SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab());
if (actor) { unit.Members.Insert(actor); m_Mode.Own(actor); }
if (!actor || nativeGroup != unit.Group) return false;
}
unit.Driver = unit.Members[0];
return true;
}
ERR_ConvoyTask Dispatch(ERR_ConvoyReceipt receipt, array<vector> positions, array<vector> angles, ERR_ForceRoute route, float now)
{
if (!Current() || !ERR_ForceRules.Finite(now) || now < 0 || !ERR_ConvoyRules.Plan(positions, angles, route) || !ERR_ConvoyResources.Available() || !ERR_ConvoyRules.GroundClear(positions, angles)) return null;
// The server role selection owns the memberless group affiliation.
if (!m_Mode.EnemyFaction() || !m_Pool.Consume(receipt)) return null;
ERR_ConvoyTask task = NewTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000;
m_Tasks.Insert(task);
for (int index = 0; index < 6; index++)
{
ERR_ConvoyVehicle unit = NewVehicle(); unit.Index = index; task.Vehicles.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), positions[index]));
if (unit.Group) m_Mode.Own(unit.Group);
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(index), positions[index], angles[index]));
if (unit.Entity) m_Mode.Own(unit.Entity);
if (!unit.Group || !unit.Entity || unit.Group.GetFaction() != m_Mode.EnemyFaction() || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{ task.Stop(m_Mode, "finite convoy startup failed; receipt consumed without retry"); return task; }
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(35);
}
ERR_HQ.Event("CONVOY CREATED", "six physical vehicles; finite crew created; native readiness and ledger admission pending");
return task;
}
protected string ReadinessReason(ERR_ConvoyVehicle unit)
{
if (unit.Registered) return "already admitted";
if (!unit.Group) return "missing group";
if (!unit.Entity) return "missing vehicle";
if (!ERR_Actors.Alive(unit.Entity)) return "vehicle destroyed or lacks damage state";
if (unit.Group.GetFaction() != m_Mode.EnemyFaction()) return "group faction mismatch";
if (!ERR_Actors.CanFight(unit.Driver)) return "driver absent/dead/unconscious or controller unavailable";
if (ERR_Actors.Player(unit.Driver)) return "driver controlled by player";
if (!ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(unit.Driver), unit.Entity)) return "driver outside intended vehicle";
foreach (IEntity member : unit.Members)
{
if (!ERR_Actors.Alive(member)) return "member absent/dead or damage state unavailable";
if (ERR_Actors.Player(member)) return "member controlled by player";
if (ERR_Actors.Group(member) != unit.Group) return "member native agent/parent group mismatch";
}
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return "native agent count differs from original crew";
foreach (AIAgent agent : agents)
{
if (!agent) return "null native agent";
if (!unit.Members.Contains(agent.GetControlledEntity())) return "native agent controls foreign entity";
if (!ERR_Actors.CanFight(agent.GetControlledEntity())) return "native agent actor cannot fight";
}
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
if (!utility) return "group utility unavailable";
if (!utility.m_VehicleMgr) return "group vehicle manager unavailable";
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!usage) return "vehicle usage component unavailable";
for (int i = 0; i < unit.Seats.Count(); i++)
if (i >= unit.Members.Count() || !unit.Seats[i] || unit.Seats[i].GetOccupant() != unit.Members[i]) return "intended seat occupant mismatch";
return "readiness guards satisfied; awaiting admission";
}
protected void InspectReadiness(ERR_ConvoyVehicle unit, float now)
{
if (!unit || now < unit.NextReadinessNote) return;
unit.NextReadinessNote = now + 5000;
int agents, activeAgents;
bool activeGroup;
if (unit.Group)
{
array<AIAgent> current = {}; unit.Group.GetAgents(current);
agents = current.Count(); activeGroup = unit.Group.IsAIActivated();
foreach (AIAgent agent : current) if (agent && agent.IsAIActivated()) activeAgents++;
}
ERR_HQ.Event("CONVOY READINESS", string.Format("vehicle=%1 reason=%2 members=%3 agents=%4 activeAgents=%5 activeGroup=%6 seats=%7 registered=%8", unit.Index, ReadinessReason(unit), unit.Members.Count(), agents, activeAgents, activeGroup, unit.Seats.Count(), unit.Registered));
}
protected bool Admit(ERR_ConvoyTask task, float now)
{
foreach (ERR_ConvoyVehicle observed : task.Vehicles) InspectReadiness(observed, now);
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
{
if (unit.Registered) continue;
if (!unit.CrewValid(m_Mode)) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return false;
foreach (AIAgent agent : agents)
if (!agent || !unit.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage)) return false;
ERR_ForceRecord record = m_Mode.Forces().Register(task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString(), ERR_ForceKind.CONVOY, unit.Entity.GetOrigin(), unit.Members.Count(), null, unit.Group, now);
if (!record || !m_Pool.BindRecord(task.Receipt, unit.Index, record)) { task.Stop(m_Mode, "convoy ledger admission rejected"); return false; }
record.Pinned = true;
unit.Registered = true;
m_Mode.RegisterGroup(unit.Group);
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
if (hq)
{
hq.ReserveGroup(unit.Group);
if (!hq.BindVehicle(unit.Group, unit.Entity))
ERR_HQ.Event("MOUNTED AMMO", "original convoy vehicle binding unavailable; no replacement lookup");
}
// Native helper idempotently registers deletion/damage callbacks and mounted
// combat activity; raw vehicle-manager admission alone omits those hooks.
utility.AddUsableVehicle(usage);
unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); }
}
return true;
}
protected void IssueCargo(ERR_ConvoyTask task)
{
if (task.CargoIssued || task.Stopped || task.Vehicles.Count() != 6) return;
task.CargoIssued = true;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(task.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent));
if (!inventory) { ERR_HQ.Event("CONVOY CARGO", "ammo truck inventory unavailable; no retry or free replacement"); return; }
for (int i = 0; i < 56; i++)
{
ResourceName prefab = ERR_ConvoyResources.Magazine();
if (i < 8) prefab = ERR_ConvoyResources.Rifle();
ERR_ConvoyCargoCallback operation = new ERR_ConvoyCargoCallback(); operation.Prefab = prefab; task.Cargo.Insert(operation);
if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, operation)) operation.Finished = true;
}
ERR_HQ.Event("CONVOY CARGO", "finite target submitted once: eight AK74 and 48 magazines; native capacity/completion may reduce actual stock");
}
void Tick(float now)
{
if (!Current() || !ERR_ForceRules.Finite(now)) return;
foreach (ERR_ConvoyTask task : m_Tasks)
{
if (task.Stopped || now < task.NextCheck) continue;
task.NextCheck = now + 1000;
bool captured;
foreach (ERR_ConvoyVehicle unit : task.Vehicles) if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) captured = true;
if (captured) { task.Stop(m_Mode, "player captured convoy vehicle or joined crew"); continue; }
if (!task.Ready)
{
if (now >= task.Deadline) { task.Stop(m_Mode, "native crew readiness expired; no replacement"); continue; }
if (!Admit(task, now)) continue;
task.Ready = true; IssueCargo(task);
}
bool pending;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
vehicle.Tick(m_Mode, task.Route, now);
if (!vehicle.Released && !vehicle.Arrived) pending = true;
}
if (!pending) task.Stop(m_Mode, "finite authored road route ended or all vehicle orders released");
}
}
protected bool InterceptEligible(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
if (!m_Mode.Forces()) return false;
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
if (!hq || hq.LogisticsToken(order.Group) != order.Token) return false;
if (order.Token < 400001 || order.Token > 400256 || order.SourceId != task.Receipt.SourceId) return false;
if (order.VehicleIndex != 0 && order.VehicleIndex != 3 && order.VehicleIndex != 5) return false;
if (order.VehicleIndex >= task.Vehicles.Count() || order.VehicleIndex >= task.Receipt.Records.Count()) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group) return false;
if (!unit.InterceptCrew(m_Mode) || !task.Route || unit.RouteIndex < 0 || unit.RouteIndex >= task.Route.Count()) return false;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY || record.Id != task.Receipt.Records[unit.Index]) return false;
if (record.Group != unit.Group || record.SourceId != task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString()) return false;
if (unit.InterceptOrder && (unit.InterceptReceipt != task.Receipt || unit.InterceptRecord != record || unit.InterceptRoute != task.Route)) return false;
foreach (ERR_ConvoyVehicle other : task.Vehicles)
if (!other || other.SupplyToken != 0 || other.SupplyHeld || other.Combat.Held) return false;
return true;
}
bool InterceptCurrent(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
{
return InterceptEligible(task, order) && task.Vehicles[order.VehicleIndex].InterceptToken == order.Token
&& task.Vehicles[order.VehicleIndex].InterceptMatches(order);
}
bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptEligible(task, order) || !ERR_ForceRules.Finite(now) || now < 0) return false;
if (!ERR_ForceRules.Finite(order.ObservedAt) || !ERR_ForceRules.Finite(order.IssuedAt) || !ERR_ForceRules.Finite(order.ExpiresAt)) return false;
if (order.ObservedAt < 0 || order.IssuedAt < order.ObservedAt || now < order.IssuedAt || now >= order.ExpiresAt) return false;
if (order.ExpiresAt > order.ObservedAt + 120000 || !ERR_ForceRules.FiniteVector(order.Position)) return false;
if (order.BeliefId <= 0 || order.BeliefRevision <= 0 || order.ReportId <= 0) return false;
foreach (ERR_ConvoyTask existing : m_Tasks)
foreach (ERR_ConvoyVehicle claimed : existing.Vehicles) if (claimed && claimed.InterceptToken != 0) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit.ClaimIntercept(m_Mode, order)) return false;
unit.InterceptReceipt = task.Receipt; unit.InterceptRecord = m_Mode.Forces().FindGroup(unit.Group); unit.InterceptRoute = task.Route;
return true;
}
bool ApplyIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptCurrent(task, order) || !ERR_ForceRules.Finite(now) || now < order.IssuedAt || now >= order.ExpiresAt) return false;
return task.Vehicles[order.VehicleIndex].DeliverIntercept(m_Mode, order, now);
}
void ReleaseIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!task || !order || !m_Tasks.Contains(task) || !ERR_ForceRules.Finite(now)) return;
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
if (unit && unit.InterceptMatches(order))
{ bool delivered = unit.InterceptActive; unit.ClearIntercept(order); if (delivered) unit.LegDeadline = now + 600000; }
}
bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
foreach (ERR_ConvoyVehicle intercept : task.Vehicles) if (intercept && intercept.InterceptToken != 0) return false;
if (order.VehicleIndex < 0 || order.VehicleIndex >= task.Vehicles.Count() || order.SourceId != task.Receipt.SourceId) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group || !unit.CrewValid(m_Mode)) return false;
if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group) || unit.Index >= task.Receipt.Records.Count()) return false;
if (!ERR_ConvoyCombatRules.Accept(unit.Combat, order, now) || !task.Route || unit.RouteIndex >= task.Route.Count()) return false;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY || record.Id != task.Receipt.Records[unit.Index]) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return false;
foreach (AIAgent agent : agents)
if (!agent || !unit.Members.Contains(agent.GetControlledEntity())) return false;
// No caller can replay even a command whose native waypoint recreation fails.
unit.Combat.LastCommandId = order.CommandId;
if (order.Hold)
{
if (!unit.Combat.Held) unit.ClearOrder();
unit.Combat.Held = true; unit.Combat.ReportId = order.ReportId; unit.Combat.ObservedAt = order.ObservedAt;
}
else
{
unit.Combat.Held = false;
if (!unit.SupplyHeld && !unit.Order(task.Route, now)) { unit.Release(m_Mode); return false; }
}
ERR_HQ.Event("CONVOY COVER", string.Format("command M%1 report M%2 vehicle=%3 held=%4 routeIndex=%5", order.CommandId, order.ReportId, unit.Index, unit.Combat.Held, unit.RouteIndex));
return true;
}
int Count() { return m_Tasks.Count(); }
string Status()
{
int ready, stopped, vehicles, completed, failed, pending;
foreach (ERR_ConvoyTask task : m_Tasks)
{
if (task.Ready) ready++;
if (task.Stopped) stopped++;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) if (vehicle && vehicle.Entity) vehicles++;
foreach (ERR_ConvoyCargoCallback operation : task.Cargo)
{
if (!operation.Finished) pending++;
else if (operation.Succeeded) completed++;
else failed++;
}
}
string reason;
if (m_Tasks.Count() == 1 && m_Tasks[0].Stopped) reason = m_Tasks[0].Reason;
if (reason.Length() > 160) reason = reason.Substring(0, 160);
return string.Format("Convoys: %1 tasks, %2 reached readiness, %3 stopped, %4 retained physical vehicles; cargo native insertion successes=%5 failed=%6 pending=%7; current inventory may differ after use/looting", m_Tasks.Count(), ready, stopped, vehicles, completed, failed, pending) + "; reason=" + reason;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
foreach (ERR_ConvoyTask task : m_Tasks) task.Stop(m_Mode, "HQ ended convoy task service");
}
}
@@ -0,0 +1,19 @@
check: /tmp/err-policy-asrxksfe/check.cpp:273: int main(): Assertion `f.unit.LegDeadline==deadline' failed.
Traceback (most recent call last):
File "/tmp/erri-mounted-intercept-convoy-1247/tests/test_convoy_lifecycle.py", line 30, in <module>
run(r'''
~~~^^^^^
#include <cassert>
^^^^^^^^^^^^^^^^^^
...<107 lines>...
}
^
''')
^^^^
File "/home/jakub/devel/escapistsrereintroduced/tests/policy_harness.py", line 14, in run
subprocess.run([str(p/'check')],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/err-policy-asrxksfe/check']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1,11 @@
from pathlib import Path
import hashlib,json,subprocess,difflib
P=Path('/tmp/erri-mounted-intercept-convoy-1247');R=Path('/home/jakub/devel/escapistsrereintroduced');meta=json.loads((P/'baseline.json').read_text());manifest={};patch=[]
for f in sorted([*P.glob('addons/**/*.c'),*P.glob('tests/test_*.py')]):
rel=str(f.relative_to(P));before=(P/'baseline'/rel).read_bytes();after=f.read_bytes();assert(R/rel).read_bytes()==before,rel
if rel not in meta:meta[rel]={'working_sha256':hashlib.sha256(before).hexdigest(),'index_sha256':hashlib.sha256(subprocess.check_output(['git','show',':'+rel],cwd=R)).hexdigest()}
if before==after:continue
manifest[rel]=hashlib.sha256(after).hexdigest();patch.extend(difflib.unified_diff(before.decode().splitlines(True),after.decode().splitlines(True),fromfile='a/'+rel,tofile='b/'+rel))
(P/'baseline.json').write_text(json.dumps(meta,indent=2)+'\n');(P/'source-manifest.json').write_text(json.dumps(manifest,indent=2)+'\n');(P/'fix.patch').write_text(''.join(patch))
r=subprocess.run(['git','apply','--check',str(P/'fix.patch')],cwd=R,text=True,capture_output=True);(P/'apply-check.log').write_text(r.stdout+r.stderr);assert r.returncode==0,r.stderr
print('Patch',len(manifest),'files; applicability check passed')
@@ -0,0 +1,169 @@
from pathlib import Path
import hashlib,json,subprocess
P=Path('/tmp/erri-mounted-intercept-convoy-1247');R=Path('/home/jakub/devel/escapistsrereintroduced');D='addons/core/Scripts/Game/ERR/10_AI/'
meta={}
for name in ['ERR_ConvoyService.c','ERR_SupplyConvoyService.c','ERR_SupplyConvoyColumn.c','ERR_SupplyConvoyTactics.c']:
rel=D+name;b=(R/rel).read_bytes()
for base in [P,P/'baseline']:(base/rel).parent.mkdir(parents=True,exist_ok=True);(base/rel).write_bytes(b)
meta[rel]={'working_sha256':hashlib.sha256(b).hexdigest(),'index_sha256':hashlib.sha256(subprocess.check_output(['git','show',':'+rel],cwd=R)).hexdigest()}
(P/'baseline.json').write_text(json.dumps(meta,indent=2)+'\n')
f=P/D/'ERR_ConvoyService.c';s=f.read_text();s=s.replace(' bool SupplyHeld;',''' bool SupplyHeld;
int InterceptToken;
bool InterceptActive;
AIWaypoint InterceptWaypoint;
ref ERR_ConvoyInterceptOrder InterceptOrder;
protected AIWaypoint m_InterceptWaypoint;
protected Vehicle m_InterceptEntity;
protected SCR_AIGroup m_InterceptGroup;
protected IEntity m_InterceptDriver;
protected ref array<IEntity> m_InterceptMembers = {};
protected ref array<BaseCompartmentSlot> m_InterceptSeats = {};
protected int m_InterceptToken;
protected vector m_InterceptPosition;
protected float m_InterceptExpiresAt;
protected int m_InterceptRouteIndex;
''',1)
marker=' void ClearOrder()'
methods=''' // Pending downlink owns a claim only; normal authored travel continues.
bool InterceptCrew(ERR_GameMode mode)
{
if (!mode || !CrewValid(mode) || !Entity || !Group || ERR_ConvoyRules.Captured(Entity, Group)) return false;
if (Members.IsEmpty() || Members.Count() > 8 || Seats.Count() != Members.Count()) return false;
array<AIAgent> agents = {}; Group.GetAgents(agents);
if (agents.Count() != Members.Count()) return false;
for (int i = 0; i < Members.Count(); i++)
{
if (!Members[i] || !ERR_Actors.CanFight(Members[i]) || !Seats[i] || Seats[i].GetOccupant() != Members[i]) return false;
foreach (AIAgent agent : agents) if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;
if (InterceptOrder && (Members[i] != m_InterceptMembers[i] || Seats[i] != m_InterceptSeats[i])) return false;
}
if (InterceptOrder && (Entity != m_InterceptEntity || Group != m_InterceptGroup || Driver != m_InterceptDriver)) return false;
return true;
}
bool InterceptMatches(ERR_ConvoyInterceptOrder order)
{
return order && InterceptOrder == order && InterceptToken == m_InterceptToken && InterceptToken == order.Token
&& Group == m_InterceptGroup && Entity == m_InterceptEntity && order.Group == m_InterceptGroup;
}
bool ClaimIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order)
{
if (!order || InterceptToken != 0 || InterceptOrder || InterceptWaypoint || !InterceptCrew(mode)) return false;
m_InterceptEntity = Entity; m_InterceptGroup = Group; m_InterceptDriver = Driver;
m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
foreach (IEntity member : Members) m_InterceptMembers.Insert(member);
foreach (BaseCompartmentSlot seat : Seats) m_InterceptSeats.Insert(seat);
m_InterceptToken = order.Token; InterceptToken = order.Token; InterceptOrder = order;
m_InterceptPosition = order.Position; m_InterceptExpiresAt = order.ExpiresAt;
return true;
}
void ClearIntercept(ERR_ConvoyInterceptOrder order)
{
if (!InterceptMatches(order)) return;
// Delete only the exact waypoint this owner created, never an adopted handle.
if (m_InterceptWaypoint && m_InterceptGroup) m_InterceptGroup.RemoveWaypoint(m_InterceptWaypoint);
if (m_InterceptWaypoint) SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
if (InterceptWaypoint == m_InterceptWaypoint) InterceptWaypoint = null;
m_InterceptWaypoint = null; InterceptToken = 0; m_InterceptToken = 0;
InterceptActive = false; InterceptOrder = null;
m_InterceptMembers.Clear(); m_InterceptSeats.Clear();
}
bool DeliverIntercept(ERR_GameMode mode, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptMatches(order) || InterceptActive || !InterceptCrew(mode)) return false;
if (order.Position != m_InterceptPosition || order.ExpiresAt != m_InterceptExpiresAt || now >= m_InterceptExpiresAt) return false;
AIWaypoint detour = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), m_InterceptPosition));
if (!detour) return false;
detour.SetCompletionRadius(15);
ClearOrder(); m_InterceptRouteIndex = RouteIndex;
m_InterceptWaypoint = detour; InterceptWaypoint = detour; InterceptActive = true;
Group.AddWaypoint(detour);
return true;
}
// True suppresses ordinary travel only after actual radio delivery. No route
// index is consumed by arrival at the detour; removal leaves the seated group held.
bool TickIntercept(ERR_GameMode mode, float now)
{
if (!InterceptOrder) return false;
if (!InterceptMatches(InterceptOrder)) return InterceptActive;
if (Released || Arrived || now >= m_InterceptExpiresAt || !InterceptCrew(mode) || Combat.Held || SupplyHeld || SupplyToken != 0)
{ ClearIntercept(InterceptOrder); LegDeadline = now + 600000; return false; }
if (!InterceptActive) return false;
if (RouteIndex != m_InterceptRouteIndex || InterceptWaypoint != m_InterceptWaypoint)
{ ClearIntercept(InterceptOrder); return false; }
if (m_InterceptWaypoint && vector.DistanceXZ(Entity.GetOrigin(), m_InterceptPosition) <= 15)
{
Group.RemoveWaypoint(m_InterceptWaypoint); SCR_EntityHelper.DeleteEntityAndChildren(m_InterceptWaypoint);
m_InterceptWaypoint = null; InterceptWaypoint = null;
}
return true;
}
'''
s=s.replace(marker,methods+marker,1)
s=s.replace(' Released = true;\n Combat.Held', ' ClearIntercept(InterceptOrder);\n Released = true;\n Combat.Held',1)
s=s.replace('if (Released || Arrived || Combat.Held || SupplyHeld ||', 'if (Released || Arrived || InterceptActive || Combat.Held || SupplyHeld ||',1)
s=s.replace(' if (Released || Arrived) return;\n if (!CrewValid', ' if (TickIntercept(mode, now)) return;\n if (Released || Arrived) return;\n if (!CrewValid',1)
service=''' protected bool InterceptEligible(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
if (order.Token < 400001 || order.Token > 400256 || order.SourceId != task.Receipt.SourceId) return false;
if (order.VehicleIndex != 0 && order.VehicleIndex != 3 && order.VehicleIndex != 5) return false;
if (order.VehicleIndex >= task.Vehicles.Count() || order.VehicleIndex >= task.Receipt.Records.Count()) return false;
ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit || !unit.Registered || unit.Released || unit.Arrived || unit.Index != order.VehicleIndex || unit.Group != order.Group) return false;
if (!unit.InterceptCrew(m_Mode) || !task.Route || unit.RouteIndex < 0 || unit.RouteIndex >= task.Route.Count()) return false;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
if (!record || !record.Physical || record.Eliminated || record.Kind != ERR_ForceKind.CONVOY || record.Id != task.Receipt.Records[unit.Index]) return false;
foreach (ERR_ConvoyVehicle other : task.Vehicles)
if (!other || other.SupplyToken != 0 || other.SupplyHeld || other.Combat.Held) return false;
return true;
}
bool InterceptCurrent(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order)
{
return InterceptEligible(task, order) && task.Vehicles[order.VehicleIndex].InterceptMatches(order);
}
bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptEligible(task, order) || !ERR_ForceRules.Finite(now) || now < 0) return false;
if (!ERR_ForceRules.Finite(order.ObservedAt) || !ERR_ForceRules.Finite(order.IssuedAt) || !ERR_ForceRules.Finite(order.ExpiresAt)) return false;
if (order.ObservedAt < 0 || order.IssuedAt < order.ObservedAt || now < order.IssuedAt || now >= order.ExpiresAt) return false;
if (order.ExpiresAt > order.ObservedAt + 120000 || !ERR_ForceRules.FiniteVector(order.Position)) return false;
if (order.BeliefId <= 0 || order.BeliefRevision <= 0 || order.ReportId <= 0) return false;
foreach (ERR_ConvoyTask existing : m_Tasks)
foreach (ERR_ConvoyVehicle claimed : existing.Vehicles) if (claimed && claimed.InterceptToken != 0) return false;
return task.Vehicles[order.VehicleIndex].ClaimIntercept(m_Mode, order);
}
bool ApplyIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!InterceptCurrent(task, order) || !ERR_ForceRules.Finite(now) || now < order.IssuedAt || now >= order.ExpiresAt) return false;
return task.Vehicles[order.VehicleIndex].DeliverIntercept(m_Mode, order, now);
}
void ReleaseIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
if (!task || !order || !m_Tasks.Contains(task) || !ERR_ForceRules.Finite(now)) return;
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
if (unit && unit.InterceptMatches(order)) { unit.ClearIntercept(order); unit.LegDeadline = now + 600000; }
}
'''
s=s.replace(' bool ApplyCombatOrder(',service+' bool ApplyCombatOrder(',1)
s=s.replace(' if (order.VehicleIndex < 0 || order.VehicleIndex >= task.Vehicles.Count()', ' foreach (ERR_ConvoyVehicle intercept : task.Vehicles) if (intercept && intercept.InterceptToken != 0) return false;\n if (order.VehicleIndex < 0 || order.VehicleIndex >= task.Vehicles.Count()',1)
f.write_text(s)
f=P/D/'ERR_SupplyConvoyService.c';s=f.read_text().replace('vehicle.SupplyHeld || vehicle.Combat.Held','vehicle.SupplyHeld || vehicle.Combat.Held || vehicle.InterceptToken != 0')
s=s.replace(' if (task.Tactics && task.Tactics.Tick(now)) continue;', ' foreach (ERR_ConvoyVehicle intercept : task.Vehicles) intercept.TickIntercept(m_Mode, now);\n if (task.Tactics && task.Tactics.Tick(now)) continue;')
s=s.replace(' override bool ApplyCombatOrder(',''' override bool ReserveIntercept(ERR_ConvoyTask task, ERR_ConvoyInterceptOrder order, float now)
{
ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
if (!supply || !supply.Tactics || supply.Tactics.State() != 0) return false;
return super.ReserveIntercept(task, order, now);
}
override bool ApplyCombatOrder(''');f.write_text(s)
f=P/D/'ERR_SupplyConvoyColumn.c';s=f.read_text().replace('if (scan.Released || !scan.Entity) continue;', 'if (scan.Released || !scan.Entity || scan.InterceptActive) continue;').replace('if (vehicle.Released || !vehicle.Entity) continue;', '''if (vehicle.Released || !vehicle.Entity) continue;
if (vehicle.InterceptActive) { m_State[index].LastProgress = -1; m_State[index].NextTarget = now; continue; }''');f.write_text(s)
f=P/D/'ERR_SupplyConvoyTactics.c';s=f.read_text().replace(' protected void Start(vector contact, float now)\n {',''' protected void Start(vector contact, float now)
{
// A local threat takes precedence, but may release only this exact detour owner.
foreach (ERR_ConvoyVehicle intercept : m_Task.Vehicles)
{
if (intercept.InterceptToken == 0) continue;
intercept.ClearIntercept(intercept.InterceptOrder);
if (intercept.InterceptToken != 0) return;
}''');f.write_text(s)
@@ -0,0 +1,24 @@
from pathlib import Path
D=Path('/tmp/erri-mounted-intercept-convoy-1247/addons/core/Scripts/Game/ERR/10_AI')
p=D/'ERR_ConvoyService.c';s=p.read_text().replace(' ref ERR_ConvoyInterceptOrder InterceptOrder;', ' ref ERR_ConvoyInterceptOrder InterceptOrder;\n ERR_ConvoyReceipt InterceptReceipt;\n ERR_ForceRecord InterceptRecord;\n ERR_ForceRoute InterceptRoute;')
s=s.replace(' foreach (AIAgent agent : agents) if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;', ' int attributed;\n foreach (AIAgent agent : agents)\n {\n if (!agent || !Members.Contains(agent.GetControlledEntity())) return false;\n if (agent.GetControlledEntity() == Members[i]) attributed++;\n }\n if (attributed != 1) return false;',1)
s=s.replace('InterceptActive = false; InterceptOrder = null;', 'InterceptActive = false; InterceptOrder = null; InterceptReceipt = null; InterceptRecord = null; InterceptRoute = null;',1)
s=s.replace('{ ClearIntercept(InterceptOrder); LegDeadline = now + 600000; return false; }', '{ bool delivered = InterceptActive; ClearIntercept(InterceptOrder); if (delivered) LegDeadline = now + 600000; return false; }',1)
s=s.replace(' if (order.Token < 400001', ' if (!m_Mode.Forces()) return false;\n ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());\n if (!hq || hq.LogisticsToken(order.Group) != order.Token) return false;\n if (order.Token < 400001',1)
s=s.replace(' foreach (ERR_ConvoyVehicle other : task.Vehicles)', ''' if (record.Group != unit.Group || record.SourceId != task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString()) return false;
if (unit.InterceptOrder && (unit.InterceptReceipt != task.Receipt || unit.InterceptRecord != record || unit.InterceptRoute != task.Route)) return false;
foreach (ERR_ConvoyVehicle other : task.Vehicles)''',1)
s=s.replace(' return task.Vehicles[order.VehicleIndex].ClaimIntercept(m_Mode, order);', ''' ERR_ConvoyVehicle unit = task.Vehicles[order.VehicleIndex];
if (!unit.ClaimIntercept(m_Mode, order)) return false;
unit.InterceptReceipt = task.Receipt; unit.InterceptRecord = m_Mode.Forces().FindGroup(unit.Group); unit.InterceptRoute = task.Route;
return true;''',1)
s=s.replace('if (unit && unit.InterceptMatches(order)) { unit.ClearIntercept(order); unit.LegDeadline = now + 600000; }', 'if (unit && unit.InterceptMatches(order))\n { bool delivered = unit.InterceptActive; unit.ClearIntercept(order); if (delivered) unit.LegDeadline = now + 600000; }',1);p.write_text(s)
p=D/'ERR_SupplyConvoyColumn.c';s=p.read_text().replace(' bool Holding;', ' bool Holding;\n bool InterceptDetached;')
s=s.replace('float largestGap; bool supplyHold;', 'float largestGap; bool supplyHold; bool interceptLive;')
s=s.replace(' if (scan.Released || !scan.Entity || scan.InterceptActive) continue;', ''' if (scan.Released || !scan.Entity) continue;
if (scan.InterceptActive) { m_State[i].InterceptDetached = true; interceptLive = true; continue; }''')
s=s.replace(' if (leader < 0) return false;', ' if (leader < 0) return interceptLive;')
s=s.replace(' promoted.RouteIndex = m_RouteIndex;', ''' if (m_State[leader].InterceptDetached) m_RouteIndex = promoted.RouteIndex;
else promoted.RouteIndex = m_RouteIndex;''')
s=s.replace(' ERR_ConvoyColumnVehicle state = m_State[index];', ' ERR_ConvoyColumnVehicle state = m_State[index];\n state.InterceptDetached = false;')
p.write_text(s)
@@ -0,0 +1,15 @@
from pathlib import Path
import os,subprocess,json
P=Path('/tmp/erri-mounted-intercept-convoy-1247');R=Path('/home/jakub/devel/escapistsrereintroduced');D=P/'addons/core/Scripts/Game/ERR/10_AI';env=os.environ.copy();env.update(PYTHONDONTWRITEBYTECODE='1',PYTHONPATH=str(R/'tests'),ERR_CONVOY_SERVICE=str(D/'ERR_ConvoyService.c'),ERR_SUPPLY_SERVICE_SOURCE=str(D/'ERR_SupplyConvoyService.c'),ERR_SUPPLY_COLUMN_SOURCE=str(D/'ERR_SupplyConvoyColumn.c'),ERR_SUPPLY_TACTICS_SOURCE=str(D/'ERR_SupplyConvoyTactics.c'));results={}
for name in ['test_convoy_lifecycle.py','test_convoy_combat.py','test_convoy_column.py','test_supply_convoys.py','test_convoy_admission.py','test_convoy_dispatch.py']:
f=P/'tests'/name
if not f.exists():f=R/'tests'/name
r=subprocess.run(['python3',str(f)],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True);(P/('final-'+name+'.log')).write_text(r.stdout);results[name]=r.returncode;print(name,r.returncode,flush=True)
if r.returncode:print(r.stdout[-4000:])
(P/'final-results.json').write_text(json.dumps(results,indent=2)+'\n');assert not any(results.values())
# A production mutation keeps signatures/native adapters unchanged, then must fail runtime.
s=(D/'ERR_ConvoyService.c').read_text();mut=s.replace('if (delivered) LegDeadline = now + 600000;', 'LegDeadline = now + 600000;').replace('bool delivered = InterceptActive;', '')
m=P/'mutation-pending-deadline.c';m.write_text(mut);env['ERR_CONVOY_SERVICE']=str(m)
r=subprocess.run(['python3',str(P/'tests/test_convoy_lifecycle.py')],env=env,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,text=True);(P/'mutation-pending-deadline.log').write_text(r.stdout)
assert r.returncode and 'Assertion' in r.stdout and 'error:' not in r.stdout,r.stdout
print('pending-deadline mutation: compiled runtime failure',flush=True)
@@ -0,0 +1,10 @@
{
"addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c": "6ca333da6183c86d19f09d0ad2b4d789879ebad2cb43dbe7fd3eb06d6f4e5a53",
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c": "35678ec625fb0a3c0871e2c373fcff49816440a97a165ed25c8798dd72ef059f",
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c": "5b1578afbf6da736453cc9d3872979eddd1649746aa32d3b0c7a9e3cab50057e",
"addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c": "747956bb2b375f5572c1d22eb19aa2b493a4b4c96d1988082f645977bb6dc70f",
"tests/test_convoy_column.py": "eb775249e434eceb047ea47b01c23c8b96a74b484c7acc1e79182e794e9f3e91",
"tests/test_convoy_combat.py": "140d5e8c8c978f97f7f15238bf2aa4efc6ca4d9b6370ef5a0c68bf469cec0b9a",
"tests/test_convoy_lifecycle.py": "926347d5ba789114de2f08651b90c21193d1d460a085c3d6eeebfede19997a25",
"tests/test_supply_convoys.py": "be4fc6376a77296c6721ce966ea33fd256fe9d76af8705a056a88b32c4f16710"
}
@@ -0,0 +1,24 @@
from pathlib import Path
P=Path('/tmp/erri-mounted-intercept-convoy-1247/tests');p=P/'test_convoy_lifecycle.py';s=p.read_text().replace("'ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder']:", "'ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder','ERR_ConvoyReceipt']:")
s=s.replace("code=re.sub(r'\\b(bool|int|float|", "code=code.replace('int attributed;', 'int attributed=0;')\ncode=re.sub(r'\\b(bool|int|float|")
s=s.replace("'claimed','m_Mode']", "'claimed','m_Mode','hq']")
s=s.replace("service=service.replace('Forces().','Forces()->')", "service=service.replace('ERR_HQ.Cast(m_Mode->Command())','m_Mode->Command()').replace('unit->Index.ToString()', 'std::to_string(unit->Index)')\nservice=service.replace('Forces().','Forces()->')")
s=s.replace('struct ERR_ForceRecord{int Id=7,Kind=3;', 'struct ERR_ForceRecord{SCR_AIGroup* Group=nullptr;std::string SourceId="original-vehicle-0";int Id=7,Kind=3;')
s=s.replace('struct Command{int unregistrations=0;', 'struct Command{int token=400001;int LogisticsToken(SCR_AIGroup*){return token;}int unregistrations=0;')
s=s.replace('struct ERR_GameMode{struct Ledger', 'using ERR_HQ=Command*;struct ERR_ConvoyReceipt;\nstruct ERR_GameMode{struct Ledger')
s=s.replace('Case(){agent.actor=&driver;', 'Case(){mode.ledger.record.Group=&group;agent.actor=&driver;')
s=s.replace('for(int bad=0;bad<13;bad++)', 'for(int bad=0;bad<14;bad++)').replace('if(bad==12)f.service.current=false;', 'if(bad==12)f.service.current=false;\n if(bad==13)f.mode.command.token=0;')
s=s.replace('int main(){', '''int main(){
{InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;auto original=f.unit.Waypoint;assert(f.Reserve());f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.Waypoint==original&&f.unit.LegDeadline==deadline);}
{InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);float deadline=f.unit.LegDeadline;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(f.unit.LegDeadline==deadline);}
{InterceptCase f;assert(f.Reserve());assert(f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,5000);assert(f.unit.LegDeadline==605000&&!f.unit.Waypoint);}
''',1);p.write_text(s)
p=P/'test_convoy_column.py';s=p.read_text().replace("s=s.replace('m_State[index].', 'm_State[index]->')", "s=s.replace('m_State[index].', 'm_State[index]->').replace('m_State[leader].', 'm_State[leader]->')")
s=s.replace("s=s.replace('float largestGap; bool supplyHold;', 'float largestGap=0; bool supplyHold=false;')", "s=s.replace('float largestGap; bool supplyHold; bool interceptLive;', 'float largestGap=0; bool supplyHold=false; bool interceptLive=false;')")
s=s.replace('struct ERR_ConvoyColumnVehicle{bool Holding=false;', 'struct ERR_ConvoyColumnVehicle{bool InterceptDetached=false,Holding=false;')
s=s.replace('int main(){', '''int main(){
{ERR_ConvoyVehicle a,b,c;b.Index=1;c.Index=2;a.InterceptActive=true;b.Released=c.Released=true;
ERR_ConvoyColumnVehicle sa,sb,sc;ERR_SupplyConvoyTask task;task.Vehicles={&a,&b,&c};Column column{nullptr,&task,{&sa,&sb,&sc}};
a.RouteIndex=0;a.entity.position={-200};assert(column.Tick(0));assert(sa.InterceptDetached&&a.RouteIndex==0&&!a.Waypoint);
column.m_RouteIndex=1;a.InterceptActive=false;assert(column.Tick(1000));assert(a.RouteIndex==0&&a.Waypoint&&column.m_RouteIndex==0);}
''',1);p.write_text(s)
@@ -0,0 +1,66 @@
from pathlib import Path
P=Path('/tmp/erri-mounted-intercept-convoy-1247');R=Path('/home/jakub/devel/escapistsrereintroduced');(P/'tests').mkdir(exist_ok=True)
def put(name,s):
b=P/'baseline/tests'/name;b.parent.mkdir(parents=True,exist_ok=True);b.write_bytes((R/'tests'/name).read_bytes());(P/'tests'/name).write_text(s)
s=(R/'tests/test_convoy_lifecycle.py').read_text().replace('from policy_harness import ROOT,run','from policy_harness import ROOT,run,body')
s=s.replace(".replace('ref ','')", ".replace('ref ','').replace('protected ','')")
s=s.replace("'ERR_ConvoyCombatState']:","'ERR_ConvoyCombatState','ERR_ConvoyInterceptOrder']:")
s=s.replace("'route','Combat']:","'route','Combat','order','detour','m_InterceptWaypoint','m_InterceptGroup','InterceptOrder']:")
s=s.replace(".replace('Forces().','Forces()->')", ".replace('Seats[i].','Seats[i]->').replace('Forces().','Forces()->')")
s=s.replace('run(r\'\'\'','''service='\\n'.join(body(path, x) for x in ('protected bool InterceptEligible(', 'bool InterceptCurrent(', 'bool ReserveIntercept(', 'bool ApplyIntercept(', 'void ReleaseIntercept('))
service=service.replace('protected ','').replace('foreach (','for (').replace('null','nullptr')
for typ in ['ERR_ConvoyTask','ERR_ConvoyInterceptOrder','ERR_ConvoyVehicle','ERR_ForceRecord']:
service=re.sub(r'\\b'+typ+r' (?=\\w)',typ+'* ',service)
for obj in ['task','order','unit','record','other','existing','claimed','m_Mode']:
service=service.replace(obj+'.',obj+'->')
for obj in ['Receipt','Route']:
service=service.replace(obj+'.',obj+'->')
service=service.replace('Forces().','Forces()->').replace('task->Vehicles[order->VehicleIndex].','task->Vehicles[order->VehicleIndex]->')
service=service.replace('ERR_ForceRules.','ERR_ForceRules::').replace('ERR_ForceKind.','ERR_ForceKind::')
service='struct ERR_ConvoyReceipt{bool Consumed=true;std::string SourceId="original";Array<int> Records={7,8,9,10,11,12};}; struct ERR_ConvoyTask{bool Stopped=false,Ready=true;ERR_ConvoyReceipt* Receipt;ERR_ForceRoute* Route;Array<ERR_ConvoyVehicle*> Vehicles;};struct Service{ERR_GameMode* m_Mode;bool current=true;Array<ERR_ConvoyTask*>m_Tasks;bool Current(){return current;}'+service+'};'
run(r\'\'\'''' ,1)
s=s.replace('#include <cmath>', '#include <cmath>\n#include <string>')
s=s.replace('float x=0,y=0,z=0;static', 'float x=0,y=0,z=0;bool operator!=(vector b){return x!=b.x||y!=b.y||z!=b.z;}static')
s=s.replace('void Insert(T v)', 'int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Insert(T v)')
s=s.replace('struct AIAgent{int lod=0;', 'struct AIAgent{IEntity* actor=nullptr;IEntity* GetControlledEntity(){return actor;}int lod=0;')
s=s.replace('struct BaseCompartmentSlot{};', 'struct BaseCompartmentSlot{IEntity* occupant=nullptr;IEntity* GetOccupant(){return occupant;}};')
s=s.replace('struct ERR_ForceRecord{bool Pinned=true;};','namespace ERR_ForceKind{const int CONVOY=3;}\nstruct ERR_ForceRecord{int Id=7,Kind=3;bool Physical=true,Eliminated=false,Pinned=true;};')
s=s.replace('namespace ERR_ConvoyRules{bool PartOf', 'namespace ERR_ConvoyRules{inline bool captured=false;bool Captured(Vehicle*,SCR_AIGroup*){return captured;}bool PartOf')
s=s.replace("''' + code + r'''", """namespace ERR_ForceRules{bool Finite(float n){return std::isfinite(n);}bool FiniteVector(vector p){return Finite(p.x)&&Finite(p.y)&&Finite(p.z);}}
struct ERR_ConvoyInterceptOrder{int Token=400001,VehicleIndex=0,BeliefId=1,BeliefRevision=1,ReportId=1;SCR_AIGroup* Group=nullptr;std::string SourceId="original";vector Position{700,0,0};float ObservedAt=1000,IssuedAt=1000,ExpiresAt=121000;};
''' + code + service + r'''""")
s=s.replace('AIAgent agent;ERR_ConvoyVehicle', 'AIAgent agent;BaseCompartmentSlot seat;ERR_ConvoyVehicle')
s=s.replace('Case(){unit.Entity=', 'Case(){agent.actor=&driver;seat.occupant=&driver;unit.Seats.Insert(&seat);unit.Registered=true;unit.Entity=')
s=s.replace('int main(){', '''struct InterceptCase:Case{Service service;ERR_ConvoyReceipt receipt;ERR_ConvoyTask task;ERR_ConvoyInterceptOrder order;
InterceptCase(){service.m_Mode=&mode;task.Receipt=&receipt;task.Route=&route;task.Vehicles={&unit};service.m_Tasks={&task};order.Group=&group;}
bool Reserve(){return service.ReserveIntercept(&task,&order,1000);}bool Apply(){return service.ApplyIntercept(&task,&order,2000);}
};
int main(){
{InterceptCase f;f.unit.Tick(&f.mode,&f.route,0);auto route=f.unit.Waypoint;assert(f.Reserve());assert(f.unit.Waypoint==route&&!f.unit.InterceptActive);
f.unit.Tick(&f.mode,&f.route,1500);assert(f.unit.Waypoint==route&&f.unit.RouteIndex==0);assert(f.Apply());assert(!f.unit.Waypoint&&f.unit.InterceptWaypoint&&f.unit.InterceptActive);
assert(!f.Apply());f.group.within=true;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.RouteIndex==0);f.vehicle.position=f.order.Position;
f.unit.Tick(&f.mode,&f.route,4000);assert(f.unit.InterceptActive&&!f.unit.InterceptWaypoint&&f.unit.RouteIndex==0);
f.unit.Tick(&f.mode,&f.route,120999);assert(!f.unit.Waypoint);f.group.within=false;f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint&&f.unit.RouteIndex==0);assert(!f.Reserve());}
{InterceptCase f;assert(f.Reserve());f.unit.Tick(&f.mode,&f.route,121000);assert(!f.unit.InterceptToken&&f.unit.Waypoint);}
{InterceptCase f;assert(f.Reserve());f.group.within=true;f.unit.Tick(&f.mode,&f.route,1100);f.unit.Tick(&f.mode,&f.route,1200);assert(f.unit.RouteIndex==1);assert(f.Apply());assert(f.unit.RouteIndex==1);}
for(int bad=0;bad<13;bad++){InterceptCase f;
if(bad==0)f.order.Token=300001;if(bad==1)f.order.VehicleIndex=1;if(bad==2)f.order.ExpiresAt=121001;
if(bad==3)f.order.Position.x=NAN;if(bad==4)f.order.IssuedAt=1001;if(bad==5)f.unit.Arrived=true;
if(bad==6)f.unit.SupplyToken=100001;if(bad==7)f.unit.SupplyHeld=true;if(bad==8)f.unit.Combat->Held=true;
if(bad==9)f.seat.occupant=nullptr;if(bad==10)f.mode.ledger.record.Id=99;if(bad==11)f.agent.actor=nullptr;if(bad==12)f.service.current=false;
assert(!f.Reserve()&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
{InterceptCase f;assert(f.Reserve());assert(f.Apply());f.driver.alive=false;f.unit.Tick(&f.mode,&f.route,3000);assert(f.unit.Released&&!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
{InterceptCase f;assert(f.Reserve());assert(f.Apply());f.unit.Members.Clear();f.unit.Tick(&f.mode,&f.route,3000);assert(!f.unit.InterceptToken&&!f.unit.InterceptWaypoint);}
{InterceptCase f;assert(f.Reserve());auto foreign=f.order;foreign.Token=400002;f.service.ReleaseIntercept(&f.task,&foreign,2000);assert(f.unit.InterceptToken==400001);
f.unit.InterceptToken=400002;f.service.ReleaseIntercept(&f.task,&f.order,2000);assert(f.unit.InterceptToken==400002);}
{InterceptCase f;assert(f.Reserve());assert(f.Apply());AIWaypoint foreign;f.unit.InterceptWaypoint=&foreign;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(f.unit.InterceptWaypoint==&foreign&&!f.unit.InterceptToken);}
{InterceptCase f;assert(f.Reserve());f.order.Position.x++;assert(!f.Apply());f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken&&!f.unit.Waypoint);}
{InterceptCase f;assert(f.Reserve());ERR_Actors::fail=true;assert(!f.Apply()&&!f.unit.InterceptActive);ERR_Actors::fail=false;f.service.ReleaseIntercept(&f.task,&f.order,3000);assert(!f.unit.InterceptToken);}
''',1)
put('test_convoy_lifecycle.py',s)
# Existing actual combat/circuit/column programs keep all original assertions.
s=(R/'tests/test_convoy_combat.py').read_text().replace("'agent','m_Mode']", "'agent','m_Mode','intercept']").replace('int Index=0,RouteIndex=2,','int InterceptToken=0,Index=0,RouteIndex=2,');put('test_convoy_combat.py',s)
s=(R/'tests/test_convoy_column.py').read_text().replace('import re','import re,os\nfrom pathlib import Path',1).replace("p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'", "p=Path(os.environ.get('ERR_SUPPLY_COLUMN_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyColumn.c'))")
s=s.replace("s=s.replace('m_Task->Vehicles[index].'", "s=s.replace('m_State[index].', 'm_State[index]->')\n s=s.replace('m_Task->Vehicles[index].'")
s=s.replace('bool Released=false,Arrived=false,SupplyHeld=false;', 'bool InterceptActive=false,Released=false,Arrived=false,SupplyHeld=false;');put('test_convoy_column.py',s)
s=(R/'tests/test_supply_convoys.py').read_text().replace("body(D/'ERR_SupplyConvoyService.c','protected void Circuit('","body(Path(os.environ.get('ERR_SUPPLY_SERVICE_SOURCE',D/'ERR_SupplyConvoyService.c')),'protected void Circuit('").replace('struct ERR_ConvoyVehicle{bool Released=false,Arrived=false,SupplyHeld=false;', 'struct ERR_ConvoyVehicle{int InterceptToken=0;bool Released=false,Arrived=false,SupplyHeld=false;');put('test_supply_convoys.py',s)