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

227 lines
12 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_CivilianTraffic.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_CivilianTraffic.c
@@ -33,6 +33,13 @@
float HiddenSince = -1;
float ProgressAt;
vector ProgressPosition;
+ ref SCR_AIVehicleBehavior Boarding;
+ float NextBoarding;
+ void CancelBoarding()
+ {
+ ERR_VehicleBehaviorCleanup.Complete(Boarding, Driver);
+ Boarding = null;
+ }
void Report(string state)
{
if (Status == state) return;
@@ -40,7 +47,7 @@
vector position; if (Car) position = Car.GetOrigin();
ERR_HQ.Event("CIVILIAN TRAFFIC", string.Format("car %1 prefab=%2 at %3: %4", Number, Prefab, position, state));
}
- void Finish(string reason) { Finished = true; Report(reason); }
+ void Finish(string reason) { Finished = true; CancelBoarding(); Report(reason); }
void Protect(bool protectedFromCleanup)
{
IEntity entity = Car; if (!entity) entity = Driver;
@@ -62,7 +69,7 @@
void Release()
{
if (Released) return;
- Released = true; ClearOrders(); Protect(false);
+ Released = true; CancelBoarding(); ClearOrders(); Protect(false);
if (Group)
{
Group.SetPermanentLOD(-1);
@@ -255,8 +262,19 @@
}
protected bool Prepare(ERR_CivilianCar traffic)
{
- if (!traffic.Car || !traffic.Group || !traffic.Seat || traffic.Seat.GetOccupant() != traffic.Driver || !ERR_Actors.CanFight(traffic.Driver))
- { traffic.Report("waiting for living seated driver"); return false; }
+ // Compartment spawning is asynchronous. Distant agents must be awake BEFORE
+ // waiting for their seat; otherwise readiness and activation wait on each other.
+ if (traffic.Group)
+ {
+ traffic.Group.SetPermanentLOD(0); traffic.Group.ActivateAI();
+ array<AIAgent> agents = {}; traffic.Group.GetAgents(agents);
+ foreach (AIAgent agent : agents) if (agent) { agent.SetPermanentLOD(0); agent.ActivateAI(); }
+ }
+ if (!traffic.Car || !traffic.Group || !traffic.Seat || !ERR_Actors.CanFight(traffic.Driver))
+ {
+ bool seated = traffic.Seat && traffic.Driver && traffic.Seat.GetOccupant() == traffic.Driver;
+ traffic.Report(string.Format("waiting for driver: present=%1 fit=%2 seated=%3", traffic.Driver != null, ERR_Actors.CanFight(traffic.Driver), seated)); return false;
+ }
if (ERR_Actors.Group(traffic.Driver) != traffic.Group || traffic.Group.GetAgentsCount() != 1)
{ traffic.Report("waiting for native AI group admission"); return false; }
SCR_AIGroupUtilityComponent utility = traffic.Group.GetGroupUtilityComponent();
@@ -264,6 +282,26 @@
if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage))
{ traffic.Report("waiting for native vehicle registration"); return false; }
utility.AddUsableVehicle(usage);
+ if (traffic.Seat.GetOccupant() != traffic.Driver)
+ {
+ // Recover the original driver if native compartment admission stalled or
+ // the driver got out before route assignment. Never replace or teleport him.
+ if (Foreign(traffic)) { traffic.Release(); return false; }
+ SCR_ChimeraAIAgent driver = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(traffic.Driver));
+ float now = GetGame().GetWorld().GetWorldTime();
+ if (driver && driver.m_UtilityComponent && now >= traffic.NextBoarding && !traffic.Seat.GetOccupant() && !CompartmentAccessComponent.GetVehicleIn(traffic.Driver))
+ {
+ array<ref AIActionBase> actions = {}; driver.m_UtilityComponent.GetActions(actions);
+ if (!traffic.Boarding || !actions.Contains(traffic.Boarding))
+ {
+ traffic.CancelBoarding(); traffic.NextBoarding = now + 10000;
+ traffic.Boarding = new SCR_AIGetInVehicle(driver.m_UtilityComponent, null, traffic.Car, traffic.Seat, EAICompartmentType.Pilot, 1000);
+ driver.m_UtilityComponent.AddAction(traffic.Boarding);
+ }
+ }
+ traffic.Report("waiting for original driver to board; native boarding recovery active"); return false;
+ }
+ traffic.CancelBoarding();
if (!GiveRoute(traffic)) { traffic.Report("waiting for route waypoints"); return false; }
traffic.Ready = true;
traffic.ProgressAt = GetGame().GetWorld().GetWorldTime(); traffic.ProgressPosition = traffic.Car.GetOrigin();
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
@@ -78,6 +78,7 @@
protected ref array<ref ERR_HunterVehicle> m_Hunters = {};
protected float m_NextHunter;
protected float m_NextSpawn;
+ protected float m_NextSummary;
protected int m_Target = 3;
protected ref ERR_HunterOpeningPlan m_Opening;
protected ref ERR_HunterCoordination m_Coordination;
@@ -283,7 +284,7 @@
protected bool Coordinated(vector position) { return m_Coordination && m_Coordination.Covered(position); }
protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
- array<ref ERR_HQReport> reports = {}; hq.SupportContacts(reports, now);
+ array<ref ERR_HQReport> reports = {}; hq.HunterContacts(reports, now);
ERR_HQBelief best;
foreach (ERR_HQReport report : reports)
{
@@ -309,8 +310,10 @@
unit.LastContact = best.ObservedAt; unit.NextContactOrder = now + 5000;
return;
}
- if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 250)) return;
- unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString();
+ if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 250))
+ { ERR_HQ.Event("HUNTER ROUTE", unit.Source + ": delivered sector has no usable approach road; retaining orders", best.MessageId); return; }
+ unit.SourceMessage = best.MessageId;
+ reason = string.Format("investigate last delivered sighting; age=%1s confidence=%2 uncertainty=%3m", (now - best.ObservedAt) / 1000, best.Confidence, best.Radius);
}
else
{
@@ -356,6 +359,12 @@
m_NextHunter = now + 1000;
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command()); if (!hq) return;
m_Target = hq.Watch().HunterTarget();
+ if (now >= m_NextSummary)
+ {
+ m_NextSummary = now + 60000;
+ array<ref ERR_HQReport> searches = {}; hq.HunterContacts(searches, now);
+ ERR_HQ.Event("HUNTER STATUS", string.Format("active=%1 deployed=%2 allowance=%3 identified bodies=%4 searchable delivered sectors=%5; lost vehicles are not replaced", ActiveHunters(), Deployed(), m_Target, hq.Watch().IdentifiedBodies(), searches.Count()));
+ }
if (!m_Opening)
{
array<IEntity> openingPlayers = {}; ERR_Actors.Players(openingPlayers);
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
@@ -14,6 +14,7 @@
// delivered sector. Support availability is not a claim of suppressing fire.
return armedCover && now >= waitingUntil - 30000;
}
+ static bool DismountRange(float range) { return range >= 140 && range <= 450; }
static bool Fresh(float observed, float now) { return observed >= 0 && now >= observed && now - observed <= 60000; }
static vector Offset(vector contact, vector forward, int role, int phase)
{
@@ -84,6 +85,7 @@
protected float m_ApproachStarted;
protected float m_ApproachProgress;
protected vector m_ApproachPosition;
+ protected bool m_UnsupportedSearch;
protected ERR_GameMode m_Mode;
void ERR_HunterTactics(ERR_GameMode mode, ERR_HunterVehicle unit, ERR_HunterAttack attack, int role)
{
@@ -322,8 +324,11 @@
{ m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin(); }
bool near = vector.DistanceXZ(Unit.Entity.GetOrigin(), Stage) <= 55;
bool local = ERR_HunterCombat.Active(Unit.Group) && vector.DistanceXZ(Position(), Attack.ReportPosition) <= 400;
+ // A road can pass close to the reported sector and then loop kilometres
+ // around it to reach the ideal staging point. Continue that last leg on foot.
+ bool onFootReach = ERR_HunterAttackRules.DismountRange(vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition));
if (Role == 0 && near) { Clear(); State = 3; Note("in covering position; native gunner owns target acquisition and fire"); }
- else if (Role > 0 && (near || local) && Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; Note("dismounting original crew"); }
+ else if (Role > 0 && (near || local || onFootReach) && Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; Note("within infantry approach range; dismounting original crew"); }
else if (now >= Deadline)
{
if (ERR_HunterAttackRules.ExtendApproach(m_ApproachStarted, m_ApproachProgress, now))
@@ -341,20 +346,22 @@
{
bool visualCover = Attack.CoverReady();
bool investigate = ERR_HunterAttackRules.AdvanceWithoutVisualCover(Attack.CoverAlive(), Deadline, now);
- if ((visualCover || investigate) && Move(Approach, 20))
- {
+ bool unsupported = !Attack.CoverAlive() && now >= Deadline;
+ if ((visualCover || investigate || unsupported) && Move(Approach, 20))
+ {
+ m_UnsupportedSearch = unsupported;
State = 4; Deadline = now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance);
if (visualCover) Note("cover established; moving around flank via " + Approach.ToString());
+ else if (unsupported) Note("cover lost; bounded infantry investigation of delivered sector via " + Approach.ToString());
else Note("armed support in position without visual contact; investigating reported flank via " + Approach.ToString());
}
else if (now >= Deadline)
{
- // Do not advance without a surviving, armed support crew in position.
- Note("cover unavailable; holding native combat and returning to vehicle");
+ Note("infantry approach could not be assigned; regrouping original survivors");
State = 6; Deadline = now + 90000; Board(true);
}
}
- if (State == 4 && !Attack.CoverAlive())
+ if (State == 4 && !m_UnsupportedSearch && !Attack.CoverAlive())
{ Clear(); State = 3; Deadline = now + 45000; Note("covering crew or mounted ammunition unavailable; manoeuvre paused"); }
if (State == 4 && vector.DistanceXZ(Position(), Approach) <= 30)
{
@@ -391,7 +398,14 @@
bool Covered(vector position)
{
foreach (ERR_HunterAttack attack : m_Attacks)
- if (vector.DistanceXZ(position, attack.ReportPosition) < 300) return true;
+ {
+ if (vector.DistanceXZ(position, attack.ReportPosition) >= 300) continue;
+ int active;
+ foreach (ERR_HunterTactics team : attack.Teams) if (!team.Stopped && team.Living()) active++;
+ // A failed/finished partner must not reserve the entire encounter and
+ // prevent an available hunter receiving a fresh HQ interception order.
+ if (active >= 2) return true;
+ }
return false;
}
void Tick(ERR_HQ hq, array<ref ERR_HunterVehicle> hunters, float now)
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
@@ -466,6 +466,20 @@
contacts.Clear();
foreach (ERR_HQReport contact : m_Contacts)
if (ERR_MortarPrediction.Fresh(contact, now)) contacts.Insert(contact.Snapshot());
+ }
+ // A ground search takes longer than a mortar fire solution remains valid.
+ // Read delivered memory, including its decaying confidence/uncertainty, never
+ // the target entity's current position. A five-minute old sector is the limit.
+ void HunterContacts(out array<ref ERR_HQReport> contacts, float now)
+ {
+ contacts.Clear();
+ for (int index = 0; index < m_Memory.Count(); index++)
+ {
+ ERR_HQBelief belief = m_Memory.At(index);
+ if (!belief || !belief.Report || !belief.Actionable() || belief.TravelMode == "aircraft" || belief.Radius > 500) continue;
+ if (belief.ObservedAt < 0 || now < belief.ObservedAt || now - belief.ObservedAt > 300000) continue;
+ contacts.Insert(belief.Report.Snapshot());
+ }
}
ERR_HQWatch Watch() { return m_Watch; }