Files
EscapistsReintroduced/docs/evidence/convoy-startup-performance-2026-11-10/candidate-production.diff
T
jakub 2390f77554
continuous-integration/drone/push Build is passing
Smooth convoy startup and record Workshop 0.0.153
2026-11-10 15:34:24 +01:00

777 lines
39 KiB
Diff

diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Performance.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Performance.c
index 4b44443..2f107ab 100644
--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Performance.c
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Performance.c
@@ -6,7 +6,7 @@ enum ERR_PerfMetric
HQ_PREP, HQ_HUNTERS, HQ_GARRISONS, HQ_CONVOYS, HQ_BODIES, HQ_RADIO_INDEX,
HQ_REPORTS, HQ_PLANNER, HQ_SUPPORT, HQ_JOURNAL,
UPDATE_INTERVAL, ENGINE_SIM_SAMPLE, ENGINE_FRAME_SAMPLE, ENGINE_FPS_SAMPLE,
- ACTIVE_AI_SAMPLE, PLAYERS_SAMPLE, HQ_GROUPS_SAMPLE, GARRISON_WORK, PERF_FLUSH, COUNT
+ ACTIVE_AI_SAMPLE, PLAYERS_SAMPLE, HQ_GROUPS_SAMPLE, GARRISON_WORK, PERF_FLUSH, CONVOY_INTERCEPT, CONVOY_RUNTIME, CONVOY_STARTUP, CONVOY_SURVEY, CONVOY_CREATE, CONVOY_CARGO, COUNT
}
class ERR_PerfStat
@@ -40,7 +40,8 @@ class ERR_Performance
"hq_prep_ms", "hq_hunters_ms", "hq_garrisons_ms", "hq_convoys_ms", "hq_bodies_ms", "hq_radio_index_ms",
"hq_reports_ms", "hq_planner_ms", "hq_support_ms", "hq_journal_ms",
"update_interval_ms", "engine_sim_sample_ms", "engine_frame_sample_ms", "engine_fps_sample",
- "active_ai_sample", "players_sample", "hq_groups_sample", "garrison_work_ms", "performance_flush_ms"
+ "active_ai_sample", "players_sample", "hq_groups_sample", "garrison_work_ms", "performance_flush_ms",
+ "convoy_intercept_ms", "convoy_runtime_ms", "convoy_startup_ms", "convoy_survey_ms", "convoy_create_ms", "convoy_cargo_ms"
};
protected string m_Run;
protected int m_Window;
diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyRoadPlan.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyRoadPlan.c
index 88e42e6..9216619 100644
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyRoadPlan.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyRoadPlan.c
@@ -126,42 +126,94 @@ class ERR_ConvoyRoadPlan
}
static bool Build(vector seed, out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route, array<vector> excludedStarts = null, float separation = 0)
{
- positions = {}; angles = {}; route = null;
- if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(seed)) return false;
- SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
- if (!world || !world.GetRoadNetworkManager()) return false;
- array<BaseRoad> roads = {};
- world.GetRoadNetworkManager().GetRoadsInAABB(seed + "-500 -1000 -500", seed + "500 1000 500", roads);
- if (roads.Count() > 128) return false;
- float bestDistance = 1e20;
- foreach (BaseRoad road : roads)
+ ERR_ConvoyRoadSurvey survey = new ERR_ConvoyRoadSurvey();
+ survey.Begin(seed, excludedStarts, separation);
+ while (!survey.Step()) {}
+ return survey.Result(positions, angles, route);
+ }
+}
+
+// Cooperative survey: each Step performs one native road lookup/setup OR one
+// formation candidate. Native calls themselves are not a hard time budget.
+// The complete road/candidate order and final tie-breaking match Build.
+class ERR_ConvoyRoadSurvey
+{
+ protected vector m_Seed;
+ protected float m_Separation;
+ protected float m_BestDistance;
+ protected ref array<vector> m_Excluded = {};
+ protected ref array<BaseRoad> m_Roads = {};
+ protected ref array<vector> m_Points = {};
+ protected ref array<vector> m_Positions = {};
+ protected ref array<vector> m_Angles = {};
+ protected ref ERR_ForceRoute m_Route;
+ protected int m_RoadIndex;
+ protected int m_CandidateIndex;
+ protected bool m_Queried;
+ protected bool m_Loaded;
+ protected bool m_Done = true;
+
+ void Begin(vector seed, array<vector> excludedStarts = null, float separation = 0)
+ {
+ m_Seed = seed; m_Separation = separation; m_BestDistance = 1e20;
+ m_Excluded.Clear();
+ if (excludedStarts) foreach (vector excluded : excludedStarts) m_Excluded.Insert(excluded);
+ m_Roads.Clear(); m_Points.Clear(); m_Positions = {}; m_Angles = {}; m_Route = null;
+ m_RoadIndex = 0; m_CandidateIndex = 0;
+ m_Queried = false; m_Loaded = false; m_Done = false;
+ }
+ bool Step()
+ {
+ if (m_Done) return true;
+ if (!m_Queried)
{
- if (!road || road.GetWidth() < 4) continue;
- array<vector> points = {}; road.GetPoints(points);
- for (int candidateIndex = 0; candidateIndex < 33; candidateIndex++)
- {
- array<vector> candidatePositions, candidateAngles;
- ERR_ForceRoute polyline;
- float distance;
- if (!FromPolyline(points, seed, candidatePositions, candidateAngles, polyline, distance, candidateIndex)) continue;
- // Exclude occupied deployment areas before selecting the best formation.
- // Returning the nearest clear-but-too-close road used to hide valid
- // alternatives from startup's outer separation check.
- if (excludedStarts && !ERR_SupplyConvoyRules.Separated(candidatePositions[0], excludedStarts, separation)) continue;
- for (int i = 0; i < candidatePositions.Count(); i++) candidatePositions[i] = ERR_Actors.Ground(candidatePositions[i]);
- ERR_ForceRoute grounded = new ERR_ForceRoute();
- bool valid = true;
- for (int i = 0; i < polyline.Count(); i++)
- {
- vector point = ERR_Actors.Ground(polyline.At(i));
- if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !grounded.Add(point)) { valid = false; break; }
- }
- if (!valid || !grounded.Seal() || !ERR_ConvoyRules.Plan(candidatePositions, candidateAngles, grounded)) continue;
- if (!Prefer(distance, candidatePositions, grounded, bestDistance, positions, route)) continue;
- if (!ERR_ConvoyRules.GroundClear(candidatePositions, candidateAngles)) continue;
- positions = candidatePositions; angles = candidateAngles; route = grounded; bestDistance = distance;
- }
+ m_Queried = true;
+ if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(m_Seed)) { m_Done = true; return true; }
+ SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
+ if (!world || !world.GetRoadNetworkManager()) { m_Done = true; return true; }
+ world.GetRoadNetworkManager().GetRoadsInAABB(m_Seed + "-500 -1000 -500", m_Seed + "500 1000 500", m_Roads);
+ if (m_Roads.Count() > 128 || m_Roads.IsEmpty()) m_Done = true;
+ return m_Done;
+ }
+ if (!m_Loaded)
+ {
+ if (m_RoadIndex >= m_Roads.Count()) { m_Done = true; return true; }
+ BaseRoad road = m_Roads[m_RoadIndex++];
+ if (!road || road.GetWidth() < 4) return false;
+ m_Points.Clear(); road.GetPoints(m_Points);
+ m_CandidateIndex = 0; m_Loaded = true;
+ return false;
+ }
+ int candidateIndex = m_CandidateIndex++;
+ if (m_CandidateIndex >= 33) m_Loaded = false;
+ Consider(candidateIndex);
+ return false;
+ }
+ protected void Consider(int candidateIndex)
+ {
+ array<vector> candidatePositions, candidateAngles;
+ ERR_ForceRoute polyline;
+ float distance;
+ if (!ERR_ConvoyRoadPlan.FromPolyline(m_Points, m_Seed, candidatePositions, candidateAngles, polyline, distance, candidateIndex)) return;
+ if (!ERR_SupplyConvoyRules.Separated(candidatePositions[0], m_Excluded, m_Separation)) return;
+ for (int i = 0; i < candidatePositions.Count(); i++) candidatePositions[i] = ERR_Actors.Ground(candidatePositions[i]);
+ ERR_ForceRoute grounded = new ERR_ForceRoute();
+ bool valid = true;
+ for (int i = 0; i < polyline.Count(); i++)
+ {
+ vector point = ERR_Actors.Ground(polyline.At(i));
+ if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !grounded.Add(point)) { valid = false; break; }
}
- return route != null;
+ if (!valid || !grounded.Seal() || !ERR_ConvoyRules.Plan(candidatePositions, candidateAngles, grounded)) return;
+ if (!ERR_ConvoyRoadPlan.Prefer(distance, candidatePositions, grounded, m_BestDistance, m_Positions, m_Route)) return;
+ if (!ERR_ConvoyRules.GroundClear(candidatePositions, candidateAngles)) return;
+ m_Positions = candidatePositions; m_Angles = candidateAngles; m_Route = grounded; m_BestDistance = distance;
+ }
+ bool Result(out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route)
+ {
+ positions = {}; angles = {}; route = null;
+ if (!m_Done || !m_Route) return false;
+ positions = m_Positions; angles = m_Angles; route = m_Route;
+ return true;
}
}
diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
index cc773b9..9459938 100644
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
@@ -334,6 +334,7 @@ class ERR_ConvoyTask
ref array<ref ERR_ConvoyCargoCallback> Cargo = {};
bool Stopped;
bool Ready;
+ bool Creating; // Supply service queues physical creation before readiness begins.
bool CargoIssued;
float Deadline;
float NextCheck;
@@ -370,9 +371,9 @@ class ERR_ConvoyService
foreach (ERR_ConvoyTask task : m_Tasks) if (task.Receipt && task.Receipt.SourceId == source) return task;
return null;
}
- protected bool FillCrew(ERR_ConvoyVehicle unit)
+ protected bool SelectCrewSeats(ERR_ConvoyVehicle unit)
{
- if (unit.Index == 2) return false;
+ if (!unit || !unit.Group || !unit.Entity || !unit.Seats.IsEmpty() || unit.Index == 2) return false;
array<BaseCompartmentSlot> all = {};
if (!ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false;
array<BaseCompartmentSlot> drivers = {}, turrets = {}, cargo = {};
@@ -391,14 +392,25 @@ class ERR_ConvoyService
for (int i = 0; i < requiredTurrets; i++) unit.Seats.Insert(turrets[i]);
// Exact planned population is known before any character is created.
if (unit.Seats.Count() != ERR_ConvoyPool.CrewCount(unit.Index)) return false;
+ return true;
+ }
+ protected bool SpawnCrewMember(ERR_ConvoyVehicle unit, int index)
+ {
+ if (!unit || !unit.Group || index < 0 || index >= unit.Seats.Count() || index != unit.Members.Count()) return false;
+ BaseCompartmentSlot seat = unit.Seats[index];
+ if (!seat || !seat.IsCompartmentAccessible() || seat.IsOccupied()) 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];
+ 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;
+ if (index == 0) unit.Driver = actor;
+ return true;
+ }
+ protected bool FillCrew(ERR_ConvoyVehicle unit)
+ {
+ if (!SelectCrewSeats(unit)) return false;
+ for (int index = 0; index < unit.Seats.Count(); index++)
+ if (!SpawnCrewMember(unit, index)) return false;
return true;
}
ERR_ConvoyTask Dispatch(ERR_ConvoyReceipt receipt, array<vector> positions, array<vector> angles, ERR_ForceRoute route, float now)
diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
index 5c38e20..f1da514 100644
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
@@ -1813,10 +1813,17 @@ class ERR_HQ : ERR_CommandService
m_ObjectiveDefence.Tick(now);
m_Towns.Tick(now);
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_GARRISONS, perfStamp);
+ int convoyStamp = perfStamp;
TickMountedIntercept(now);
+ if (perf) convoyStamp = perf.Mark(ERR_PerfMetric.CONVOY_INTERCEPT, convoyStamp);
m_Convoys.Tick(now);
+ if (perf) convoyStamp = perf.Mark(ERR_PerfMetric.CONVOY_RUNTIME, convoyStamp);
AutomaticConvoy(now);
- if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_CONVOYS, perfStamp);
+ if (perf)
+ {
+ perf.Mark(ERR_PerfMetric.CONVOY_STARTUP, convoyStamp);
+ perfStamp = perf.Mark(ERR_PerfMetric.HQ_CONVOYS, perfStamp);
+ }
m_Bodies.TickBudget(now);
if (perf) perfStamp = perf.Mark(ERR_PerfMetric.HQ_BODIES, perfStamp);
if (now >= m_NextNetwork)
diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
index 9edda1c..5cf5e07 100644
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
@@ -1,3 +1,26 @@
+// A consumed convoy receipt owns one finite physical creation transaction.
+// Native spawn calls are spread across callbacks; successful partial entities
+// stay mission-owned and lootable on refusal, capture or cancellation.
+class ERR_SupplyConvoyCreation
+{
+ ref ERR_SupplyConvoyTask Task;
+ ref array<vector> Positions = {};
+ ref array<vector> Angles = {};
+ ref array<ResourceName> Prefabs = {};
+ float Expires;
+ int VehicleIndex;
+ int Phase;
+}
+
+class ERR_SupplyConvoyLoad
+{
+ ref ERR_SupplyConvoyTask Task;
+ ERR_ConvoyVehicle OriginalVehicle;
+ IEntity Truck;
+ InventoryStorageManagerComponent Inventory;
+ ref ERR_ConvoyCargoCallback Operation;
+}
+
// Full supply convoys reuse finite vehicle creation and cargo ownership. The
// legacy finite-route service remains available for isolated migration fixtures.
class ERR_SupplyConvoyService : ERR_ConvoyService
@@ -5,6 +28,240 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
void ERR_SupplyConvoyService(ERR_GameMode mode, ERR_ConvoyPool pool) {}
protected ref ERR_SupplyConvoyTask m_Creating;
protected ref array<vector> m_Bases = {};
+ protected ref array<ref ERR_SupplyConvoyCreation> m_Creations = {};
+ protected bool m_CreationQueued;
+ protected ref array<ref ERR_SupplyConvoyLoad> m_Loads = {};
+ protected bool m_CargoQueued;
+ override 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 || m_Creations.Count() >= 20
+ || !ERR_ConvoyRules.Plan(positions, angles, route) || !ERR_ConvoyResources.Available() || !ERR_ConvoyRules.GroundClear(positions, angles)) return null;
+ array<ResourceName> selected = {};
+ for (int slot = 0; slot < 6; slot++)
+ {
+ ResourceName prefab = ERR_ConvoyResources.VehiclePrefab(slot);
+ if (slot == 1) prefab = ERR_VehicleSelection.ForRole(4, prefab);
+ if (slot == 0 || slot == 3 || slot == 5) prefab = ERR_VehicleSelection.ForRole(3, prefab);
+ if (slot != 2 && prefab.IsEmpty()) return null;
+ selected.Insert(prefab);
+ }
+ if (!m_Mode.EnemyFaction() || !m_Pool.Consume(receipt)) return null;
+ ERR_SupplyConvoyTask task = ERR_SupplyConvoyTask.Cast(NewTask());
+ task.Receipt = receipt; task.Route = route; task.Creating = true;
+ for (int index = 0; index < 6; index++)
+ {
+ ERR_ConvoyVehicle unit = NewVehicle(); unit.Index = index;
+ if (index == 2) unit.Released = true;
+ task.Vehicles.Insert(unit);
+ }
+ m_Tasks.Insert(task);
+ ERR_SupplyConvoyCreation creation = new ERR_SupplyConvoyCreation();
+ creation.Task = task; creation.Positions.Copy(positions); creation.Angles.Copy(angles); creation.Prefabs.Copy(selected);
+ creation.Expires = now + 60000; m_Creations.Insert(creation);
+ if (!m_CreationQueued)
+ {
+ m_CreationQueued = true;
+ GetGame().GetCallqueue().CallLater(CreationStep, 50, true);
+ }
+ return task;
+ }
+ protected void FinishCreation(ERR_SupplyConvoyCreation creation, bool success, string reason)
+ {
+ ERR_SupplyConvoyTask task = creation.Task;
+ task.Creating = false;
+ if (success)
+ {
+ task.Deadline = GetGame().GetWorld().GetWorldTime() + 30000;
+ task.NextCheck = 0;
+ ERR_HQ.Event("CONVOY CREATED", "five physical vehicles; finite crew created over queued callbacks; native readiness and ledger admission pending");
+ }
+ else task.Stop(m_Mode, reason);
+ m_Creations.RemoveItem(creation);
+ if (m_Creations.IsEmpty())
+ {
+ GetGame().GetCallqueue().Remove(CreationStep);
+ m_CreationQueued = false;
+ }
+ }
+ protected bool CreationOwned(ERR_SupplyConvoyCreation creation)
+ {
+ ERR_SupplyConvoyTask task = creation.Task;
+ if (!task || task.Stopped || !task.Creating || !task.Receipt || !task.Receipt.Consumed || task.Vehicles.Count() != 6) return false;
+ if (Captured(task)) return false;
+ foreach (ERR_ConvoyVehicle unit : task.Vehicles)
+ {
+ if (!unit || (unit.Index != 2 && unit.Released)) return false;
+ if (unit.Index == 2) continue;
+ bool groupCreated = unit.Index < creation.VehicleIndex || (unit.Index == creation.VehicleIndex && creation.Phase >= 1);
+ bool vehicleCreated = unit.Index < creation.VehicleIndex || (unit.Index == creation.VehicleIndex && creation.Phase >= 2);
+ if ((groupCreated && !unit.Group) || (vehicleCreated && !unit.Entity)) return false;
+ if (unit.Group && (unit.Group.GetFaction() != m_Mode.EnemyFaction() || unit.Group.GetPlayerCount(true) > 0)) return false;
+ if (unit.Entity && !ERR_Actors.Alive(unit.Entity)) return false;
+ for (int member = 0; member < unit.Members.Count(); member++)
+ {
+ IEntity actor = unit.Members[member];
+ if (!actor || !ERR_Actors.Alive(actor) || ERR_Actors.Player(actor) || member >= unit.Seats.Count()
+ || !unit.Seats[member]) return false;
+ // Native compartment membership can settle only after the later Wake/Admit
+ // pass. An empty pending seat is not a foreign occupant or permission to refill.
+ IEntity occupant = unit.Seats[member].GetOccupant();
+ if (occupant && occupant != actor) return false;
+ SCR_AIGroup parent = ERR_Actors.Group(actor);
+ if (parent && parent != unit.Group) return false;
+ }
+ if (unit.Group)
+ {
+ array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
+ foreach (AIAgent agent : agents)
+ if (!agent || !unit.Members.Contains(agent.GetControlledEntity())) return false;
+ }
+ }
+ return true;
+ }
+ // One native group, vehicle or character spawn at most, globally for this
+ // service's queue. No catch-up loop after a long frame or empty-server pause.
+ protected void CreationStep()
+ {
+ int started = System.GetTickCount();
+ CreationPiece();
+ if (m_Mode && m_Mode.Performance()) m_Mode.Performance().Mark(ERR_PerfMetric.CONVOY_CREATE, started);
+ }
+ protected void CreationPiece()
+ {
+ if (!Current()) { Stop(); return; }
+ if (m_Creations.IsEmpty())
+ { GetGame().GetCallqueue().Remove(CreationStep); m_CreationQueued = false; return; }
+ // Match the mission's existing no-player suspension. A resumed transaction
+ // past its finite deadline stops instead of creating replacement bodies.
+ if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
+ ERR_SupplyConvoyCreation creation = m_Creations[0];
+ ERR_SupplyConvoyTask task = creation.Task;
+ float now = GetGame().GetWorld().GetWorldTime();
+ if (!ERR_ForceRules.Finite(now) || now >= creation.Expires || !CreationOwned(creation))
+ { FinishCreation(creation, false, "finite convoy creation expired, captured or changed; partial entities retained"); return; }
+ if (creation.VehicleIndex == 2) creation.VehicleIndex++;
+ if (creation.VehicleIndex >= 6) { FinishCreation(creation, true, ""); return; }
+ int index = creation.VehicleIndex;
+ ERR_ConvoyVehicle unit = task.Vehicles[index];
+ if (creation.Phase == 0)
+ {
+ unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), creation.Positions[index]));
+ if (unit.Group) m_Mode.Own(unit.Group);
+ if (!unit.Group || unit.Group.GetFaction() != m_Mode.EnemyFaction() || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group))
+ { FinishCreation(creation, false, "finite convoy group creation failed; no retry"); return; }
+ creation.Phase = 1; return;
+ }
+ if (creation.Phase == 1)
+ {
+ // A player or unrelated entity may have entered this footprint since dispatch.
+ ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
+ if (!placement.Clear(creation.Positions[index], creation.Angles[index][0]) || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group))
+ { FinishCreation(creation, false, "convoy placement or original empty crew changed; no retry"); return; }
+ unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(creation.Prefabs[index], creation.Positions[index], creation.Angles[index]));
+ if (unit.Entity) m_Mode.Own(unit.Entity);
+ bool assigned = creation.Prefabs[index] != ERR_ConvoyResources.VehiclePrefab(index);
+ bool compatible = !assigned || (ERR_VehicleSelection.RoadFit(unit.Entity) && (index != 1 || unit.Entity.FindComponent(InventoryStorageManagerComponent)));
+ if (!unit.Entity || !compatible)
+ {
+ int role = 3; if (index == 1) role = 4;
+ if (assigned) GetGame().ERR_ServerVehicles().Reject(creation.Prefabs[index], role);
+ FinishCreation(creation, false, "finite convoy vehicle creation failed; no retry"); return;
+ }
+ task.Starts.Insert(unit.Entity.GetOrigin());
+ 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);
+ creation.Phase = 2; return;
+ }
+ if (creation.Phase == 2)
+ {
+ if (!ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !SelectCrewSeats(unit))
+ { FinishCreation(creation, false, "finite convoy seats unavailable; no retry"); return; }
+ creation.Phase = 3; return;
+ }
+ if (!SpawnCrewMember(unit, unit.Members.Count()))
+ { FinishCreation(creation, false, "finite convoy crew creation failed; no retry"); return; }
+ if (unit.Members.Count() == unit.Seats.Count())
+ { creation.VehicleIndex++; creation.Phase = 0; }
+ }
+ protected void QueueCargo(ERR_SupplyConvoyTask task, InventoryStorageManagerComponent inventory, ERR_ConvoyCargoCallback operation)
+ {
+ ERR_SupplyConvoyLoad load = new ERR_SupplyConvoyLoad();
+ load.Task = task; load.OriginalVehicle = task.Vehicles[1]; load.Truck = load.OriginalVehicle.Entity;
+ load.Inventory = inventory; load.Operation = operation; m_Loads.Insert(load);
+ if (!m_CargoQueued)
+ {
+ m_CargoQueued = true;
+ GetGame().GetCallqueue().CallLater(CargoStep, 50, true);
+ }
+ }
+ protected bool CargoPending(ERR_SupplyConvoyTask task)
+ {
+ foreach (ERR_SupplyConvoyLoad load : m_Loads) if (load.Task == task) return true;
+ return false;
+ }
+ protected void CancelCargo(ERR_SupplyConvoyTask task)
+ {
+ // Only requests still in this queue have not crossed the native boundary.
+ // Accepted operations were removed before submission and are never rewritten.
+ for (int index = m_Loads.Count() - 1; index >= 0; index--)
+ {
+ ERR_SupplyConvoyLoad load = m_Loads[index];
+ if (task && load.Task != task) continue;
+ load.Operation.Finished = true; load.Operation.Succeeded = false;
+ m_Loads.Remove(index);
+ }
+ }
+ protected bool CargoOwner(ERR_SupplyConvoyLoad load)
+ {
+ ERR_SupplyConvoyTask task = load.Task;
+ if (!task || task.Stopped || !task.Ready || task.Creating || task.Vehicles.Count() != 6 || task.Vehicles[1] != load.OriginalVehicle
+ || !load.OriginalVehicle || load.OriginalVehicle.Released || load.OriginalVehicle.Entity != load.Truck || !load.Truck
+ || !load.Inventory || load.Inventory.GetOwner() != load.Truck || !load.OriginalVehicle.CrewValid(m_Mode) || Captured(task)) return false;
+ return load.Truck.FindComponent(InventoryStorageManagerComponent) == load.Inventory;
+ }
+ protected void CargoStep()
+ {
+ int started = System.GetTickCount();
+ CargoPiece();
+ if (m_Mode && m_Mode.Performance()) m_Mode.Performance().Mark(ERR_PerfMetric.CONVOY_CARGO, started);
+ }
+ protected void CargoPiece()
+ {
+ if (!Current()) { Stop(); return; }
+ if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
+ // One globally bounded pair of native insertion requests, never a catch-up
+ // loop. Original prefabs/callbacks and random choices were frozen by planning.
+ for (int issued = 0; issued < 2 && !m_Loads.IsEmpty(); issued++)
+ {
+ ERR_SupplyConvoyLoad load = m_Loads[0];
+ if (!CargoOwner(load))
+ {
+ CancelCargo(load.Task);
+ // The ordinary convoy tick owns capture and per-vehicle release policy.
+ // Losing this truck's cargo authority must not stop surviving escorts.
+ continue;
+ }
+ m_Loads.Remove(0);
+ if (!load.Inventory.TrySpawnPrefabToStorage(load.Operation.Prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load.Operation)) load.Operation.Finished = true;
+ }
+ if (m_Loads.IsEmpty())
+ {
+ GetGame().GetCallqueue().Remove(CargoStep);
+ m_CargoQueued = false;
+ }
+ }
+ override void Stop()
+ {
+ if (GetGame()) GetGame().GetCallqueue().Remove(CreationStep);
+ m_CreationQueued = false;
+ foreach (ERR_SupplyConvoyCreation creation : m_Creations) creation.Task.Creating = false;
+ m_Creations.Clear();
+ if (GetGame()) GetGame().GetCallqueue().Remove(CargoStep);
+ m_CargoQueued = false; CancelCargo(null);
+ super.Stop();
+ }
void SetBases(array<vector> bases) { m_Bases.Copy(bases); }
protected override ERR_ConvoyTask NewTask()
{
@@ -81,7 +338,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
{
ERR_ConvoyCargoCallback load = new ERR_ConvoyCargoCallback();
load.Prefab = seed.Prefab; task.Cargo.Insert(load); issued++;
- if (!inventory.TrySpawnPrefabToStorage(seed.Prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load)) load.Finished = true;
+ QueueCargo(task, inventory, load);
}
}
return issued;
@@ -108,12 +365,22 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
if (choice) prefab = choice.GetItemResourceName();
}
ERR_ConvoyCargoCallback operation = new ERR_ConvoyCargoCallback(); operation.Prefab = prefab; supply.Cargo.Insert(operation);
- if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, operation)) operation.Finished = true;
+ QueueCargo(supply, inventory, operation);
}
}
protected void IssueMagazines(ERR_SupplyConvoyTask task, float now)
{
- if (task.MagazinesIssued || !task.CargoIssued || task.Stopped) return;
+ if (!task || task.MagazinesIssued || !task.CargoIssued || task.Stopped || CargoPending(task)) return;
+ ERR_ConvoyVehicle vehicle = null;
+ if (task.Vehicles.Count() == 6) vehicle = task.Vehicles[1];
+ // A queued weapon batch may have been cancelled after its truck disappeared
+ // or lost its original crew. Finish only cargo planning; escorts keep running.
+ if (!vehicle || vehicle.Released || !vehicle.Entity || !vehicle.CrewValid(m_Mode))
+ {
+ task.MagazinesIssued = true;
+ task.MountedStock.Clear(); task.CargoBefore.Clear();
+ return;
+ }
if (task.Cargo.Count() > 128) { task.MagazinesIssued = true; return; }
bool cargoSettled = true;
foreach (ERR_ConvoyCargoCallback operation : task.Cargo)
@@ -152,7 +419,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
for (int mag = 0; mag < 6 && issued < 96 && task.Cargo.Count() < 128; mag++)
{
ERR_ConvoyCargoCallback load = new ERR_ConvoyCargoCallback(); load.Prefab = magazine; task.Cargo.Insert(load); issued++;
- if (!inventory.TrySpawnPrefabToStorage(magazine, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load)) load.Finished = true;
+ QueueCargo(task, inventory, load);
}
}
}
@@ -204,7 +471,7 @@ class ERR_SupplyConvoyService : ERR_ConvoyService
foreach (ERR_ConvoyTask baseTask : m_Tasks)
{
ERR_SupplyConvoyTask task = ERR_SupplyConvoyTask.Cast(baseTask);
- if (!task || task.Stopped || now < task.NextCheck) continue;
+ if (!task || task.Stopped || task.Creating || now < task.NextCheck) continue;
task.NextCheck = now + 1000;
if (Captured(task)) { task.Stop(m_Mode, "player captured convoy vehicle or crew"); continue; }
if (!task.Ready)
diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c
index 1c44973..f387997 100644
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoySystem.c
@@ -13,8 +13,8 @@ class ERR_SupplyConvoyRules
}
}
-// Original finite startup target, eight bounded road candidates per attempt,
-// separated initial positions and base-to-base circulation; never replacement spawns.
+// Original finite startup target and eight seeds per attempt. Survey and
+// physical startup yield between bounded pieces; never replacement spawns.
class ERR_SupplyConvoySystem
{
protected ERR_GameMode m_Mode;
@@ -30,6 +30,12 @@ class ERR_SupplyConvoySystem
protected float m_Next;
protected string m_Reason;
protected string m_LastFailure;
+ protected bool m_Searching, m_FindingBases;
+ protected int m_BaseCursor, m_SeedAttempt, m_StartingCount;
+ protected string m_Source;
+ protected vector m_Low, m_High;
+ protected float m_Separation;
+ protected ref ERR_ConvoyRoadSurvey m_Survey;
void ERR_SupplyConvoySystem(ERR_GameMode mode, ERR_ConvoyPool pool, ERR_SupplyConvoyService service, int target, bool automatic)
{
m_Mode = mode; m_Pool = pool; m_Service = service;
@@ -61,63 +67,116 @@ class ERR_SupplyConvoySystem
if (length < 0.01) continue;
vector offset = center - points[i - 1]; offset[1] = 0;
float fraction = Math.Clamp(vector.Dot(offset, delta) / length, 0, 1);
- vector candidate = ERR_Actors.Ground(vector.Lerp(points[i - 1], points[i], fraction));
+ vector candidate = vector.Lerp(points[i - 1], points[i], fraction);
float distance = vector.DistanceXZ(center, candidate);
- if (distance >= nearest || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) continue;
+ // Height cannot change XZ ranking. Avoid terrain/native water calls for
+ // segments that cannot improve the nearest road approach.
+ if (distance >= nearest) continue;
+ candidate = ERR_Actors.Ground(candidate);
+ if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) continue;
nearest = distance; position = candidate; angles = Vector(Math.Atan2(delta[0], delta[2]) * Math.RAD2DEG, 0, 0); found = true;
}
}
return found;
}
- protected void FindBases()
+ // One site's road approach per callback, instead of all sites in one HQ tick.
+ protected bool FindBaseStep()
{
- m_Bases.Clear();
- if (!m_Mode.Sites()) return;
- for (int i = 0; i < m_Mode.Sites().Count(); i++)
- {
- ERR_SiteRuntime site = m_Mode.Sites().At(i);
- if (!site || !site.Definition) continue;
- ERR_LocationKind kind = site.Definition.Kind;
- if (kind != ERR_LocationKind.OUTPOST && kind != ERR_LocationKind.DEPOT && kind != ERR_LocationKind.RADIO) continue;
- if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) continue;
- vector approach;
- if (Approach(site.Definition.Position, approach) && ERR_SupplyConvoyRules.Separated(approach, m_Bases, 300)) m_Bases.Insert(approach);
- }
- m_Service.SetBases(m_Bases);
+ if (!m_Mode.Sites() || m_BaseCursor >= m_Mode.Sites().Count())
+ { m_Service.SetBases(m_Bases); return true; }
+ ERR_SiteRuntime site = m_Mode.Sites().At(m_BaseCursor++);
+ if (!site || !site.Definition) return false;
+ ERR_LocationKind kind = site.Definition.Kind;
+ if (kind != ERR_LocationKind.OUTPOST && kind != ERR_LocationKind.DEPOT && kind != ERR_LocationKind.RADIO) return false;
+ if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) return false;
+ vector approach;
+ if (Approach(site.Definition.Position, approach) && ERR_SupplyConvoyRules.Separated(approach, m_Bases, 300)) m_Bases.Insert(approach);
+ return false;
}
bool StartOne()
{
- if (m_Stopped || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || m_Service.Count() >= m_Target) return false;
- string source = "supply-convoy-" + (m_Service.Count() + 1).ToString();
- // Existing source receipts already reserve their future crew. Only an
- // impossible new body reservation can be refused before terrain surveying.
- if (!m_Pool || !m_Pool.BodyBudgetAvailable(source))
+ if (m_Stopped || m_Searching || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || m_Service.Count() >= m_Target) return false;
+ m_StartingCount = m_Service.Count();
+ m_Source = "supply-convoy-" + (m_StartingCount + 1).ToString();
+ if (!m_Pool || !m_Pool.BodyBudgetAvailable(m_Source))
{ m_Reason = RefusalReason(null); return false; }
- if (m_Bases.Count() < 2) FindBases();
- if (m_Bases.Count() < 2) { m_Reason = "waiting for two friendly military road approaches"; return false; }
if (!GetGame().GetWorldEntity()) { m_Reason = "world bounds unavailable for road survey"; return false; }
- vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
- float separation = Math.Min(1000, Math.Max(high[0] - low[0], high[2] - low[2]) * 0.1);
- for (int attempt = 0; attempt < 8; attempt++)
+ GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
+ m_Separation = Math.Min(1000, Math.Max(m_High[0] - m_Low[0], m_High[2] - m_Low[2]) * 0.1);
+ m_FindingBases = m_Bases.Count() < 2;
+ if (m_FindingBases) { m_Bases.Clear(); m_BaseCursor = 0; }
+ m_SeedAttempt = 0; m_Survey = null; m_Searching = true;
+ m_Reason = "road survey queued; no convoy slot consumed yet";
+ GetGame().GetCallqueue().CallLater(SearchStep, 20, true);
+ return true;
+ }
+ protected void FinishSearch(string reason, bool failed = true)
+ {
+ if (GetGame()) GetGame().GetCallqueue().Remove(SearchStep);
+ m_Survey = null; m_Searching = false; m_FindingBases = false; m_Reason = reason;
+ m_Next = GetGame().GetWorld().GetWorldTime() + 5000;
+ if (!failed) { m_LastFailure = string.Empty; return; }
+ NoteFailure();
+ if (m_Attempts >= 120 && m_Service.Count() < m_Target)
+ ERR_HQ.Event("CONVOY STARTUP", "bounded startup attempts exhausted; existing crews retained, no replacements. " + Status());
+ }
+ protected void SearchStep()
+ {
+ if (!m_Searching) return;
+ if (m_Stopped || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) { Stop(); return; }
+ // Match ordinary HQ's empty-server pause; never survey or spawn unseen rounds.
+ if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
+ ERR_Performance perf = m_Mode.Performance();
+ int started = System.GetTickCount();
+ SearchPiece();
+ if (perf) perf.Mark(ERR_PerfMetric.CONVOY_SURVEY, started);
+ }
+ protected void SearchPiece()
+ {
+ // A queued plan grants no reservation. Other admissions may spend capacity
+ // while it yields, so validate again before any native mutation.
+ if (m_Service.Count() != m_StartingCount || m_Service.Count() >= m_Target || !m_Pool || !m_Pool.BodyBudgetAvailable(m_Source))
+ { FinishSearch("convoy capacity changed while surveying; no entities created"); return; }
+ if (m_FindingBases)
{
- vector seed = Vector(Math.RandomFloat(low[0], high[0]), 0, Math.RandomFloat(low[2], high[2]));
- // A nearby base is the first candidate; remaining attempts cover the island.
- if (attempt == 0) seed = m_Bases[(m_Attempts + m_Service.Count()) % m_Bases.Count()];
- seed = ERR_Actors.Ground(seed);
- array<vector> positions, angles; ERR_ForceRoute route;
- if (!ERR_ConvoyRoadPlan.Build(seed, positions, angles, route, m_Starts, separation) || !ERR_SupplyConvoyRules.Separated(positions[0], m_Starts, separation)) continue;
- ERR_ConvoyReceipt receipt = m_Pool.Reserve(source);
- if (!receipt || receipt.Consumed) { m_Reason = RefusalReason(receipt); return false; }
- ERR_ConvoyTask task = m_Service.Dispatch(receipt, positions, angles, route, GetGame().GetWorld().GetWorldTime());
- if (!task) { m_Reason = "road preflight changed; no entities created"; return false; }
- m_Starts.Insert(positions[0]);
- m_Reason = "finite convoy slot deployed";
- if (task.Stopped) m_Reason = "partial startup retained; this slot cannot respawn";
- ERR_HQ.Event("CONVOY STARTUP", string.Format("%1/%2 at %3: %4", m_Service.Count(), m_Target, positions[0], m_Reason));
- return !task.Stopped;
+ if (!FindBaseStep()) return;
+ m_FindingBases = false;
+ if (m_Bases.Count() < 2) FinishSearch("waiting for two friendly military road approaches");
+ return;
}
- m_Reason = "no clear separated road formation in this bounded survey";
- return false;
+ if (!m_Survey)
+ {
+ if (m_SeedAttempt >= 8) { FinishSearch("no clear separated road formation in this bounded survey"); return; }
+ vector seed = Vector(Math.RandomFloat(m_Low[0], m_High[0]), 0, Math.RandomFloat(m_Low[2], m_High[2]));
+ if (m_SeedAttempt == 0) seed = m_Bases[(m_Attempts + m_StartingCount) % m_Bases.Count()];
+ m_SeedAttempt++;
+ m_Survey = new ERR_ConvoyRoadSurvey();
+ m_Survey.Begin(ERR_Actors.Ground(seed), m_Starts, m_Separation);
+ return;
+ }
+ // Cooperative 2ms target and hard eight-piece cap. A single native query or
+ // terrain trace cannot be preempted, and uptime can coarsen the native timer.
+ int started = System.GetTickCount();
+ bool complete;
+ for (int piece = 0; piece < 8; piece++)
+ {
+ complete = m_Survey.Step();
+ if (complete || System.GetTickCount() - started >= 2) break;
+ }
+ if (!complete) return;
+ array<vector> positions, angles; ERR_ForceRoute route;
+ bool found = m_Survey.Result(positions, angles, route);
+ m_Survey = null;
+ if (!found || !ERR_SupplyConvoyRules.Separated(positions[0], m_Starts, m_Separation)) return;
+ ERR_ConvoyReceipt receipt = m_Pool.Reserve(m_Source);
+ if (!receipt || receipt.Consumed) { FinishSearch(RefusalReason(receipt)); return; }
+ ERR_ConvoyTask task = m_Service.Dispatch(receipt, positions, angles, route, GetGame().GetWorld().GetWorldTime());
+ if (!task) { FinishSearch("road preflight changed; no entities created"); return; }
+ m_Starts.Insert(positions[0]);
+ string reason = "finite convoy slot reserved; staged physical startup pending";
+ if (task.Stopped) reason = "partial startup retained; this slot cannot respawn";
+ FinishSearch(reason, false);
+ ERR_HQ.Event("CONVOY STARTUP", string.Format("%1/%2 at %3: %4", m_Service.Count(), m_Target, positions[0], m_Reason));
}
protected string RefusalReason(ERR_ConvoyReceipt receipt)
{
@@ -139,7 +198,7 @@ class ERR_SupplyConvoySystem
}
void Tick(float now, bool admitted)
{
- if (m_Stopped || !m_Automatic || m_Target == 0 || m_Attempts >= 120 || m_Service.Count() >= m_Target || !ERR_ForceRules.Finite(now) || now < 0) return;
+ if (m_Stopped || m_Searching || !m_Automatic || m_Target == 0 || m_Attempts >= 120 || m_Service.Count() >= m_Target || !ERR_ForceRules.Finite(now) || now < 0) return;
if (m_Began < 0) { m_Began = now; m_Next = now + 30000; }
if (now < m_Next) return;
m_Next = now + 5000;
@@ -147,11 +206,15 @@ class ERR_SupplyConvoySystem
m_Attempts++;
if (StartOne()) m_LastFailure = string.Empty;
else NoteFailure();
- if (m_Attempts == 120 && m_Service.Count() < m_Target) ERR_HQ.Event("CONVOY STARTUP", "bounded startup attempts exhausted; existing crews retained, no replacements. " + Status());
+ if (!m_Searching && m_Attempts == 120 && m_Service.Count() < m_Target) ERR_HQ.Event("CONVOY STARTUP", "bounded startup attempts exhausted; existing crews retained, no replacements. " + Status());
}
string Status()
{
return string.Format("This round: %1/%2 convoy slots; automatic=%3, startup attempts=%4/120; %5", m_Service.Count(), m_Target, m_Automatic, m_Attempts, m_Reason) + "; " + BudgetStatus();
}
- void Stop() { m_Stopped = true; }
+ void Stop()
+ {
+ m_Stopped = true; m_Searching = false; m_FindingBases = false; m_Survey = null;
+ if (GetGame()) GetGame().GetCallqueue().Remove(SearchStep);
+ }
}