Reconcile radio-delivered body and status casualties by original roster identity

This commit is contained in:
2026-09-20 03:09:16 +02:00
parent a2aab17171
commit 9b6596eb3e
44 changed files with 6042 additions and 86 deletions
+112 -15
View File
@@ -9,16 +9,33 @@ def cpp(s):
('ERR_HQWatchRules.','ERR_HQWatchRules::'),('null','nullptr'),
('report.SourceId.IsEmpty()','report.SourceId.empty()'),('report.SourceId.Length()','report.SourceId.size()')]:
s=s.replace(old,new)
for typ in ('ERR_HQStatusReport','ERR_HQWatchEntry'):
for typ in ('ERR_HQStatusReport','ERR_HQWatchEntry','ERR_HQReport','ERR_HQDeliveredCasualties'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
for var in ('entry','report'):s=s.replace(var+'.',var+'->')
for var in ('entry','report','validation'):s=s.replace(var+'.',var+'->')
s=s.replace('entry->Casualties.', 'entry->Casualties->')
s=s.replace('report->CasualtySourceId.IsEmpty()', 'report->CasualtySourceId.empty()')
s=s.replace('report->CasualtySourceId.Length()', 'report->CasualtySourceId.size()')
s=s.replace('report->DeathSlots,', '&report->DeathSlots,')
return s
helper=(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQDeliveredCasualties.c').read_text()
helper=helper.replace('class ','struct ').replace('protected ','').replace('ref ','').replace('Math.','Math::')
helper=helper.replace('array<int> slots','Array<int>* slots').replace('slots.', 'slots->')
helper=helper.replace('array<int>','Array<int>').replace('foreach (','for (')
helper=helper.replace(' : slots)', ' : *slots)').replace('int m_Strength;', 'int m_Strength=0;')
helper=helper.replace('\n}\n','\n};\n')
rules='\n'.join(cpp(body(P,s)) for s in ('static bool Valid(', 'static bool RequestDue(',
'static bool InvestigationDue(', 'static int SquadLimit('))
methods='\n'.join(cpp(body(P,s)) for s in ('int Count()', 'ERR_HQWatchEntry Find(', 'ERR_HQWatchEntry At(',
'int ConfirmedLosses()', 'int SquadLimit(int baseline)', 'bool Ingest(', 'bool RequestDue(string',
'int ConfirmedLosses()', 'int SquadLimit(int baseline)', 'protected ERR_HQWatchEntry Create(',
'protected void ApplyLosses(', 'bool Ingest(', 'bool IngestBody(', 'bool RequestDue(string',
'bool MarkRequested(', 'bool InvestigationDue(string', 'bool MarkInvestigating(', 'bool FinishInvestigation(', 'void Clear('))
common='\n'.join(cpp(body(U,s)) for s in ('static bool Finite(', 'static bool PositionValid('))
snapshot=cpp(body(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQReport.c','ERR_HQReport Snapshot(')).replace('copy.', 'copy->')
accept=cpp(body(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQ.c','protected void Accept('))
accept=accept.replace('ERR_MissionRules.', 'ERR_MissionRules::').replace('ERR_HQMemoryRules.', 'ERR_HQMemoryRules::')
accept=accept.replace('ERR_HQMemoryKind.', 'ERR_HQMemoryKind::').replace('vector.DistanceXZ(', 'vector::DistanceXZ(')
accept=accept.replace('previous.', 'previous->')
accept=accept.replace('string.Format(', 'Format(')
run(r'''
#include <cassert>
#include <cmath>
@@ -28,11 +45,16 @@ run(r'''
#include <vector>
#include <string>
using string=std::string;
struct vector{float x=0,y=0,z=0;float operator[](int i)const{return i==0?x:i==1?y:z;}};
struct vector{float x=0,y=0,z=0;float operator[](int i)const{return i==0?x:i==1?y:z;}
vector operator-(vector v){return {x-v.x,y-v.y,z-v.z};}vector operator/(float f){return {x/f,y/f,z/f};}
float Length(){return std::sqrt(x*x+y*y+z*z);}
static float DistanceXZ(vector a,vector b){return std::hypot(a.x-b.x,a.z-b.z);}};
struct Math{static int Clamp(int n,int lo,int hi){return std::clamp(n,lo,hi);}
static int Min(int a,int b){return std::min(a,b);}static float AbsFloat(float n){return std::abs(n);}};
int logs=0;void Print(const char*){logs++;}
template<class T>struct Array:std::vector<T>{
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;
bool Contains(T n){return std::find(this->begin(),this->end(),n)!=this->end();}
void Remove(int n){this->erase(this->begin()+n);}
int Count(){return this->size();}void Insert(T n){this->push_back(n);}void Clear(){this->clear();}
};
template<class K,class V>struct Map:std::map<K,V>{
@@ -42,13 +64,22 @@ template<class K,class V>struct Map:std::map<K,V>{
struct ERR_HQPursuitRules{
'''+common+r'''
};
'''+helper+r'''
struct ERR_HQReport{
string TravelMode="body",CasualtySourceId="patrol-1";bool Distress=false;int MessageId=99,CasualtySlot=0,CasualtyStrength=4;
vector Position{900,30,900},Velocity;float ObservedAt=1000,DetailsObservedAt=-1;
void* Target=nullptr;void* Source=nullptr;bool HasVelocity=false,Engaged=false;
int SeenEscapists=-1;string SeenWeapons="unknown";
'''+snapshot+r'''
};
struct ERR_HQStatusReport{
string SourceId="patrol-1";int MessageId=1;vector Position{100,20,300};
int Strength=4,Count=4,Fit=4,ConfirmedLosses=0;float ObservedAt=0;
int Strength=4,Count=4,Fit=4,ConfirmedLosses=0;float ObservedAt=0;Array<int> DeathSlots;
};
struct ERR_HQWatchEntry{
string SourceId;int MessageId=0,RequestId=0;vector Position;
int Strength=0,Count=0,Fit=0,ConfirmedLosses=0;
int Strength=0,Count=-1,Fit=-1,ConfirmedLosses=0;
ERR_HQDeliveredCasualties* Casualties=new ERR_HQDeliveredCasualties();float LastBodyDeliveredAt=-1;
float ObservedAt=-1,LastDeliveredAt=-1,AskedAt=-1,NextAttempt=0;
bool Investigating=false,Closed=false;
};
@@ -60,6 +91,19 @@ struct Watch{
int m_ConfirmedLosses=0;bool m_CapLogged=false;
'''+methods+r'''
};
struct ERR_MissionRules{static bool ContactFresh(float t,float now){return std::isfinite(t)&&std::isfinite(now)&&t>=0&&now>=t&&now-t<=120000;}};
struct ERR_HQMemoryKind{static constexpr int SIGHTING=0,DISTRESS=1,BODY=2;};
struct ERR_HQMemoryRules{static int Kind(ERR_HQReport* r){return r->Distress?1:r->TravelMode=="body"?2:0;}};
struct MemoryAdapter{int attempts=0;bool Ingest(ERR_HQReport* r,float){attempts++;return r->Target!=nullptr;}int Count(){return 0;}};
struct BodyAdapter{int confirmations=0;void ConfirmDelivered(void*){confirmations++;}};
struct CoverageAdapter{int forgotten=0;void Forget(vector){forgotten++;}};
struct JournalAdapter{void Write(string,string,int){}};
string Format(string,int,int){return "delivered";}
struct HQDelivery{
Watch m_Watch;MemoryAdapter m_Memory;BodyAdapter m_Bodies;CoverageAdapter m_Coverage;JournalAdapter m_Journal;
Array<ERR_HQReport*>m_Contacts;
'''+accept+r'''
};
int main(){
using R=ERR_HQWatchRules;
assert(R::SquadLimit(0,100)==0&&R::SquadLimit(-1,100)==0);
@@ -98,23 +142,23 @@ int main(){
assert(!watch.FinishInvestigation(report.SourceId,1,1,603000));
assert(!watch.Ingest(&report,603000));
report.MessageId=3;assert(!watch.Ingest(&report,603000)); // same observation relabelled cannot renew status
report.ObservedAt=601000;report.Count=3;report.Fit=2;report.ConfirmedLosses=1;
report.ObservedAt=601000;report.Count=3;report.Fit=2;report.ConfirmedLosses=1;report.DeathSlots={1};
assert(watch.Ingest(&report,603000)&&watch.ConfirmedLosses()==1);
report.MessageId=4;report.ObservedAt=602000;
assert(watch.Ingest(&report,604000)&&watch.ConfirmedLosses()==1); // cumulative, not additive
report.MessageId=5;report.ObservedAt=603000;report.Count=2;report.ConfirmedLosses=2;
report.MessageId=5;report.ObservedAt=603000;report.Count=2;report.ConfirmedLosses=2;report.DeathSlots={1,2};
assert(watch.Ingest(&report,605000)&&watch.ConfirmedLosses()==2&&watch.SquadLimit(2)==3);
report.MessageId=6;report.ObservedAt=604000;report.ConfirmedLosses=1;
report.MessageId=6;report.ObservedAt=604000;report.ConfirmedLosses=1;report.DeathSlots={1};
assert(!watch.Ingest(&report,606000)&&watch.ConfirmedLosses()==2);
report.ConfirmedLosses=2;report.Strength=5;
report.ConfirmedLosses=2;report.DeathSlots={1,2};report.Strength=5;
assert(!watch.Ingest(&report,606000));report.Strength=4;
// Missing members cannot be counted as dead. A delivered explicit whole-formation loss can close it.
report.Count=0;report.Fit=0;
assert(watch.Ingest(&report,606000)&&!entry->Closed&&watch.ConfirmedLosses()==2);
report.MessageId=7;report.ObservedAt=605000;report.ConfirmedLosses=4;
report.MessageId=7;report.ObservedAt=605000;report.ConfirmedLosses=4;report.DeathSlots={0,1,2,3};
assert(watch.Ingest(&report,607000)&&entry->Closed&&watch.ConfirmedLosses()==4);
assert(!watch.RequestDue(report.SourceId,2000000));
report.MessageId=8;report.ObservedAt=606000;report.ConfirmedLosses=0;report.Count=4;report.Fit=4;
report.MessageId=8;report.ObservedAt=606000;report.ConfirmedLosses=0;report.DeathSlots={};report.Count=4;report.Fit=4;
assert(!watch.Ingest(&report,608000)); // source cannot resurrect or erase counted deaths
// Validation precedes allocation/watermarks; malformed deliveries cannot poison future genuine reports.
float nan=std::numeric_limits<float>::quiet_NaN();
@@ -139,7 +183,7 @@ int main(){
assert(!watch.Ingest(nullptr,1000)&&!watch.Ingest(&late,nan));
assert(!watch.Ingest(&late,120001)&&watch.Ingest(&late,120000));
assert(!watch.RequestDue("late",nan));
Watch cap;ERR_HQStatusReport item;item.Count=0;item.Fit=0;item.ConfirmedLosses=4;
Watch cap;ERR_HQStatusReport item;item.Count=0;item.Fit=0;item.ConfirmedLosses=4;item.DeathSlots={0,1,2,3};
for(int i=0;i<128;i++){item.SourceId=std::to_string(i);assert(cap.Ingest(&item,1000));}
assert(cap.Count()==128&&cap.ConfirmedLosses()==512);
item.SourceId="overflow";assert(!cap.Ingest(&item,1000));
@@ -147,11 +191,64 @@ int main(){
item.SourceId="0";item.MessageId=2;item.ObservedAt=2000;
assert(cap.Ingest(&item,2000)&&cap.ConfirmedLosses()==512&&cap.Count()==128);
cap.Clear();assert(cap.Count()==0&&cap.ConfirmedLosses()==0&&!cap.Find("0"));
// Delivered bodies merge exact original roster slots, never aggregate totals or body positions.
Watch merged;ERR_HQStatusReport status;status.Count=3;status.Fit=2;status.ConfirmedLosses=1;status.DeathSlots={0};
assert(merged.Ingest(&status,1000));auto known=merged.Find("patrol-1");
ERR_HQReport body;assert(merged.IngestBody(&body,2000)&&merged.ConfirmedLosses()==1); // same casualty
body.CasualtySlot=2;assert(merged.IngestBody(&body,2001)&&merged.ConfirmedLosses()==2);
assert(known->MessageId==1&&known->ObservedAt==0&&known->LastDeliveredAt==1000&&known->Position.x==100);
assert(known->Count==2&&known->Fit==2&&known->AskedAt==-1&&!known->Closed);
assert(known->LastBodyDeliveredAt==2001&&known->LastDeliveredAt==1000); // independent delivery chronology
status.MessageId=2;status.ObservedAt=3000;status.Position.x=200;
assert(merged.Ingest(&status,3000)); // caller still only knows slot0; body id99 cannot block status id2
assert(known->ConfirmedLosses==2&&known->Count==2&&known->Position.x==200);
body.CasualtySlot=1;body.ObservedAt=4000;assert(merged.IngestBody(&body,4000));
body.CasualtySlot=3;assert(merged.IngestBody(&body,4001));
assert(known->Closed&&known->Count==0&&known->Fit==0&&merged.ConfirmedLosses()==4);
assert(known->MessageId==2&&known->ObservedAt==3000&&known->Position.x==200);
assert(merged.IngestBody(&body,4002)&&merged.ConfirmedLosses()==4); // replay or different observer
status.MessageId=3;status.ObservedAt=5000;assert(merged.Ingest(&status,5000)&&known->Closed);
assert(!merged.RequestDue("patrol-1",999999));
// Exercise the actual HQ delivered-intake method and actual Report.Snapshot, including corpse deletion.
HQDelivery intake;ERR_HQReport local;local.CasualtySourceId="deleted-during-uplink";
local.CasualtyStrength=1;local.CasualtySlot=0;local.Target=&local;local.MessageId=501;
auto wire=local.Snapshot();local.CasualtySlot=9;local.Position.x=1;
assert(wire->CasualtySlot==0&&wire->Position.x==900&&wire->CasualtySourceId=="deleted-during-uplink");
assert(intake.m_Watch.Count()==0); // preparing/queuing local evidence changes nothing
wire->Target=nullptr; // simulate native weak-reference invalidation after captured LOS, before radio completes
intake.Accept(wire,2000);
auto delivered=intake.m_Watch.Find("deleted-during-uplink");
assert(delivered&&delivered->Closed&&delivered->Count==0&&intake.m_Watch.ConfirmedLosses()==1);
assert(delivered->MessageId==0&&delivered->ObservedAt==-1); // no invented check-in or corpse position
assert(intake.m_Memory.attempts==1&&intake.m_Contacts.Count()==0&&intake.m_Coverage.forgotten==0);
intake.Accept(wire,2001);assert(intake.m_Watch.ConfirmedLosses()==1); // delivered duplicates cannot add losses
wire->CasualtySourceId="stale";intake.Accept(wire,121001);assert(!intake.m_Watch.Find("stale"));
wire->ObservedAt=2000;wire->TravelMode="walking";wire->Target=&local;
intake.Accept(wire,2000);assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1);
wire->TravelMode="body";wire->Distress=true;intake.Accept(wire,2001);
assert(!intake.m_Watch.Find("stale")&&intake.m_Contacts.Count()==1); // distress cannot claim bodily identity
Watch bodyOnly;body.CasualtySourceId="no-survivor";body.CasualtySlot=0;body.ObservedAt=1000;
assert(bodyOnly.IngestBody(&body,1000));auto wiped=bodyOnly.Find("no-survivor");
assert(wiped->Count==-1&&wiped->Fit==-1&&wiped->MessageId==0&&wiped->ObservedAt==-1);
assert(!bodyOnly.RequestDue("no-survivor",999999));
for(int slot=1;slot<4;slot++){body.CasualtySlot=slot;assert(bodyOnly.IngestBody(&body,1000));}
assert(wiped->Closed&&wiped->Count==0&&wiped->Fit==0&&bodyOnly.ConfirmedLosses()==4);
body.CasualtySourceId="invalid-body";body.CasualtySlot=4;
assert(!bodyOnly.IngestBody(&body,1000)&&bodyOnly.Count()==1);
body.CasualtySlot=0;body.MessageId=0;assert(!bodyOnly.IngestBody(&body,1000));body.MessageId=100;
body.TravelMode="walking";assert(!bodyOnly.IngestBody(&body,1000));body.TravelMode="body";
body.Distress=true;assert(!bodyOnly.IngestBody(&body,1000));body.Distress=false;
assert(!bodyOnly.IngestBody(&body,999)&&!bodyOnly.IngestBody(&body,121001));
assert(!bodyOnly.IngestBody(&body,nan)&&bodyOnly.Count()==1);
// Bad slot payloads remain atomic and cannot consume identity slots.
ERR_HQStatusReport corrupt;corrupt.SourceId="badslots";corrupt.Count=2;corrupt.Fit=2;
corrupt.ConfirmedLosses=2;corrupt.DeathSlots={0,0};assert(!bodyOnly.Ingest(&corrupt,1000));
corrupt.DeathSlots={0,4};assert(!bodyOnly.Ingest(&corrupt,1000)&&bodyOnly.Count()==1);
}
''')
source=P.read_text()
for forbidden in ('IEntity','SCR_AIGroup','GetGame(','GetOrigin(','GetPlayers(','GetAgents(',
'GetControlledEntity(','Spawn(','CallLater(','ERR_HQMemory','ERR_HQReport '):
'GetControlledEntity(','Spawn(','CallLater(','ERR_HQMemory'):
assert forbidden not in source,forbidden
assert all(len(line)<120 for line in source.splitlines())
print('PASS: delivered-only status snapshots, two missed check-ins/reply deadline, last reported coordinates, episode/replay guards, confirmed-loss idempotence and 128-record cap')