Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
163 lines
12 KiB
Python
163 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Execute production bounded radio graph and hop-delivery state machine."""
|
|
from pathlib import Path
|
|
import os, re, subprocess, tempfile
|
|
ROOT=Path(__file__).resolve().parents[1]
|
|
source=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c')).read_text()
|
|
def extract(signature):
|
|
a=source.index(signature);b=source.index('{',a);end=b+1;depth=1
|
|
while depth:depth+=(source[end]=='{')-(source[end]=='}');end+=1
|
|
return source[a:end]
|
|
def trans(s):
|
|
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('null','nullptr')
|
|
s=s.replace('string.Format','Format').replace('string ', 'std::string ')
|
|
s=s.replace('Math.Round(distance).ToString()', 'std::to_string(int(distance))')
|
|
s=re.sub(r'\b(\w+)\.ToString\(\)',r'std::to_string(\1)',s)
|
|
for name in ['Math','vector','ERR_HQLocalHandoff']:s=s.replace(name+'.',name+'::')
|
|
for typ in ['IEntity','SCR_AIGroup','ERR_HQRoute','ERR_HQRadioNode','ERR_HQJournal']:
|
|
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
|
|
s=s.replace('array<int> queue = {}','Arr<int> queue').replace('array<int> cell','Arr<int>* cell').replace('array<int> neighbors','Arr<int>* neighbors')
|
|
s=s.replace('cell = {};','cell = new Arr<int>();')
|
|
s=re.sub(r'foreach\s*\(int (\w+) : neighbors\)',r'for (auto \1 : *neighbors)',s)
|
|
for name in ['carrier','caller','node','route','journal','cell']:
|
|
s=s.replace(name+'.',name+'->')
|
|
s=re.sub(r'(Nodes|m_Nodes)\[([^\]]+)\]\.',r'\1[\2]->',s)
|
|
s=s.replace('Carrier.GetOrigin()', 'Carrier->GetOrigin()')
|
|
s=re.sub(r'\b(int|float|bool) (\w+);',r'\1 \2{};',s)
|
|
return s
|
|
headers=r'''
|
|
#include <cassert>
|
|
#include <cmath>
|
|
#include <algorithm>
|
|
#include <vector>
|
|
#include <map>
|
|
#include <string>
|
|
#include <iostream>
|
|
struct vector{float v[3]{};float operator[](int i)const{return v[i];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
|
|
vector V(float x,float z=0){return {{x,0,z}};}
|
|
namespace Math{int Floor(float x){return std::floor(x);}}
|
|
std::string Format(const char*,int x,int z){return std::to_string(x)+":"+std::to_string(z);}
|
|
template<class T>struct Arr:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T x){this->push_back(x);}void InsertAt(T x,int i){this->insert(this->begin()+i,x);}};
|
|
template<class K,class V>struct Map:std::map<K,V>{void Clear(){this->clear();}bool Contains(K k){return this->count(k);}bool Find(K k,V& v){if(!Contains(k))return false;v=this->at(k);return true;}V Get(K k){if(!Contains(k))return {};return this->at(k);}void Insert(K k,V v){(*this)[k]=v;}};
|
|
struct SCR_AIGroup{bool enemy=true;};struct IEntity{vector pos;bool alive=true;int radio=1;bool captured=false;vector GetOrigin(){return pos;}};
|
|
struct ERR_HQRadioNode{IEntity* Carrier{};SCR_AIGroup* Group{};int UnitId{},Capability{};bool Valid(bool manpack=false){return Carrier&&Carrier->alive&&!Carrier->captured&&Carrier->radio>=(manpack?2:1);}std::string Label(){return "unit "+std::to_string(UnitId);}};
|
|
struct ERR_HQJournal{Arr<std::string> events;void Write(std::string kind,std::string text,int){events.Insert(kind+" "+text);}};
|
|
'''
|
|
headers+='struct ERR_HQStatusSampler{};struct ERR_HQLocalHandoff{static bool Valid(SCR_AIGroup*,ERR_HQStatusSampler*,IEntity*,ERR_HQRadioNode*){return true;}};\n'
|
|
routefields='SCR_AIGroup* LocalSource=nullptr;IEntity* LocalWitness=nullptr;ERR_HQStatusSampler* LocalRoster=nullptr;Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{},Originated{};int Cursor{},MessageId{};float Due{},StartedAt=-1,OriginDue=-1;vector SentFrom;std::string Payload;'
|
|
networkfields='int m_LocalChecks=0;Arr<ERR_HQRadioNode*> m_Nodes;Arr<int> m_Parents,m_Depths;Map<IEntity*,int> m_Indices;Map<std::string,Arr<int>*> m_Cells;bool Overflow{};'
|
|
route='struct ERR_HQRoute{'+routefields+'\n'+'\n'.join(trans(extract(x)) for x in ['int Advance(', 'protected string SenderLabel(', 'protected int Fail('])+'};\n'
|
|
network='struct Network{'+networkfields+'\n'+'\n'.join(trans(extract(x)) for x in ['void Clear(', 'protected string Cell(', 'void Add(', 'void Build(', 'ERR_HQRoute Route('])+'};\n'
|
|
tests=r'''
|
|
int main(){
|
|
Network network;SCR_AIGroup group;IEntity c[5];for(int i=0;i<5;i++)c[i].pos=V(i*500);
|
|
c[0].radio=2;for(int i=0;i<5;i++)network.Add(&c[i],&group,37+i,c[i].radio);network.Build();
|
|
assert(network.Route(&c[0])->Nodes.Count()==1);assert(network.Route(&c[3])->Nodes.Count()==4);assert(!network.Route(&c[4]));
|
|
ERR_HQJournal journal;auto up=network.Route(&c[2]);assert(up->Nodes.Count()==3);
|
|
assert(up->Advance(0,&journal)==0);assert(!up->Sending&&!up->Originated&&up->OriginDue==3000);
|
|
assert(journal.events.Count()==1&&journal.events.back().find("CALL ")==0);
|
|
assert(up->Advance(2999,&journal)==0);assert(journal.events.Count()==1);
|
|
assert(up->Advance(3000,&journal)==0);assert(up->Sending&&up->Originated&&up->Due==5000);
|
|
assert(up->Advance(5000,&journal)==0);
|
|
c[2].alive=false;assert(up->Advance(7000,&journal)==0);c[0].alive=false;assert(up->Advance(9000,&journal)==-1);
|
|
assert(journal.events.back().find("LOST")!=std::string::npos); // final transmission needs its live sender
|
|
for(auto& carrier:c)carrier.alive=true;
|
|
auto good=network.Route(&c[2]);assert(good->Advance(0,&journal)==0);assert(good->Advance(3000,&journal)==0);assert(good->Advance(5000,&journal)==0);
|
|
c[2].alive=false;assert(good->Advance(7000,&journal)==0);assert(good->Advance(9000,&journal)==1);
|
|
assert(journal.events.back().find("HQ received")!=std::string::npos); // a completed earlier hop survives its sender
|
|
auto severed=network.Route(&c[0]);assert(severed->Advance(0,&journal)==0);assert(severed->Advance(3000,&journal)==0);c[0].radio=0;assert(severed->Advance(5000,&journal)==-1);c[0].radio=2;
|
|
for(auto& carrier:c)carrier.alive=true;
|
|
auto lost=network.Route(&c[2]);assert(lost->Advance(0,&journal)==0);assert(lost->Advance(3000,&journal)==0);c[1].radio=0;assert(lost->Advance(5000,&journal)==-1);assert(lost->Advance(7000,&journal)==-1);
|
|
c[1].radio=1;auto moving=network.Route(&c[2]);moving->Advance(0,&journal);moving->Advance(3000,&journal);c[1].pos=V(499);assert(moving->Advance(5000,&journal)==-1);c[1].pos=V(500);
|
|
// No originating transmission may survive radio loss, death/incapacitation, or capture during the hold.
|
|
for(int reason=0;reason<3;reason++){
|
|
auto interrupted=network.Route(&c[0]);ERR_HQJournal calls;assert(interrupted->Advance(10000,&calls)==0);
|
|
if(reason==0)c[0].radio=1;
|
|
if(reason==1)c[0].alive=false;
|
|
if(reason==2)c[0].captured=true;
|
|
assert(interrupted->Advance(11500,&calls)==-1);assert(!interrupted->Sending&&!interrupted->Originated);
|
|
assert(calls.events.Count()==2&&calls.events[0].find("CALL ")==0&&calls.events[1].find("LOST ")==0);
|
|
c[0].radio=2;c[0].alive=true;c[0].captured=false;
|
|
assert(interrupted->Advance(15000,&calls)==-1);assert(calls.events.Count()==2);
|
|
}
|
|
auto direct=network.Route(&c[0]);ERR_HQJournal directLog;
|
|
assert(direct->Advance(0,&directLog)==0);assert(direct->Advance(2999,&directLog)==0);
|
|
assert(direct->Advance(3000,&directLog)==0);assert(direct->Advance(4999,&directLog)==0);assert(direct->Advance(5000,&directLog)==1);
|
|
assert(directLog.events.Count()==4); // CALL, CALL COMPLETE, TRANSMIT, RECEIVE exactly once.
|
|
ERR_HQRoute empty;assert(empty.Advance(0,&journal)==-1);assert(!empty.Originated&&!empty.Sending);
|
|
int beforeDown=journal.events.Count();auto down=network.Route(&c[2],true);assert(down->Nodes[0]->Carrier==&c[0]);assert(down->Advance(0,&journal)==0);
|
|
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
|
|
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
|
|
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
|
|
// The sender must stay valid on EVERY observed tick of the two-second hop,
|
|
// not only at completion. Recovery before Due cannot revive an interrupted hop.
|
|
for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){
|
|
auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log;
|
|
assert(interrupted->Advance(0,&log)==0);
|
|
float sendingAt=3000;
|
|
if(downlink)sendingAt=2000;
|
|
assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
|
|
if(reason==0)c[0].alive=false;
|
|
if(reason==1)c[0].captured=true;
|
|
if(reason==2)c[0].radio=0;
|
|
assert(interrupted->Advance(sendingAt+1000,&log)==-1);
|
|
assert(interrupted->Failed&&log.events.back().find("LOST ")==0);
|
|
int terminalEvents=log.events.Count();
|
|
c[0].alive=true;c[0].captured=false;c[0].radio=2;
|
|
assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
|
|
}
|
|
// Receiver interruption and leaving/re-entering the link also cancel the
|
|
// same physical short-range hop in either direction, before its due time.
|
|
for(bool downlink:{false,true})for(int reason=0;reason<4;reason++){
|
|
auto interrupted=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
|
|
assert(interrupted->Advance(0,&log)==0);
|
|
float sendingAt=downlink?2000:3000;
|
|
assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
|
|
assert(interrupted->Nodes[interrupted->Cursor+1]->Carrier==&c[1]);
|
|
if(reason==0)c[1].alive=false;
|
|
if(reason==1)c[1].captured=true;
|
|
if(reason==2)c[1].radio=0;
|
|
if(reason==3)c[1].pos=V(3000);
|
|
assert(interrupted->Advance(sendingAt+1000,&log)==-1);
|
|
int terminalEvents=log.events.Count();
|
|
c[1].alive=true;c[1].captured=false;c[1].radio=1;c[1].pos=V(500);
|
|
assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
|
|
}
|
|
// HQ's first downlink leg has no physical sender; its original receiving
|
|
// manpack must still survive while waiting, rather than use a late recovery.
|
|
auto firstDown=network.Route(&c[1],true);ERR_HQJournal firstDownLog;
|
|
assert(firstDown->Advance(0,&firstDownLog)==0&&firstDown->Cursor==-1);
|
|
c[0].radio=1;assert(firstDown->Advance(1000,&firstDownLog)==-1);
|
|
c[0].radio=2;assert(firstDown->Advance(2000,&firstDownLog)==-1);
|
|
// Healthy receiver-bearing hops keep the cursor and RECEIVE log unchanged
|
|
// during intermediate polling, with both 500m boundary links still valid.
|
|
for(bool downlink:{false,true}){
|
|
auto healthy=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
|
|
assert(healthy->Advance(0,&log)==0);
|
|
float sendingAt=downlink?2000:3000;
|
|
assert(healthy->Advance(sendingAt,&log)==0&&healthy->Sending);
|
|
int cursor=healthy->Cursor,events=log.events.Count();
|
|
assert(healthy->Advance(sendingAt+1000,&log)==0&&healthy->Cursor==cursor&&log.events.Count()==events);
|
|
assert(healthy->Advance(sendingAt+2000,&log)==(downlink?1:0)&&healthy->Cursor==cursor+1);
|
|
}
|
|
// Healthy intermediate polling does not transmit early or duplicate log entries.
|
|
auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog;
|
|
assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0);
|
|
int transmittingEvents=waitingLog.events.Count();
|
|
assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending);
|
|
assert(waitingLog.events.Count()==transmittingEvents);
|
|
assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1);
|
|
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
|
|
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
|
|
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
|
|
network.Clear();assert(!network.Route(&c[0]));
|
|
Network crowded;std::vector<IEntity> many(2050);for(auto& carrier:many){carrier.radio=2;crowded.Add(&carrier,&group,1,2);}assert(crowded.Overflow&&crowded.m_Nodes.Count()==2048);
|
|
crowded.Build();assert(crowded.Overflow); // bounded dense graph work
|
|
std::cout<<"PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds\n";
|
|
}
|
|
'''
|
|
with tempfile.TemporaryDirectory(prefix='eai-hq-network-') as tmp:
|
|
cpp=Path(tmp)/'test.cpp';exe=Path(tmp)/'test';cpp.write_text(headers+route+network+tests)
|
|
subprocess.run(['g++','-std=c++17','-Wall','-Wextra',str(cpp),'-o',str(exe)],check=True)
|
|
subprocess.run([str(exe)],check=True)
|