Files
EscapistsReintroduced/tests/test_hq_watch.py
T
jakub cab998fb64 Combine Workshop addon and fix prison startup
Merge owned systems into one addon while preserving resource identities. Gate player admission on the replicated prison scene, restore the original framed door, and hold enemy AI until escape. Correct scenario branding and author metadata, include publishing assets, and retain regression and native verification evidence.
2026-09-20 16:42:52 +02:00

283 lines
19 KiB
Python

"""Actual delivered-only roster, missing-patrol and cumulative confirmed-loss rules."""
from policy_harness import ROOT, body, run
from pathlib import Path
import os,re
P=Path(os.environ.get('ERR_WATCH_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQWatch.c'))
U=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c'
def cpp(s):
for old,new in [('protected ',''),('Math.','Math::'),('ERR_HQPursuitRules.','ERR_HQPursuitRules::'),
('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','ERR_HQReport','ERR_HQDeliveredCasualties','ERR_HQAmmoSnapshot'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
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,')
s=s.replace('entry->AmmoState.', 'entry->AmmoState->').replace('report->AmmoState.', 'report->AmmoState->')
return s
helper=(ROOT/'addons/core/Scripts/Game/ERR/10_AI/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')
ammo_source=Path(os.environ.get('ERR_AMMO_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQAmmoSampler.c')).read_text()
ammo=ammo_source[:ammo_source.index('class ERR_HQAmmoSampler')]
ammo=ammo.replace('class ','struct ').replace('ERR_HQAmmoSnapshot Snapshot(', 'ERR_HQAmmoSnapshot* Snapshot(')
ammo=ammo.replace('ERR_HQAmmoSnapshot copy', 'ERR_HQAmmoSnapshot* copy').replace('copy.', 'copy->')
ammo=re.sub(r'\b(bool|int) (\w+);',r'\1 \2=0;',ammo).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 ArmedSearchBlocked(', '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/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c','ERR_HQReport Snapshot(')).replace('copy.', 'copy->')
accept=cpp(body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/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>
#include <algorithm>
#include <limits>
#include <map>
#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;}
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>{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>{
V Get(K k){return this->count(k)?this->at(k):V{};}void Set(K k,V v){(*this)[k]=v;}
void Clear(){this->clear();}
};
struct ERR_HQPursuitRules{
'''+common+r'''
};
'''+helper+ammo+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};
ERR_HQAmmoSnapshot* AmmoState=nullptr;
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;
ERR_HQAmmoSnapshot* AmmoState=nullptr;
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;
};
struct ERR_HQWatchRules{
'''+rules+r'''
};
struct Watch{
Array<ERR_HQWatchEntry*>m_Entries;Map<string,ERR_HQWatchEntry*>m_BySource;
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);
assert(R::SquadLimit(2,0)==2&&R::SquadLimit(2,1)==2&&R::SquadLimit(2,2)==3);
assert(R::SquadLimit(2,-1)==2&&R::SquadLimit(2,15)==8);
assert(R::SquadLimit(100,0)==8&&R::SquadLimit(2,2147483647)==8);
Watch ammunition;ERR_HQStatusReport ammoReport;ERR_HQAmmoSnapshot ammoState;
ammoState.Complete=true;ammoState.LauncherComplete=true;ammoState.Members=4;ammoState.SmallArmsMuzzles=4;
ammoReport.AmmoState=&ammoState;
assert(!ammunition.ArmedSearchBlocked("patrol-1",1000)); // local scan is not HQ knowledge
assert(ammunition.Ingest(&ammoReport,1000)&&ammunition.ArmedSearchBlocked("patrol-1",1000));
ammoState.SmallArmsRounds=120;assert(ammunition.ArmedSearchBlocked("patrol-1",1001)); // immutable delivered copy
assert(ammunition.ArmedSearchBlocked("patrol-1",180000)&&!ammunition.ArmedSearchBlocked("patrol-1",180001));
assert(!ammunition.ArmedSearchBlocked("patrol-1",-1)&&!ammunition.ArmedSearchBlocked("patrol-1",NAN));
ammoReport.MessageId=2;ammoReport.ObservedAt=2000;
assert(ammunition.Ingest(&ammoReport,2000)&&!ammunition.ArmedSearchBlocked("patrol-1",2000));
ammoReport.MessageId=3;ammoReport.ObservedAt=3000;ammoState.SmallArmsRounds=0;ammoState.Complete=false;
assert(ammunition.Ingest(&ammoReport,3000)&&!ammunition.ArmedSearchBlocked("patrol-1",3000)); // unknown != empty
ammoReport.MessageId=4;ammoReport.ObservedAt=4000;ammoState.Complete=true;ammoState.SmallArmsChambers=1;
assert(ammunition.Ingest(&ammoReport,4000)&&!ammunition.ArmedSearchBlocked("patrol-1",4000));
ammoReport.MessageId=5;ammoReport.ObservedAt=5000;ammoState.SmallArmsChambers=0;
assert(ammunition.Ingest(&ammoReport,5000)&&ammunition.ArmedSearchBlocked("patrol-1",5000));
ERR_HQReport casualty;casualty.MessageId=901;casualty.ObservedAt=5000;
assert(ammunition.IngestBody(&casualty,5001)&&!ammunition.ArmedSearchBlocked("patrol-1",5001)); // changed roster requires fresh inventory evidence
ammoReport.MessageId=6;ammoReport.ObservedAt=6000;ammoReport.AmmoState=nullptr;
assert(ammunition.Ingest(&ammoReport,6000)&&!ammunition.ArmedSearchBlocked("patrol-1",6000)); // missing payload clears stale ammo claim
Watch watch;ERR_HQStatusReport report;
// Until an actual delivery calls Ingest the local status produces no HQ roster or missing case.
assert(watch.Count()==0&&!watch.RequestDue(report.SourceId,999999));
assert(watch.Ingest(&report,5000)&&watch.Count()==1);
auto entry=watch.Find(report.SourceId);
report.Position={999,40,999}; // immutable intake: changing sender object cannot relocate HQ's snapshot
assert(entry->Position.x==100&&entry->LastDeliveredAt==5000);
assert(!watch.RequestDue(report.SourceId,359999));
assert(watch.RequestDue(report.SourceId,360000));
assert(!watch.MarkRequested(report.SourceId,2,360000));
assert(watch.MarkRequested(report.SourceId,1,360000));
assert(!watch.MarkRequested(report.SourceId,1,360001));
assert(!watch.InvestigationDue(report.SourceId,389999));
assert(watch.InvestigationDue(report.SourceId,390000));
assert(watch.MarkInvestigating(report.SourceId,1,1,390000));
assert(!watch.MarkInvestigating(report.SourceId,1,1,390000));
assert(watch.ConfirmedLosses()==0&&entry->Position.x==100); // silence proves neither casualties nor target position
assert(!watch.FinishInvestigation(report.SourceId,2,1,391000));
assert(watch.FinishInvestigation(report.SourceId,1,1,391000));
assert(!watch.FinishInvestigation(report.SourceId,1,1,391000));
assert(!watch.RequestDue(report.SourceId,570999)&&watch.RequestDue(report.SourceId,571000));
assert(watch.MarkRequested(report.SourceId,1,571000));
assert(watch.MarkInvestigating(report.SourceId,1,2,601000));
assert(!watch.FinishInvestigation(report.SourceId,1,1,601001)); // late completion from first request
// A fresh delayed check-in cancels the old missing episode, never refreshes an enemy contact.
report.MessageId=2;report.ObservedAt=600000;report.Fit=2;
assert(watch.Ingest(&report,602000));
assert(!entry->Investigating&&entry->AskedAt==-1&&entry->Position.x==999);
assert(watch.ConfirmedLosses()==0); // two unconscious members still alive
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.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.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.DeathSlots={1};
assert(!watch.Ingest(&report,606000)&&watch.ConfirmedLosses()==2);
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.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.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();
for(int field=0;field<13;field++){
ERR_HQStatusReport bad;bad.SourceId="invalid";
if(field==0)bad.SourceId="";
if(field==1)bad.MessageId=0;
if(field==2)bad.Position.x=nan;
if(field==3)bad.ObservedAt=1001;
if(field==4)bad.ObservedAt=-1;
if(field==5)bad.ObservedAt=nan;
if(field==6)bad.Strength=33;
if(field==7)bad.Count=5;
if(field==8)bad.Fit=5;
if(field==9)bad.Fit=-1;
if(field==10)bad.ConfirmedLosses=1;
if(field==11)bad.ConfirmedLosses=-1;
if(field==12)bad.SourceId=string(97,'a');
assert(!watch.Ingest(&bad,1000)&&watch.Count()==1);
}
ERR_HQStatusReport late;late.SourceId="late";
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;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));
item.SourceId="overflow2";assert(!cap.Ingest(&item,1000)&&logs==1);
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'):
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')