--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirSupportPolicy.c @@ -28,9 +28,6 @@ if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample"; if (s.BeliefId <= 0 || s.Samples < 2 || s.Age < 0 || s.Age > 90 || s.Confidence < 0.7 || s.Confidence > 1 || s.Radius < 0 || s.Radius > 160) return "memory is too uncertain; continue reconnaissance"; if (!s.Infantry || !s.MotionKnown || s.Speed < 0 || s.Speed > 6) return "target movement does not justify an infantry drop"; - int needed = Math.Clamp(s.Spotted * 2, 4, 12); - if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength"; - if (s.GroundFit + s.CommittedFit >= needed && s.ConfirmedLosses < 2) return "nearby ground forces can handle this contact"; if (checkAirspace && s.ArtilleryBusy) return "mortar order or shells occupy the approach area"; return ""; } @@ -38,7 +35,7 @@ { if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false; // Artillery temporarily closes the approach; it does not invalidate fresh - // contact history or the confirmed lack of ground support. Restarting the + // contact history. Restarting the // timer on every salvo could prevent a drop through every clear interval. string refusal = Refusal(s, false); if (!refusal.IsEmpty()) { Candidate = 0; @@ -55,15 +52,18 @@ if (s.ArtilleryBusy) { Reason = Refusal(s); return false; } - float delay = 45000; - if (s.ConfirmedLosses >= 2) delay = 20000; - if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall"; + // Ground hunters complement airborne troops rather than vetoing all drops. + int needed = Math.Clamp(s.Spotted * 2, 4, 12); + float delay = 20000; + if (s.GroundFit + s.CommittedFit >= needed) delay = 45000; + if (s.ConfirmedLosses >= 2) delay = 10000; + if (now - ConsideringSince < delay) { Reason = "confirming contact before airborne reinforcement"; return false; } - Reason = "persistent infantry contact exceeds available ground response"; + Reason = "persistent infantry contact; reinforcing the ground hunt"; return true; } - void Committed(float now) { NextDrop = now + 480000; + void Committed(float now) { NextDrop = now + 240000; Candidate = 0; ConsideringSince = -1; } --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c @@ -33,6 +33,12 @@ class ERR_AirDropGeometry { static string LastFailure; + // Once a fresh radio order is accepted, give the physical approach a bounded + // travel budget. A large island may require several minutes of flying. + static float ApproachBudget(float distance, float descent) + { + return Math.Clamp(Math.Max(distance / 10, Math.Max(0, descent) / 2) + 90, 120, 600) * 1000; + } static float Along(vector position, vector center, vector direction) { vector offset = position - center; offset[1] = 0; @@ -77,7 +83,8 @@ protected ref array m_Passengers = {}; protected ref array m_Teams = {}; protected ref ERR_AirDropOrder m_Order; - protected float m_CoverUntil; + protected float m_CoverUntil, m_ApproachUntil; + protected bool m_HasDropped; bool Cover(float now, out vector center) { if (!m_Order || !m_Used || m_Approaching || now >= m_CoverUntil) return false; center = m_Order.Target; return true; @@ -95,6 +102,7 @@ void ReuseRemaining() { m_Used = false; + m_HasDropped = false; m_Order = null; m_CoverUntil = 0; foreach (ERR_AirPassenger member : m_Passengers) @@ -282,7 +290,11 @@ if (!found) return false; m_RunStart = m_Center - m_Direction * 350; m_RunEnd = m_Center + m_Direction * 450; - m_CoverUntil = now + 300000; + m_CoverUntil = 0; + m_HasDropped = false; + vector aircraftPosition = m_Aircraft.GetOrigin(); + m_ApproachUntil = now + ERR_AirDropGeometry.ApproachBudget(vector.DistanceXZ(aircraftPosition, m_RunStart), aircraftPosition[1] - m_Center[1] - 120); + ERR_HQ.Event("AIR DROP APPROACH", string.Format("distance=%1m budget=%2s; original passengers retained", vector.DistanceXZ(aircraftPosition, m_RunStart), (m_ApproachUntil - now) / 1000)); m_Order = order; m_Used = true; m_Approaching = true; @@ -290,7 +302,7 @@ m_NextPassenger = 0; m_NextJump = now; foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order; - m_AirspaceUntil = order.ExpiresAt + 150000; + m_AirspaceUntil = m_ApproachUntil + 150000; return true; } bool Approach(out vector point) @@ -300,9 +312,15 @@ return m_Approaching && !m_Stopped; } void Abort() - { if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000; + { m_Approaching = false; m_RunCommitted = false; + // An approach with no jump is not a completed drop. The same original + // passengers can accept a later order; no fictitious landed-team cover. + if (!m_HasDropped) + { + m_Used = false; m_Order = null; m_CoverUntil = 0; m_AirspaceUntil = 0; + } } bool BlocksMortar(vector impact, float now) { @@ -319,9 +337,9 @@ return; } if (!m_Approaching) return; - if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_Order.ExpiresAt) + if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_ApproachUntil) { Abort(); - ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created"); + ERR_HQ.Event("AIR DROP ABORTED", "approach deadline reached or aircraft unavailable; no new passengers created"); return; } foreach (ERR_AirAssaultTeam originalTeam : m_Teams) @@ -331,6 +349,7 @@ vector position = m_Aircraft.GetOrigin(); if (!m_RunCommitted && vector.DistanceXZ(position, m_RunStart) <= 80) m_RunCommitted = true; if (m_RunCommitted && ERR_AirDropGeometry.Along(position, m_Center, m_Direction) > 300) { Abort(); + ERR_HQ.Event("AIR DROP ABORTED", "drop run passed the safe corridor; unjumped original passengers retained"); return; } float height = position[1] - SCR_TerrainHelper.GetTerrainY(position); @@ -351,6 +370,7 @@ passenger.Chute = chute; vector landing = m_Center + m_Direction * ERR_AirDropGeometry.Along(position, m_Center, m_Direction); passenger.Jumped = chute.BeginJump(passenger.Actor, m_Teams[index % 2], landing); + if (passenger.Jumped) { m_HasDropped = true; m_CoverUntil = now + 300000; } ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped)); break; } --- a/tests/test_air_support_policy.py +++ b/tests/test_air_support_policy.py @@ -17,12 +17,16 @@ '''+source+r''' int main(){ ERR_AirSupportSituation s{};s.AircraftReady=true;s.BeliefId=1;s.Samples=3;s.Spotted=3;s.Age=5;s.Confidence=.9;s.Radius=20;s.MotionKnown=true;s.Infantry=true;s.Speed=1; - ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,44999));assert(p.Consider(&s,45000)); - p.Committed(45000);assert(!p.Consider(&s,50000));assert(!p.Consider(&s,525000));assert(p.Consider(&s,570000)); - s.CommittedFit=6;assert(!p.Consider(&s,600000));s.CommittedFit=0;s.GroundFit=6;assert(!p.Consider(&s,600000)); - s.ConfirmedLosses=2;assert(!p.Consider(&s,600000));assert(p.Consider(&s,620000)); - s.ArtilleryBusy=true;assert(!p.Consider(&s,630000));s.ArtilleryBusy=false;assert(p.Consider(&s,640000));assert(p.Consider(&s,660000)); - s.BeliefId=2;assert(!p.Consider(&s,670000));assert(!p.Consider(&s,689999));assert(p.Consider(&s,690000)); + ERR_AirSupportPolicy p;assert(!p.Consider(&s,0));assert(!p.Consider(&s,19999));assert(p.Consider(&s,20000)); + p.Committed(20000);assert(!p.Consider(&s,259999));assert(!p.Consider(&s,260000));assert(p.Consider(&s,280000)); + // Existing hunters delay reinforcement, but no longer suppress it forever. + ERR_AirSupportPolicy ground;s.CommittedFit=6; + assert(!ground.Consider(&s,0));assert(!ground.Consider(&s,44999));assert(ground.Consider(&s,45000)); + s.CommittedFit=0;s.GroundFit=6;assert(ground.Consider(&s,45000)); + ERR_AirSupportPolicy losses;s.ConfirmedLosses=2; + assert(!losses.Consider(&s,0));assert(!losses.Consider(&s,9999));assert(losses.Consider(&s,10000)); + s.ArtilleryBusy=true;assert(!losses.Consider(&s,20000));s.ArtilleryBusy=false;assert(losses.Consider(&s,20001)); + s.BeliefId=2;assert(!losses.Consider(&s,30000));assert(!losses.Consider(&s,39999));assert(losses.Consider(&s,40000)); auto good=s; s.Age=91;assert(!p.Consider(&s,700000));s=good;s.Confidence=.69;assert(!p.Consider(&s,700000)); s=good;s.Radius=161;assert(!p.Consider(&s,700000));s=good;s.Samples=1;assert(!p.Consider(&s,700000)); @@ -30,25 +34,22 @@ s=good;s.MotionKnown=false;assert(!p.Consider(&s,700000));s=good;s.AircraftReady=false;assert(!p.Consider(&s,700000)); s=good;s.Age=std::numeric_limits::quiet_NaN();assert(!p.Consider(&s,700000)); assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits::infinity())); - // Artillery blocks authorization, not the history of fresh sightings and - // ground-force shortage. Repeated <45-second clear windows used to starve - // airborne support by restarting its confirmation timer on every salvo. + // Busy mortar airspace still vetoes drops without discarding contact history. ERR_AirSupportPolicy blocked;auto contact=good;contact.BeliefId=9;contact.GroundFit=0;contact.ConfirmedLosses=0; - assert(!blocked.Consider(&contact,0)); - contact.ArtilleryBusy=true; - assert(!blocked.Consider(&contact,10000)); - assert(!blocked.Consider(&contact,46000)); - contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,47000)); - // Stale intelligence and a changed target still invalidate confirmation. - contact.Age=91;assert(!blocked.Consider(&contact,48000)); - contact.Age=5;assert(!blocked.Consider(&contact,49000)); - contact.BeliefId=10;assert(!blocked.Consider(&contact,90000)); - assert(!blocked.Consider(&contact,134999));assert(blocked.Consider(&contact,135000)); - // Busy airspace must not preconfirm a new drop during the 8-minute cooldown. - blocked.Committed(135000);contact.ArtilleryBusy=true; - assert(!blocked.Consider(&contact,614999)); - contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,615000)); - assert(!blocked.Consider(&contact,659999));assert(blocked.Consider(&contact,660000)); + assert(!blocked.Consider(&contact,0));contact.ArtilleryBusy=true; + assert(!blocked.Consider(&contact,10000));assert(!blocked.Consider(&contact,21000)); + contact.ArtilleryBusy=false;assert(blocked.Consider(&contact,22000)); + contact.Age=91;assert(!blocked.Consider(&contact,23000)); + contact.Age=5;assert(!blocked.Consider(&contact,24000)); + contact.BeliefId=10;assert(!blocked.Consider(&contact,45000)); + assert(!blocked.Consider(&contact,64999));assert(blocked.Consider(&contact,65000)); + // Cooldown cannot preconfirm the next drop, and failed orders back off. + blocked.Committed(65000);contact.ArtilleryBusy=true; + assert(!blocked.Consider(&contact,304999)); + contact.ArtilleryBusy=false;assert(!blocked.Consider(&contact,305000)); + assert(!blocked.Consider(&contact,324999));assert(blocked.Consider(&contact,325000)); + blocked.Retry(325000);assert(!blocked.Consider(&contact,339999)); + assert(!blocked.Consider(&contact,340000));assert(blocked.Consider(&contact,360000)); } ''') @@ -58,12 +59,17 @@ load=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AirAssaultLoad.c' cover=body(load,'bool Cover(').replace('out vector center','int& center').replace('m_Order.Target','m_Order->Target') service=body(load,'bool NeedsService(') +abort=body(load,'void Abort(').replace('null','nullptr') +budget=body(load,'static float ApproachBudget(').replace('Math.', 'Math::') run(r''' #include +#include +struct Math {static float Clamp(float x,float a,float b){return std::clamp(x,a,b);}static float Max(float a,float b){return std::max(a,b);}}; +struct Geometry {'''+budget+r'''}; struct Order {int Target=730;}; struct Load { - Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false;float m_CoverUntil=300000; -'''+cover+service+r''' + Order* m_Order=nullptr;bool m_Used=false,m_Approaching=false,m_RunCommitted=false,m_HasDropped=false;float m_CoverUntil=300000,m_AirspaceUntil=450000; +'''+cover+service+abort+r''' }; int main(){ Load load;Order order;int center=-1; @@ -72,6 +78,24 @@ assert(!load.Cover(1,center)&&!load.NeedsService(300001)); load.m_Approaching=false;assert(load.Cover(299999,center)&¢er==730&&!load.NeedsService(299999)); center=-1;assert(!load.Cover(300000,center)&¢er==-1&&load.NeedsService(300000)); + // Previously an expired approach with nobody dropped falsely entered cover + // and made the original passenger load unavailable until an airport visit. + load.m_Approaching=true;load.m_RunCommitted=true;load.Abort(); + assert(!load.m_Order&&!load.m_Used&&!load.m_Approaching&&!load.m_RunCommitted); + assert(!load.Cover(1,center)&&!load.NeedsService(1)&&load.m_AirspaceUntil==0); + load.Abort();assert(!load.m_Used); // idempotent, no new soldiers + // A partial/complete real drop must retain cover and descending airspace. + load.m_Order=ℴload.m_HasDropped=true;load.m_Used=true;load.m_Approaching=true; + load.m_CoverUntil=350000;load.m_AirspaceUntil=450000;load.Abort(); + assert(load.Cover(349999,center)&&load.m_Used&&load.m_Order==&order&&load.m_AirspaceUntil==450000); + assert(load.NeedsService(350000)); + // Separate radio freshness (unchanged) from physical travel time. Observed + // approaches of ~1.1 km and ~5 km cannot complete within the old 120 seconds. + assert(Geometry::ApproachBudget(0,0)==120000); + assert(Geometry::ApproachBudget(1100,0)==200000); + assert(Geometry::ApproachBudget(5000,0)==590000); + assert(Geometry::ApproachBudget(0,600)==390000); + assert(Geometry::ApproachBudget(20000,2000)==600000); } ''') -print('PASS: production cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished') +print('PASS: production bounded approach deadline, empty-drop reuse, partial-drop protection; cover retains the carried HQ target until its deadline; airport reload waits until the drop pass and cover have finished')