Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
258 lines
14 KiB
Diff
258 lines
14 KiB
Diff
--- 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<ref ERR_AirPassenger> m_Passengers = {};
|
|
protected ref array<ref ERR_AirAssaultTeam> 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<float>::quiet_NaN();assert(!p.Consider(&s,700000));
|
|
assert(!p.Consider(nullptr,700000));assert(!p.Consider(&good,std::numeric_limits<float>::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 <cassert>
|
|
+#include <algorithm>
|
|
+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')
|