Files
EscapistsReintroduced/tests/test_mortar_spotting.py
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

173 lines
12 KiB
Python

"""Execute production spotting math/delivery/adoption with native API doubles.
This is not evidence of native explosion callbacks or live impact accuracy.
"""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_MortarSpotting.c'
r=p.parent
def cpp(path, signature):
s=body(path,signature).replace('protected ','')
# Logging/formatting are inert at the native boundary, not delivery logic.
while 'string.Format(' in s:
start=s.index('string.Format('); end=start+len('string.Format('); level=1
while level:
level+=(s[end]=='(')-(s[end]==')');end+=1
s=s[:start]+'"log"'+s[end:]
for typ in ['ERR_MortarSpotReport','ERR_MortarBattery','ERR_HQReport']:
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
s=s.replace('ERR_MortarSpotReport* best;','ERR_MortarSpotReport* best=nullptr;')
s=s.replace('bool searched;', 'bool searched=false;')
for cls in ['ERR_ForceRules','ERR_MortarSpotRules','ERR_MortarRefreshRules','ERR_MortarPrediction','ERR_MortarSalvo','vector','Math']:
s=s.replace(cls+'.',cls+'::')
for field in ['spot','battery','report','best','m_HQ','update','shot','Observation','Gun','AimSolution','Refresh','Report','Battery','Downlink']:
s=s.replace(field+'.',field+'->')
s=s.replace('foreach (ERR_MortarSpotReport* spot : m_Reports)', 'for (ERR_MortarSpotReport* spot : m_Reports.values)')
s=s.replace('null','nullptr').replace('nullptrptr','nullptr')
return s
rules='\n'.join(cpp(p,s) for s in ['static bool Fresh(', 'static vector Bias(', 'static vector CorrectedOffset('])
newer=cpp(r/'ERR_MortarRefresh.c','static bool Newer(').replace('current.', 'current->').replace('incoming.','incoming->')
methods='\n'.join(cpp(p,s) for s in ['protected bool Usable(', 'protected ERR_MortarSpotReport Latest(', 'vector Offset(', 'void PrepareAim(', 'void ReceiveHQ(', 'void Tick('])
run(r'''#include <cassert>
#include <cmath>
#include <vector>
#include <limits>
struct vector {
float v[3]{};float& operator[](int i){return v[i];}
vector operator+(vector b) const{return {v[0]+b.v[0],v[1]+b.v[1],v[2]+b.v[2]};}
vector operator-(vector b) const{return {v[0]-b.v[0],v[1]-b.v[1],v[2]-b.v[2]};}
vector operator*(float b) const{return {v[0]*b,v[1]*b,v[2]*b};}
float Length() const{return std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);}
vector Normalized() const {float l=Length();return l?(*this)*(1/l):vector{};}
static float Distance(vector a,vector b){return (a-b).Length();}
};
struct Math {static float Round(float v){return std::round(v);}};
struct ERR_ForceRules {static bool Finite(float v){return std::isfinite(v);}};
struct ERR_MortarSpotRules {
'''+rules+r'''};
struct Entity {vector position;vector GetOrigin(){return position;}};
struct ERR_HQReport {Entity* Target=nullptr;int MessageId=1;float ObservedAt=0;vector Position;ERR_HQReport* Snapshot(){return this;}};
struct ERR_MortarPrediction {static bool Fresh(ERR_HQReport* r,float t){return t>=r->ObservedAt&&t-r->ObservedAt<=60000;}};
struct ERR_MortarRefreshRules {
'''+newer+r'''};
struct Aim {vector SalvoOffset;};
struct RefreshState {ERR_HQReport* Report=nullptr;};
struct ERR_MortarBattery {
Entity* Gun=nullptr;void* Group=this;ERR_HQReport* Report=nullptr;RefreshState* Refresh=nullptr;Aim* AimSolution=nullptr;
bool operational=true,CommandOpen=true,Released=false,RefreshRejected=false,Rejected=false;
int SiteId=1,OrderId=1,OrderShots=1;bool Operational(){return operational;}
void Audit(const char*,const char*){}
};
struct ERR_HQRoute {int result=0;int Advance(float,void*){return result;}};
struct ERR_MortarSpotReport {
ERR_MortarBattery* Battery=nullptr;Entity *Gun=nullptr,*Target=nullptr;
ERR_HQReport* Observation=nullptr;ERR_HQRoute* Downlink=nullptr;
vector GunOrigin,Bias;bool Delivered=false;float FiredAt=0,ImpactAt=-1;int GroupsChecked=0;
};
template<class T> struct Items {
std::vector<T> values;int Count(){return values.size();}void Insert(T t){values.push_back(t);}
void Remove(int i){values.erase(values.begin()+i);}T operator[](int i){return values[i];}
};
struct HQ {ERR_HQRoute* route=nullptr;int surveys=0;
ERR_HQRoute* Downlink(void*,const char*,const char*,int){return route;}
void* Journal(){return nullptr;}bool ReportMortarSplash(ERR_MortarSpotReport*,float){surveys++;return false;}
};
struct ERR_MortarSalvo {static vector Offset(int,int,int){return {80,0,0};}};
struct Spotting {bool m_Stopped=false;HQ* m_HQ=nullptr;Items<ERR_MortarSpotReport*> m_Reports,m_Flying;
'''+methods+r'''};
struct Fixture {
Entity gun,target;ERR_HQReport original,observed;ERR_MortarBattery battery;ERR_MortarSpotReport spot;HQ hq;ERR_HQRoute route;Spotting service;
Fixture(){original.Target=&target;observed=original;observed.MessageId=2;observed.ObservedAt=2000;
battery.Gun=&gun;battery.Report=&original;spot.Battery=&battery;spot.Gun=&gun;spot.Target=&target;spot.Observation=&observed;
spot.Bias={15,0,0};spot.FiredAt=1000;service.m_HQ=&hq;hq.route=&route;}
void receive(){service.ReceiveHQ(&spot,3000);}
void deliver(){route.result=1;service.Tick(4000);}
vector offset(){return service.Offset(&battery,battery.Report,4000);}
};
int main(){
assert(ERR_MortarSpotRules::Fresh(0,45000)&&!ERR_MortarSpotRules::Fresh(0,45001));
assert(!ERR_MortarSpotRules::Fresh(1,0)&&!ERR_MortarSpotRules::Fresh(-1,0));
assert(!ERR_MortarSpotRules::Fresh(0,std::numeric_limits<float>::quiet_NaN()));
vector aim{1000,40,1000},landing{985,10,1000};
vector correction=ERR_MortarSpotRules::Bias(aim,landing);
assert(correction[0]==15&&correction[1]==0&&correction[2]==0);
// After applying +15m, another identical -15m ballistic error stays +15m.
assert(ERR_MortarSpotRules::Bias(aim+correction,landing+correction)[0]==15);
assert(ERR_MortarSpotRules::Bias(aim,aim).Length()==0); // deliberate spread is not ballistic error
assert(std::fabs(ERR_MortarSpotRules::Bias({1000,0,1000},{}).Length()-60)<.001);
Fixture f;assert(f.offset()[0]==80);f.receive();assert(f.offset()[0]==80);
f.service.Tick(3500);assert(!f.spot.Delivered&&f.offset()[0]==80); // radio still in flight
f.deliver();assert(f.spot.Delivered&&f.offset()[0]==23); // 8m spread plus 15m calibration
f.battery.Released=true;f.service.PrepareAim(&f.battery,4000);assert(f.battery.Report==&f.original);
f.battery.Released=false;Aim commanded;commanded.SalvoOffset={42,0,1};f.battery.AimSolution=&commanded;
assert(f.offset()[0]==42);f.service.PrepareAim(&f.battery,4000);assert(f.battery.Report==&f.original);
f.battery.AimSolution=nullptr;f.service.PrepareAim(&f.battery,4000);assert(f.battery.Report==&f.observed&&f.observed.ObservedAt==2000);
ERR_MortarSpotReport older=f.spot;older.FiredAt=500;older.Bias={-40,0,0};f.service.m_Reports.Insert(&older);
assert(f.offset()[0]==23); // older airborne round arrives after newer correction
f.gun.position={3,0,0};assert(f.offset()[0]==80);f.gun.position={};
Entity another;f.battery.Report->Target=&another;assert(f.offset()[0]==80);f.battery.Report->Target=&f.target;
assert(f.service.Offset(&f.battery,f.battery.Report,47001)[0]==80); // age not renewed by delivery
Fixture lost;lost.receive();lost.route.result=-1;lost.service.Tick(4000);assert(lost.service.m_Reports.Count()==0&&!lost.spot.Delivered&&lost.offset()[0]==80);
Fixture disconnected;disconnected.hq.route=nullptr;disconnected.receive();assert(disconnected.service.m_Reports.Count()==0);
Fixture dead;dead.receive();dead.battery.operational=false;dead.deliver();assert(!dead.spot.Delivered&&dead.service.m_Reports.Count()==0);
Fixture stale;stale.service.ReceiveHQ(&stale.spot,47001);assert(stale.service.m_Reports.Count()==0);
Fixture bounded;for(int i=0;i<33;i++)bounded.receive();assert(bounded.service.m_Reports.Count()==32);
Fixture scan;ERR_MortarSpotReport second=scan.spot;scan.spot.ImpactAt=second.ImpactAt=3000;
scan.service.m_Flying.Insert(&scan.spot);scan.service.m_Flying.Insert(&second);scan.service.Tick(4000);assert(scan.hq.surveys==1);
scan.service.Tick(9000);assert(scan.service.m_Flying.Count()==0); // splash not observable indefinitely
}
''')
# Wiring and native-boundary guards, distinct from the executed state tests.
source=p.read_text()
firing=(r/'ERR_MortarFiring.c').read_text()
assert firing.index('system.TrackProjectile(')<firing.index('battery.ObserveProjectile(')
assert 'spot.ShellEntity != source' in body(p,'protected void Explosion(')
assert 'spot.Splash = matrix[0]' in body(p,'protected void Explosion(')
assert 'ERR_HQObservation.Capture(target, now)' in source and 'TraceMove(sight, null)' in source
assert 'GetTargetsList(targets, ETargetCategory.ENEMY)' in source
assert 'Target.GetOrigin()' not in source
assert 'SCR_ExplosionAmmoEffect.GetOnExplosionAmmoEffect().Remove(Explosion)' in source
assert 'system.ShotOffset(battery, report, now)' in firing
assert 'AcceptMortarSpot(unit.PendingMortarSpot, now)' in (r/'ERR_HQ.c').read_text()
print('PASS: observed mortar corrections, radio delivery, bounded expiry, aim preservation and out-of-order impacts; native flight untested')
# Execute the actual HQ correction intake. The existing memory regression owns
# velocity estimation; this boundary supplies its resulting fire-support sample.
intake=cpp(r/'ERR_HQ.c','protected void AcceptMortarSpot(')
intake=intake.replace('observation.', 'observation->').replace('contact.', 'contact->')
intake=intake.replace('foreach (ERR_HQReport* contact : m_Contacts)', 'for (ERR_HQReport* contact : m_Contacts)')
run(r'''#include <cassert>
#include <vector>
struct vector {float x=0,y=0,z=0;};
struct ERR_HQReport {void* Target=nullptr;float ObservedAt=0;vector Position,Velocity;bool HasVelocity=false;ERR_HQReport* Snapshot(){return new ERR_HQReport(*this);}};
struct ERR_MortarSpotReport {ERR_HQReport* Observation=nullptr;};
struct ERR_MortarSpotRules {static bool Fresh(float t,float now){return t>=0&&now>=t&&now-t<=45000;}};
struct ERR_MortarPrediction {static bool Fresh(ERR_HQReport* r,float now){return r->Target&&now>=r->ObservedAt&&now-r->ObservedAt<=60000;}};
struct Mortars {int calls=0;ERR_HQReport* delivered=nullptr;void ReceiveSpot(ERR_MortarSpotReport* spot,float){calls++;delivered=spot->Observation;}};
struct HQ {std::vector<ERR_HQReport*> m_Contacts;Mortars m_Mortars;int accepts=0;
// Represents Accept's output, not a substitute motion-estimation algorithm.
void Accept(ERR_HQReport*,float){accepts++;}
'''+intake+r'''};
int main(){
int target=1,other=2;ERR_HQReport sighting;sighting.Target=&target;sighting.ObservedAt=1000;sighting.Position={100,0,100};
ERR_HQReport enriched=sighting;enriched.HasVelocity=true;enriched.Velocity={3,0,-2};
ERR_MortarSpotReport spot;spot.Observation=&sighting;HQ hq;hq.m_Contacts.push_back(&enriched);
hq.AcceptMortarSpot(&spot,2000);assert(hq.accepts==1&&hq.m_Mortars.calls==1);
assert(spot.Observation!=&sighting&&spot.Observation!=&enriched&&spot.Observation->HasVelocity);
assert(spot.Observation->Velocity.x==3&&spot.Observation->Velocity.z==-2&&spot.Observation->ObservedAt==1000);
assert(!sighting.HasVelocity); // the captured sighting was not magically given native live speed
// A more recent delivered sighting carries its OWN position, time and velocity.
enriched.ObservedAt=1500;enriched.Position={102,0,99};enriched.Velocity={-1,0,4};spot.Observation=&sighting;
hq.AcceptMortarSpot(&spot,2000);assert(spot.Observation->ObservedAt==1500&&spot.Observation->Position.x==102&&spot.Observation->Velocity.z==4);
// Insufficient/conflicting history must not preserve an older movement estimate.
enriched.HasVelocity=false;enriched.Velocity={};spot.Observation=&sighting;
hq.AcceptMortarSpot(&spot,2000);assert(!spot.Observation->HasVelocity);
HQ rejected;rejected.m_Contacts.push_back(&enriched);enriched.Target=&other;spot.Observation=&sighting;
rejected.AcceptMortarSpot(&spot,2000);assert(rejected.m_Mortars.calls==0);
enriched.Target=&target;enriched.ObservedAt=999;rejected.AcceptMortarSpot(&spot,2000);assert(rejected.m_Mortars.calls==0);
int before=rejected.accepts;rejected.AcceptMortarSpot(&spot,46001);assert(rejected.accepts==before);
}
''')
print('PASS: splash reports retain HQ-estimated movement with matching position/time; stale, mismatched or unsupported estimates rejected')