Files
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

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)&&center==730&&!load.NeedsService(299999));
center=-1;assert(!load.Cover(300000,center)&&center==-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=&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')