Integrate physical radio calls and finite vehicle maintenance
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production bounded radio graph and hop-delivery state machine."""
|
||||
from pathlib import Path
|
||||
import re, subprocess, tempfile
|
||||
import os, re, subprocess, tempfile
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
source=(ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQNetwork.c').read_text()
|
||||
source=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/ai/Scripts/Game/ERR/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
|
||||
@@ -43,7 +43,7 @@ struct SCR_AIGroup{bool enemy=true;};struct IEntity{vector pos;bool alive=true;i
|
||||
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);}};
|
||||
'''
|
||||
routefields='Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{};int Cursor{},MessageId{};float Due{},StartedAt=-1;vector SentFrom;std::string Payload;'
|
||||
routefields='Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{},Originated{};int Cursor{},MessageId{};float Due{},StartedAt=-1,OriginDue=-1;vector SentFrom;std::string Payload;'
|
||||
networkfields='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'
|
||||
@@ -53,19 +53,40 @@ int main(){
|
||||
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->Advance(1999,&journal)==0);assert(up->Advance(2000,&journal)==0);
|
||||
c[2].alive=false;assert(up->Advance(4000,&journal)==0);c[0].alive=false;assert(up->Advance(6000,&journal)==-1);
|
||||
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(2000,&journal)==0);
|
||||
c[2].alive=false;assert(good->Advance(4000,&journal)==0);assert(good->Advance(6000,&journal)==1);
|
||||
assert(journal.events.back().find("HQ received")!=std::string::npos); // an already completed earlier hop survives its sender
|
||||
auto severed=network.Route(&c[0]);assert(severed->Advance(0,&journal)==0);c[0].radio=0;assert(severed->Advance(2000,&journal)==-1);c[0].radio=2;
|
||||
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);c[1].radio=0;assert(lost->Advance(2000,&journal)==-1);assert(lost->Advance(4000,&journal)==-1);
|
||||
c[1].radio=1;auto moving=network.Route(&c[2]);moving->Advance(0,&journal);c[1].pos=V(499);assert(moving->Advance(2000,&journal)==-1);c[1].pos=V(500);
|
||||
auto down=network.Route(&c[2],true);assert(down->Nodes[0]->Carrier==&c[0]);assert(down->Advance(0,&journal)==0);
|
||||
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;
|
||||
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]));
|
||||
@@ -73,7 +94,7 @@ int main(){
|
||||
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, hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds\n";
|
||||
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:
|
||||
|
||||
@@ -133,11 +133,11 @@ coordinator=source[source.index('class ERR_EnemyEquipmentGroup'):]
|
||||
coordinator=re.sub(r'//[^\n]*','',coordinator).replace('protected ','').replace('ref ','').replace('class ','struct ')
|
||||
coordinator=coordinator.replace('array<ERR_EnemyEquipmentGroup>','Array<ERR_EnemyEquipmentGroup*>').replace('array<ERR_EnemyEquipmentEntry>','Array<ERR_EnemyEquipmentEntry*>').replace('array<AIAgent>','Array<AIAgent*>')
|
||||
coordinator=coordinator.replace('SCR_AICombatComponent.Cast(actor.FindComponent(SCR_AICombatComponent))','actor->combat').replace('SCR_AICombatComponent.Cast(entry.Actor.FindComponent(SCR_AICombatComponent))','entry->Actor->combat')
|
||||
for cls in ('ERR_GameMode','SCR_AIGroup','ERR_EnemyEquipmentGroup','ERR_EnemyEquipmentEntry','IEntity','AIAgent'):
|
||||
for cls in ('ERR_GameMode','SCR_AIGroup','ERR_EnemyRadio','ERR_EnemyEquipmentGroup','ERR_EnemyEquipmentEntry','IEntity','AIAgent'):
|
||||
coordinator=re.sub(r'\b'+cls+r' ([a-zA-Z_]\w*)',cls+r'* \1',coordinator)
|
||||
for obj in ('m_Mode','existing','state','group','agent','entry','actor'):
|
||||
coordinator=coordinator.replace(obj+'.',obj+'->')
|
||||
coordinator=coordinator.replace('Group.GetAgents','Group->GetAgents').replace('Combat.ERR_','Combat->ERR_').replace('Optics.Check','Optics->Check')
|
||||
coordinator=coordinator.replace('Group.GetAgents','Group->GetAgents').replace('Combat.ERR_','Combat->ERR_').replace('Optics.Check','Optics->Check').replace('Radio.Check','Radio->Check').replace('Radio.Stop','Radio->Stop').replace('Radio.Register','Radio->Register')
|
||||
for cls in ('Replication','ERR_GameMode','ERR_EnemyEquipmentRules'):coordinator=coordinator.replace(cls+'.',cls+'::')
|
||||
coordinator=coordinator.replace('void ERR_EnemyEquipment(', 'ERR_EnemyEquipment(')
|
||||
coordinator=coordinator.replace('GetGame().GetWorld().GetWorldTime()','worldTime').replace('foreach (','for (').replace('null','nullptr')
|
||||
@@ -161,6 +161,7 @@ struct ERR_EnemyOptics{
|
||||
ERR_EnemyOptics(IEntity* a,SCR_AIGroup* g):actor(a),group(g){}
|
||||
void Check(ERR_GameMode*){checks++;}void Stop(){stops++;}
|
||||
};
|
||||
struct ERR_EnemyRadio{ERR_EnemyRadio(SCR_AIGroup*,int){}void Register(IEntity*){}void Check(ERR_GameMode*){}void Stop(){}};
|
||||
struct ERR_EnemyEquipmentEntry{
|
||||
ERR_EnemyOptics* Optics=nullptr;bool Complete=false;
|
||||
IEntity* Actor=nullptr;SCR_AIGroup* Group=nullptr;SCR_AICombatComponent* Combat=nullptr;bool Released=false;int SkillChecks=0,checks=0,stops=0;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Actual one-time group radio state machine against native inventory API doubles."""
|
||||
from pathlib import Path
|
||||
import os,re
|
||||
from policy_harness import ROOT,run
|
||||
P=Path(os.environ.get('ERR_RADIO_SOURCE',ROOT/'addons/ai/Scripts/Game/ERR/ERR_EnemyRadio.c'))
|
||||
s=P.read_text()
|
||||
assert 'GetOrigin' not in s and 'CallLater' not in s and 'Math.Random' not in s
|
||||
callback=(ROOT/'addons/ai/Scripts/Game/ERR/ERR_EnemyEquipment.c').read_text().split('class ERR_EnemyEquipmentCallback',1)[1].split('class ERR_EnemyEquipmentEntry',1)[0]
|
||||
code='class ERR_EnemyEquipmentCallback'+callback+s
|
||||
code=re.sub(r'//[^\n]*','',code).replace('protected ','').replace('override ','').replace('class ','struct ')
|
||||
code=code.replace('InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent))','actor->inventory')
|
||||
code=code.replace('item.FindComponent(BaseRadioComponent)','item->baseRadio').replace('SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent))','item->radio')
|
||||
code=code.replace('ref array<','Array<').replace('array<','Array<').replace('ref ERR_EnemyEquipmentCallback','ERR_EnemyEquipmentCallback*')
|
||||
code=code.replace('static const ResourceName','inline static const ResourceName').replace('void ERR_EnemyRadio(','ERR_EnemyRadio(')
|
||||
code=re.sub(r'\bERR_GameMode (?=\w)','ERR_GameMode* ',code)
|
||||
code=code.replace('foreach (','for (').replace('null','nullptr')
|
||||
for name in ('ERR_EnemyEquipmentRules','ERR_HQ','Replication','SCR_Global','ERR_GameMode','EStoragePurpose'):
|
||||
code=code.replace(name+'.',name+'::')
|
||||
for name in ('actor','inventory','item','radio','agent','m_Group','m_Operation','mode'):
|
||||
code=re.sub(r'\b'+name+r'\.',name+'->',code)
|
||||
code=re.sub(r'\b(bool|int|IEntity|SCR_AIGroup|ERR_EnemyEquipmentCallback\*) (\w+);',r'\1 \2{};',code)
|
||||
code=code.replace('\n}\n','\n};\n')
|
||||
equipment=Path(os.environ.get('ERR_EQUIPMENT_SOURCE',ROOT/'addons/ai/Scripts/Game/ERR/ERR_EnemyEquipment.c')).read_text()
|
||||
coordinator=equipment[equipment.index('class ERR_EnemyEquipmentGroup'):]
|
||||
coordinator=re.sub(r'//[^\n]*','',coordinator).replace('protected ','').replace('ref ','').replace('class ','struct ')
|
||||
coordinator=coordinator.replace('array<ERR_EnemyEquipmentGroup>','Array<ERR_EnemyEquipmentGroup*>').replace('array<ERR_EnemyEquipmentEntry>','Array<ERR_EnemyEquipmentEntry*>').replace('array<AIAgent>','Array<AIAgent>')
|
||||
coordinator=coordinator.replace('SCR_AICombatComponent.Cast(actor.FindComponent(SCR_AICombatComponent))','actor->combat').replace('SCR_AICombatComponent.Cast(entry.Actor.FindComponent(SCR_AICombatComponent))','entry->Actor->combat')
|
||||
for cls in ('ERR_GameMode','ERR_EnemyRadio','ERR_EnemyEquipmentGroup','ERR_EnemyEquipmentEntry'):
|
||||
coordinator=re.sub(r'\b'+cls+r' ([a-zA-Z_]\w*)',cls+r'* \1',coordinator)
|
||||
for obj in ('m_Mode','existing','state','group','agent','entry','actor'):
|
||||
coordinator=coordinator.replace(obj+'.',obj+'->')
|
||||
coordinator=coordinator.replace('Group.GetAgents','Group->GetAgents').replace('Combat.ERR_','Combat->ERR_').replace('Optics.Check','Optics->Check').replace('Radio.Stop','Radio->Stop').replace('Radio.Check','Radio->Check').replace('Radio.Register','Radio->Register')
|
||||
for cls in ('Replication','ERR_GameMode','ERR_EnemyEquipmentRules'):coordinator=coordinator.replace(cls+'.',cls+'::')
|
||||
coordinator=coordinator.replace('void ERR_EnemyEquipment(', 'ERR_EnemyEquipment(')
|
||||
coordinator=coordinator.replace('GetGame().GetWorld().GetWorldTime()','worldTime').replace('foreach (','for (').replace('null','nullptr')
|
||||
coordinator=re.sub(r'\b(bool|int|float|SCR_AIGroup|[A-Za-z_]\w*\*) ([A-Za-z_]\w*);',r'\1 \2 {};',coordinator).replace('\n}\n','\n};\n')
|
||||
hook_stubs=r'''
|
||||
float worldTime=0;
|
||||
struct SCR_AICombatComponent{void ERR_BindEnemySkill(ERR_GameMode*,Group*){}};
|
||||
struct ERR_EnemyOptics{ERR_EnemyOptics(IEntity,Group*){}void Check(ERR_GameMode*){}};
|
||||
struct ERR_EnemyEquipmentEntry{IEntity Actor=nullptr;struct Group*Group=nullptr;SCR_AICombatComponent*Combat=nullptr;ERR_EnemyOptics*Optics=nullptr;bool Released=false;int SkillChecks=0;
|
||||
void Check(ERR_GameMode*){}void Stop(ERR_GameMode*){Released=true;}};
|
||||
'''
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
using string=std::string;using ResourceName=std::string;
|
||||
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Insert(T t){this->push_back(t);}void Clear(){this->clear();}bool Contains(T t){return std::find(this->begin(),this->end(),t)!=this->end();}};
|
||||
struct Entity;struct Group;struct Inventory;struct Agent;struct Radio;struct SCR_AICombatComponent;
|
||||
using IEntity=Entity*;using SCR_AIGroup=Group*;using InventoryStorageManagerComponent=Inventory*;using AIAgent=Agent*;using SCR_RadioComponent=Radio*;
|
||||
struct ScriptedInventoryOperationCallback{virtual void OnComplete(){}virtual void OnFailed(){}virtual void OnAllComplete(){}virtual void OnAllFailed(){}virtual ~ScriptedInventoryOperationCallback()=default;};
|
||||
struct EStoragePurpose{static const int PURPOSE_ANY=0;};
|
||||
struct Radio{bool manpack=false;bool ERR_IsHQManpack(){return manpack;}};
|
||||
struct Entity{SCR_AICombatComponent*combat=nullptr;Group*group=nullptr;Inventory*inventory=nullptr;bool eligible=true,baseRadio=false;Radio*radio=nullptr;};
|
||||
struct Agent{IEntity actor=nullptr;IEntity GetControlledEntity(){return actor;}};
|
||||
struct Group{Array<Agent*>agents;int players=0,faction=1;bool slave=false,expanded=true;
|
||||
void GetAgents(Array<Agent*>&out){out=agents;}int GetPlayerCount(bool){return players;}int GetFaction(){return faction;}
|
||||
bool IsSlave(){return slave;}bool IsExpandComplete(){return expanded;}};
|
||||
struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;bool active=true;static ERR_GameMode*Get(){return current;}bool Active(){return active;}int EnemyFaction(){return 1;}};
|
||||
struct Replication{inline static bool server=true;static bool IsServer(){return server;}};
|
||||
struct SCR_Global{inline static bool editor=false;static bool IsEditMode(){return editor;}};
|
||||
struct ERR_HQ{inline static Array<string>events;static void Event(string,string message){events.Insert(message);}};
|
||||
struct ERR_EnemyEquipmentRules{static bool Eligible(ERR_GameMode*m,Group*g,IEntity a){return m&&g&&a&&a->eligible&&a->group==g;}};
|
||||
struct Inventory{Array<IEntity>items;Array<int>requests;bool basicFits=true,manpackFits=true,accept=true;int callback=0;ScriptedInventoryOperationCallback*last=nullptr;
|
||||
void GetItems(Array<IEntity>&out){out=items;}
|
||||
int Kind(ResourceName p){return p.find("R107M")!=string::npos?2:1;}
|
||||
bool CanInsertResource(ResourceName p,int purpose){assert(purpose==0);return Kind(p)==2?manpackFits:basicFits;}
|
||||
bool TrySpawnPrefabToStorage(ResourceName p,void*target,int slot,int purpose,ScriptedInventoryOperationCallback*cb){assert(!target&&slot==-1&&purpose==0);requests.Insert(Kind(p));last=cb;
|
||||
if(callback==1){cb->OnComplete();}if(callback==2){cb->OnFailed();}return accept;}};
|
||||
'''+code+hook_stubs+coordinator+r'''
|
||||
struct Fixture{ERR_GameMode mode;Group group,other;Entity actors[3],basic,manpack;Agent agents[3];Inventory inv[3];Radio pack;
|
||||
Fixture(){ERR_GameMode::current=&mode;pack.manpack=true;basic.baseRadio=true;manpack.baseRadio=true;manpack.radio=&pack;
|
||||
for(int i=0;i<3;i++){actors[i].group=&group;actors[i].inventory=&inv[i];agents[i].actor=&actors[i];group.agents.Insert(&agents[i]);}}
|
||||
void Bind(ERR_EnemyRadio&s){for(auto&a:actors)s.Register(&a);}
|
||||
int Requests(){int n=0;for(auto&i:inv)n+=i.requests.Count();return n;}
|
||||
};
|
||||
int main(){
|
||||
// Existing basic/manpack sets are terminal, including later loss. Basic sets aren't upgraded on ordinary groups.
|
||||
for(int kind=1;kind<=2;kind++){Fixture f;ERR_EnemyRadio s(&f.group,kind==1?2:1);f.Bind(s);f.inv[0].items.Insert(kind==1?&f.basic:&f.manpack);
|
||||
assert(s.Check(&f.mode)&&f.Requests()==0);f.inv[0].items.clear();for(int i=0;i<8;i++)s.Check(&f.mode);assert(f.Requests()==0);}
|
||||
// Frozen relay policy: only positive ids congruent to one modulo four select a manpack.
|
||||
for(int id=0;id<=9;id++){Fixture f;ERR_EnemyRadio s(&f.group,id);f.Bind(s);s.Check(&f.mode);
|
||||
assert(s.m_RelaySelected==(id>0&&id%4==1));if(id==0){assert(f.Requests()==0);continue;}
|
||||
assert(f.Requests()==1&&f.inv[0].requests[0]==(id%4==1?2:1));}
|
||||
// A pending request owns the sole group grant slot; no second carrier or fallback even after timeout.
|
||||
{Fixture f;ERR_EnemyRadio s(&f.group,1);f.Bind(s);for(int i=0;i<12;i++)s.Check(&f.mode);
|
||||
assert(f.Requests()==1&&s.m_Stopped&&s.m_Submitted&&s.m_Checks==5);f.inv[0].last->OnFailed();s.Check(&f.mode);assert(f.Requests()==1);}
|
||||
// Explicit sync/async failure allows a different original carrier once, bounded before check five.
|
||||
for(int sync=0;sync<2;sync++){Fixture f;for(auto&i:f.inv)i.callback=sync?2:0;ERR_EnemyRadio s(&f.group,2);f.Bind(s);
|
||||
for(int t=0;t<6;t++){s.Check(&f.mode);if(!sync){for(auto&i:f.inv)if(i.last)i.last->OnAllFailed();}}
|
||||
assert(f.Requests()==3);for(auto&i:f.inv)assert(i.requests.Count()==1);}
|
||||
// Success is terminal even if the physical item was removed before inspection; callback first result wins.
|
||||
for(int sync=0;sync<2;sync++){Fixture f;f.inv[0].callback=sync?1:0;ERR_EnemyRadio s(&f.group,2);f.Bind(s);s.Check(&f.mode);
|
||||
if(!sync){f.inv[0].last->OnAllComplete();}f.inv[0].last->OnFailed();assert(s.Check(&f.mode));assert(f.Requests()==1);}
|
||||
// Native false plus synchronous success must not issue another copy.
|
||||
{Fixture f;f.inv[0].accept=false;f.inv[0].callback=1;ERR_EnemyRadio s(&f.group,2);f.Bind(s);s.Check(&f.mode);assert(s.Check(&f.mode));assert(f.Requests()==1);}
|
||||
// Existing basic capability is never replenished while optional manpack fitting is pending/refused.
|
||||
{Fixture f;f.inv[1].items={&f.basic};for(auto&i:f.inv)i.manpackFits=false;ERR_EnemyRadio s(&f.group,1);f.Bind(s);s.Check(&f.mode);
|
||||
f.inv[1].items.clear();for(int t=0;t<8;t++)s.Check(&f.mode);assert(f.Requests()==0&&s.m_BasicDone);}
|
||||
// No fitting manpack: one basic fallback may fit; no parallel optional upgrade while it is pending.
|
||||
{Fixture f;for(auto&i:f.inv)i.manpackFits=false;ERR_EnemyRadio s(&f.group,1);f.Bind(s);s.Check(&f.mode);assert(f.inv[0].requests[0]==1);
|
||||
for(auto&i:f.inv){i.manpackFits=true;}s.Check(&f.mode);assert(f.Requests()==1);f.inv[0].last->OnComplete();s.Check(&f.mode);
|
||||
assert(f.Requests()==2&&f.inv[0].requests[1]==2);}
|
||||
// Unknown inventory/oversize inspection blocks all grants, but a detected existing manpack is enough.
|
||||
{Fixture f;f.actors[2].inventory=nullptr;ERR_EnemyRadio s(&f.group,2);f.Bind(s);for(int t=0;t<5;t++)s.Check(&f.mode);assert(f.Requests()==0);}
|
||||
{Fixture f;for(int i=0;i<257;i++)f.inv[1].items.Insert(&f.basic);ERR_EnemyRadio s(&f.group,2);f.Bind(s);s.Check(&f.mode);assert(f.Requests()==0);}
|
||||
{Fixture f;f.actors[2].inventory=nullptr;f.inv[0].items={&f.manpack};ERR_EnemyRadio s(&f.group,1);f.Bind(s);assert(s.Check(&f.mode)&&f.Requests()==0);}
|
||||
// Native roster expansion is bounded; final fifth check never issues.
|
||||
{Fixture f;f.group.expanded=false;ERR_EnemyRadio s(&f.group,2);f.Bind(s);for(int i=0;i<4;i++)s.Check(&f.mode);f.group.expanded=true;
|
||||
assert(s.Check(&f.mode)&&f.Requests()==0);}
|
||||
// Changed/dead/captured/removed original crew terminates the whole startup allocation.
|
||||
for(int change=0;change<4;change++){Fixture f;ERR_EnemyRadio s(&f.group,2);f.Bind(s);s.Check(&f.mode);
|
||||
if(change==0){f.actors[0].group=&f.other;}if(change==1){f.actors[0].eligible=false;}
|
||||
if(change==2){f.group.agents.pop_back();}if(change==3){f.group.agents[0]=&f.agents[1];}
|
||||
assert(s.Check(&f.mode));f.inv[0].last->OnFailed();f.actors[0].group=&f.group;f.actors[0].eligible=true;s.Check(&f.mode);assert(f.Requests()==1);}
|
||||
// An actor not bound through the coordinator's canonical entry cannot receive a grant in another group.
|
||||
{Fixture f;ERR_EnemyRadio s(&f.group,2);for(int t=0;t<8;t++)s.Check(&f.mode);assert(f.Requests()==0);}
|
||||
// Stop retains quarantine and registrations cannot revive it.
|
||||
{Fixture f;ERR_EnemyRadio s(&f.group,2);f.Bind(s);s.Check(&f.mode);s.Stop();f.inv[0].last->OnFailed();s.Register(&f.actors[0]);s.Check(&f.mode);assert(f.Requests()==1);}
|
||||
for(int bad=0;bad<6;bad++){Fixture f;ERR_EnemyRadio s(&f.group,2);f.Bind(s);
|
||||
if(bad==0){Replication::server=false;}if(bad==1){SCR_Global::editor=true;}if(bad==2){f.mode.active=false;}
|
||||
if(bad==3){f.group.slave=true;}if(bad==4){f.group.players=1;}if(bad==5){f.group.faction=2;}
|
||||
assert(s.Check(&f.mode)&&f.Requests()==0);Replication::server=true;SCR_Global::editor=false;}
|
||||
// Actual coordinator binds only its first exact actor entry; a transferred pending carrier cannot receive a second allocation.
|
||||
{Fixture f;ERR_EnemyEquipment coordinator(&f.mode);coordinator.RegisterGroup(&f.group,1);coordinator.RegisterGroup(&f.group,2);
|
||||
assert(coordinator.m_Groups.Count()==1&&coordinator.m_Groups[0]->Radio->m_RelaySelected);
|
||||
coordinator.Tick(3000);assert(f.Requests()==1&&coordinator.m_Entries.Count()==3);
|
||||
coordinator.Tick(3001);assert(f.Requests()==1&&coordinator.m_Groups[0]->Radio->m_Checks==1);
|
||||
f.actors[0].group=&f.other;f.group.agents.erase(f.group.agents.begin());f.other.agents.Insert(&f.agents[0]);
|
||||
coordinator.RegisterGroup(&f.other,2);coordinator.Tick(6000);
|
||||
assert(coordinator.m_Groups[0]->Radio->m_Stopped&&coordinator.m_Groups[1]->Radio->m_Allowed.IsEmpty());
|
||||
f.inv[0].last->OnFailed();for(int t=9000;t<=21000;t+=3000)coordinator.Tick(t);assert(f.Requests()==1);
|
||||
coordinator.UnregisterGroup(&f.other);assert(coordinator.m_Groups[1]->Radio->m_Stopped);coordinator.Stop();}
|
||||
// Mortar allocation owner explicitly opts out; later duplicate registration cannot turn fitting on or reroll.
|
||||
{Fixture f;ERR_EnemyEquipment coordinator(&f.mode);coordinator.RegisterGroup(&f.group,1,false);coordinator.RegisterGroup(&f.group,1,true);
|
||||
coordinator.Tick(3000);assert(!coordinator.m_Groups[0]->Radio&&f.Requests()==0);}
|
||||
{Fixture f;ERR_EnemyEquipment coordinator(&f.mode);coordinator.RegisterGroup(&f.group,2);coordinator.Tick(3000);
|
||||
auto radio=coordinator.m_Groups[0]->Radio;coordinator.Stop();f.inv[0].last->OnFailed();coordinator.Tick(6000);assert(radio->m_Stopped&&f.Requests()==1);}
|
||||
assert(ERR_EnemyRadio::Capability(nullptr)==-1);
|
||||
}
|
||||
''')
|
||||
print('PASS: actual group radio startup preservation, finite native grants, callback quarantine, frozen selection/actor ownership and bounded observation')
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Execute physical node validity, including live consciousness during inventory cache lifetime."""
|
||||
import os,re
|
||||
from pathlib import Path
|
||||
from policy_harness import ROOT,run
|
||||
p=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/ai/Scripts/Game/ERR/ERR_HQNetwork.c'))
|
||||
s=p.read_text();a=s.index('bool Valid(');b=s.index('{',a);end=b+1;depth=1
|
||||
while depth:depth+=(s[end]=='{')-(s[end]=='}');end+=1
|
||||
code=s[a:end].replace('ERR_GameMode factions = ERR_GameMode.Get()', 'ERR_GameMode* factions = ERR_GameMode::Get()')
|
||||
code=code.replace('ERR_Actors.', 'ERR_Actors::').replace('ERR_HQRadio.', 'ERR_HQRadio::').replace('Group.', 'Group->').replace('factions.', 'factions->')
|
||||
code=code.replace('GetGame().GetWorld().GetWorldTime()', 'nativeNow')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
struct SCR_AIGroup{int players=0,faction=1;int GetPlayerCount(bool){return players;}int GetFaction(){return faction;}};
|
||||
struct IEntity{bool canFight=true;int capability=2;SCR_AIGroup*group=nullptr;};
|
||||
struct ERR_GameMode{static inline ERR_GameMode*current=nullptr;static ERR_GameMode*Get(){return current;}int EnemyFaction(){return 1;}};
|
||||
namespace ERR_Actors{bool CanFight(IEntity*a){return a&&a->canFight;}SCR_AIGroup*Group(IEntity*a){return a?a->group:nullptr;}}
|
||||
namespace ERR_HQRadio{int reads=0;int Capability(IEntity*a){reads++;return a->capability;}}
|
||||
float nativeNow=0;
|
||||
struct Node{IEntity*Carrier=nullptr;SCR_AIGroup*Group=nullptr;float m_CheckedAt=-1;int m_LiveCapability=0;
|
||||
'''+code+r'''
|
||||
};
|
||||
int main(){
|
||||
ERR_GameMode mode;ERR_GameMode::current=&mode;SCR_AIGroup group,other;IEntity actor;actor.group=&group;
|
||||
Node node;node.Carrier=&actor;node.Group=&group;
|
||||
assert(node.Valid(true)&&ERR_HQRadio::reads==1);
|
||||
nativeNow=100;assert(node.Valid()&&ERR_HQRadio::reads==1);
|
||||
// No cached inventory result can make an incapacitated/dead carrier valid.
|
||||
actor.canFight=false;nativeNow=200;assert(!node.Valid()&&ERR_HQRadio::reads==1);
|
||||
actor.canFight=true;assert(node.Valid(true)&&ERR_HQRadio::reads==1);
|
||||
// Exact original group/faction and absence of player members are always checked live.
|
||||
actor.group=&other;assert(!node.Valid());actor.group=&group;
|
||||
group.players=1;assert(!node.Valid());group.players=0;
|
||||
group.faction=2;assert(!node.Valid());group.faction=1;
|
||||
ERR_GameMode::current=nullptr;assert(!node.Valid());ERR_GameMode::current=&mode;
|
||||
// Inventory capability is intentionally sampled at most once per second.
|
||||
actor.capability=1;nativeNow=999;assert(node.Valid(true));assert(ERR_HQRadio::reads==1);
|
||||
nativeNow=1000;assert(!node.Valid(true));assert(node.Valid());assert(ERR_HQRadio::reads==2);
|
||||
actor.capability=0;nativeNow=2000;assert(!node.Valid());assert(ERR_HQRadio::reads==3);
|
||||
actor.capability=2;nativeNow=1500;assert(node.Valid(true));assert(ERR_HQRadio::reads==4); // rewind refresh
|
||||
node.Carrier=nullptr;assert(!node.Valid());node.Carrier=&actor;node.Group=nullptr;assert(!node.Valid());
|
||||
}
|
||||
''')
|
||||
print('PASS: actual radio node rejects current incapacitation, death, ownership and faction changes despite cached inventory; one-second capability refresh remains explicit')
|
||||
@@ -11,7 +11,7 @@ def adapt(s):
|
||||
s=re.sub(r'foreach \((\w+) (\w+) : ([^)]+)\)',r'for (\1 \2 : \3)',s)
|
||||
for n in ('Replication','ERR_Actors','Math','ERR_Resources','ERR_DiscoveryService','SCR_InventoryStorageManagerComponent','SCR_AIGroupUtilityComponent','ERR_MortarSupplyState'):
|
||||
s=s.replace(n+'.',n+'::')
|
||||
for n in ('group','member','actor','request','inventory','known','unit','m_Mode','m_Battery','mode','m_Reroll','home','sites','record'):
|
||||
for n in ('group','member','actor','request','inventory','known','unit','m_Mode','m_Battery','mode','m_Reroll','home','sites','record','m_Mortars'):
|
||||
s=s.replace(n+'.',n+'->')
|
||||
s=s.replace('Actor.FindComponent(', 'Actor->FindComponent(').replace('SCR_InventoryStorageManagerComponent inventory', 'SCR_InventoryStorageManagerComponent* inventory').replace('SCR_AIGroupUtilityComponent utility', 'SCR_AIGroupUtilityComponent* utility')
|
||||
s=s.replace('home->Definition.', 'home->Definition->').replace('Patrol.IsEmpty()', 'Patrol->IsEmpty()').replace('unit->StatusSampler.Bind(', 'unit->StatusSampler.Bind(')
|
||||
@@ -78,10 +78,12 @@ struct Intel{Mode* m_Mode;Array<ERR_IntelRequest> m_Requests;Set<IEntity> m_Roll
|
||||
};
|
||||
struct Clock{float GetWorldTime(){return 10;}Clock* GetWorld(){return this;}}clock_;Clock* GetGame(){return &clock_;}
|
||||
struct Sampler{bool Bound(){return bindings>0;}int bindings=0,strength=0;SCR_AIGroup group=nullptr;string source;void Bind(SCR_AIGroup g,string id,int n,int faction){assert(faction==1);bindings++;group=g;source=id;strength=n;}};
|
||||
struct GroupService{int registrations=0;Set<SCR_AIGroup> seen;void RegisterGroup(SCR_AIGroup g){if(!seen.Contains(g)){seen.Insert(g);registrations++;}}};
|
||||
struct GroupService{int registrations=0;Set<SCR_AIGroup> seen;Array<int> ids;Array<bool> fits;
|
||||
void RegisterGroup(SCR_AIGroup g,int radioId=0,bool fitRadios=true){ids.Insert(radioId);fits.Insert(fitRadios);if(!seen.Contains(g)){seen.Insert(g);registrations++;}}};
|
||||
struct Mortars{SCR_AIGroup owned=nullptr;bool FindGroup(SCR_AIGroup group){return group&&group==owned;}};
|
||||
struct BodyService:GroupService{void RegisterGroup(SCR_AIGroup g,Sampler& s){assert(s.Bound()&&s.group==g);GroupService::RegisterGroup(g);}};
|
||||
struct Unit{string HomeId,ForceSourceId;bool HomeGuard=false;float NextStatus=0;Sampler StatusSampler;int Id=0;SCR_AIGroup Group=nullptr;struct Utility* Utility=nullptr;float NextReport=0;};using ERR_HQUnit=Unit*;
|
||||
struct HQ{Mode* m_Mode;GroupService m_Equipment;BodyService m_Bodies;int m_NextId=0;Array<ERR_HQUnit> m_Units;
|
||||
struct HQ{Mode* m_Mode;GroupService m_Equipment;BodyService m_Bodies;int m_NextId=0;Array<ERR_HQUnit> m_Units;Mortars* m_Mortars=nullptr;
|
||||
'''+hqbind+hqregister+r'''
|
||||
};
|
||||
Intel* intel_;HQ* hq_;
|
||||
@@ -92,8 +94,10 @@ struct Ready{bool m_Stopped=false,m_Ready=false;Battery* m_Battery;int Finish(Mo
|
||||
'''+handoff+r'''
|
||||
}};
|
||||
int main(){
|
||||
Mode mode;Intel intel{&mode,{},{} };HQ hq{&mode,{},{},0,{}};intel_=&intel;hq_=&hq;Group group;
|
||||
Mode mode;Intel intel{&mode,{},{} };HQ hq{&mode,{},{},0,{}};intel_=&intel;hq_=&hq;Group group;Mortars mortars;mortars.owned=&group;hq.m_Mortars=&mortars;
|
||||
hq.RegisterGroup(&group);assert(hq.m_Units.Count()==1&&mode.publications==0&&Math::rolls==0);
|
||||
assert(hq.m_Equipment.ids.Count()==1&&hq.m_Equipment.ids[0]==hq.m_Units[0]->Id&&hq.m_Units[0]->Id==1);
|
||||
assert(!hq.m_Equipment.fits[0]); // Existing mortar readiness owns this group's radio allocation.
|
||||
assert(!hq.m_Units[0]->StatusSampler.Bound()&&hq.m_Units[0]->ForceSourceId.empty()&&hq.m_Bodies.registrations==0);
|
||||
Actor actors[4];Agent agents[4];for(int i=0;i<4;i++){agents[i].actor=&actors[i];group.members.Insert(&agents[i]);}
|
||||
Battery battery;Ready ready{false,false,&battery};battery.shells=26;
|
||||
@@ -108,6 +112,7 @@ int main(){
|
||||
for(int n=0;n<5;n++){assert(ready.Finish(&mode,&group)==1);intel.RegisterGroup(&group);hq.RegisterGroup(&group);}
|
||||
assert(mode.publications==1&&Math::rolls==4&&hq.m_Units.Count()==1);
|
||||
assert(hq.m_Units[0]->StatusSampler.bindings==1&&hq.m_Equipment.registrations==1&&hq.m_Bodies.registrations==1);
|
||||
assert(hq.m_Equipment.ids.Count()==1&&hq.m_Equipment.ids[0]==1&&!hq.m_Equipment.fits[0]);
|
||||
intel.Tick(1);for(Actor& a:actors)assert(a.inventory.requests==1);
|
||||
for(int n=0;n<5;n++){intel.Tick(2+n);}for(Actor& a:actors)assert(a.inventory.requests==1); // accepted asynchronous spawn never reissued
|
||||
for(Actor& a:actors){a.inventory.folder=true;}for(auto request:intel.m_Requests){request->Operation->Finished=true;request->Operation->Succeeded=true;}intel.Tick(10);assert(intel.m_Requests.Count()==0);
|
||||
@@ -118,6 +123,13 @@ int main(){
|
||||
battery.stopped=true;Ready cancelled{false,false,&battery};assert(cancelled.Finish(&mode,&group)==-1&&mode.publications==1);battery.stopped=false;
|
||||
Actor killed;Agent killedAgent{&killed};Group deathGroup;deathGroup.members.Insert(&killedAgent);intel.RegisterGroup(&deathGroup);assert(Math::rolls==5);killed.alive=false;intel.Tick(13);assert(killed.inventory.requests==0&&intel.m_Requests.Count()==0);intel.RegisterGroup(&deathGroup);assert(Math::rolls==5);
|
||||
Mode neverMode;neverMode.settings.chance=0;Intel never{&neverMode,{},{}};never.RegisterGroup(&group);int after=Math::rolls;neverMode.settings.chance=100;never.RegisterGroup(&group);assert(Math::rolls==after&&never.m_Requests.Count()==0); // no reroll of original negative decision
|
||||
// Ordinary physical groups receive the stable HQ ID and opt in, with or without a mortar service.
|
||||
Group ordinary,withoutMortars;hq.RegisterGroup(&ordinary);
|
||||
assert(hq.m_Equipment.ids.Count()==2&&hq.m_Equipment.ids[1]==hq.m_Units[1]->Id&&hq.m_Equipment.ids[1]==2&&hq.m_Equipment.fits[1]);
|
||||
hq.m_Mortars=nullptr;hq.RegisterGroup(&withoutMortars);
|
||||
assert(hq.m_Equipment.ids.Count()==3&&hq.m_Equipment.ids[2]==hq.m_Units[2]->Id&&hq.m_Equipment.ids[2]==3&&hq.m_Equipment.fits[2]);
|
||||
// A subsequent missing mortar registry cannot reroll an already bound group's opt-out.
|
||||
hq.RegisterGroup(&group);assert(hq.m_Equipment.ids.Count()==3&&!hq.m_Equipment.fits[0]&&hq.m_NextId==3);
|
||||
}
|
||||
''')
|
||||
print('PASS: actual Intel/HQ methods and one-shot qualified readiness handoff deliver late actors once; duplicate readiness, accepted spawn, pickup/drop, death and original negative rolls do not mint assignments')
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Execute actual read-only native-stock fixture checks with deterministic component adapters."""
|
||||
from pathlib import Path
|
||||
import os,re
|
||||
from policy_harness import ROOT,body,run
|
||||
P=Path(os.environ.get('ERR_NATIVE_TRANSPORT_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/ERR_NativeTransportProbe.c'))
|
||||
method=body(P,'protected bool MaintenanceStock(')
|
||||
for forbidden in ('SetFuel','SetResource','Consume','Spawn(','TryMove','GetInVehicle','SetHealth','EnableDamage'):
|
||||
assert forbidden not in method,forbidden
|
||||
s=method.replace('protected ','').replace('array<','Array<').replace('foreach (','for (').replace('null','nullptr').replace('ResourceName','string')
|
||||
s=s.replace('InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_Site.Container.FindComponent(InventoryStorageManagerComponent));','Inventory* inventory = m_Site->Container->inventory;')
|
||||
s=s.replace('SCR_ResourceComponent resource = SCR_ResourceComponent.Cast(item.FindComponent(SCR_ResourceComponent));','Resource* resource = item->resource;')
|
||||
s=s.replace('SCR_FuelManagerComponent fuel = SCR_FuelManagerComponent.Cast(item.FindComponent(SCR_FuelManagerComponent));','Fuel* fuel = item->fuel;')
|
||||
s=s.replace('SCR_ResourceContainer supplies;','Supply* supplies = nullptr;').replace('int kits, cans;','int kits=0, cans=0;')
|
||||
for var in ('m_Site','m_Mode','item','resource','supplies','fuel'):s=re.sub(r'\b'+var+r'\.',var+'->',s)
|
||||
s=s.replace('Sites().','Sites()->').replace('GetPrefabData().','GetPrefabData()->')
|
||||
for ns in ('ERR_Inventory','Math','EResourceType'):s=s.replace(ns+'.',ns+'::')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
using string=std::string;
|
||||
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}};
|
||||
struct Item;using IEntity=Item*;
|
||||
struct Inventory{Array<IEntity>items;};
|
||||
struct Supply{float amount=20,capacity=20;float GetResourceValue(){return amount;}float GetMaxResourceValue(){return capacity;}};
|
||||
struct Resource{Supply* supply=nullptr;Supply* GetContainer(int){return supply;}};
|
||||
struct Fuel{float litres=5,capacity=20;float GetTotalFuel(){return litres;}float GetTotalMaxFuel(){return capacity;}};
|
||||
struct Prefab{string name;string GetPrefabName(){return name;}};
|
||||
struct Item{Prefab* prefab=nullptr;Inventory* inventory=nullptr;Resource* resource=nullptr;Fuel* fuel=nullptr;bool owned=true;Prefab* GetPrefabData(){return prefab;}};
|
||||
struct Site{IEntity Container=nullptr;};struct Sites{bool Owns(IEntity item){return item&&item->owned;}};
|
||||
struct Mode{struct Sites sites;struct Sites* Sites(){return &sites;}};
|
||||
struct ERR_Inventory{static void Items(Inventory* inv,Array<IEntity>& items){for(auto item:inv->items)if(item&&std::find(items.begin(),items.end(),item)==items.end())items.push_back(item);}};
|
||||
namespace Math{float AbsFloat(float x){return std::fabs(x);}}namespace EResourceType{enum{SUPPLIES};}
|
||||
template<class...T>void PrintFormat(const char*,T...){}
|
||||
struct Probe{Mode* m_Mode;Site* m_Site;int checks=0;bool Check(bool valid,string){checks++;return valid;}
|
||||
'''+s+r'''
|
||||
};
|
||||
int main(){
|
||||
Mode mode;Site site;Probe p{&mode,&site};Item container,kit,can1,can2;Inventory inventory;
|
||||
Prefab kitPrefab{"{C09C7C97EFA34909}Prefabs/ERR/Items/EmergencyRepairKit.et"};
|
||||
Prefab canPrefab{"{12D5AD21E383B768}Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et"};
|
||||
Supply supplies;Resource resource{&supplies};Fuel fuel1,fuel2;
|
||||
site.Container=&container;container.inventory=&inventory;kit.prefab=&kitPrefab;kit.resource=&resource;
|
||||
can1.prefab=&canPrefab;can1.fuel=&fuel1;can2.prefab=&canPrefab;can2.fuel=&fuel2;
|
||||
inventory.items={&kit,&can1,&can2};assert(p.MaintenanceStock());
|
||||
assert(supplies.amount==20&&fuel1.litres==5&&fuel2.litres==5); // inspection made no changes
|
||||
inventory.items.push_back(&can1);assert(p.MaintenanceStock()); // duplicate inventory enumeration is not extra stock
|
||||
inventory.items={&kit,&can1};assert(!p.MaintenanceStock());inventory.items={&kit,&can1,&can2};
|
||||
Item extraKit=kit;inventory.items.push_back(&extraKit);assert(!p.MaintenanceStock());inventory.items.pop_back();
|
||||
kit.owned=false;assert(!p.MaintenanceStock());kit.owned=true;
|
||||
can2.owned=false;assert(!p.MaintenanceStock());can2.owned=true;
|
||||
kit.resource=nullptr;assert(!p.MaintenanceStock());kit.resource=&resource;
|
||||
resource.supply=nullptr;assert(!p.MaintenanceStock());resource.supply=&supplies;
|
||||
can2.fuel=nullptr;assert(!p.MaintenanceStock());can2.fuel=&fuel2;
|
||||
for(float invalid:{0.f,19.f,21.f,NAN}){supplies.amount=invalid;assert(!p.MaintenanceStock());}supplies.amount=20;
|
||||
supplies.capacity=100;assert(!p.MaintenanceStock());supplies.capacity=20;
|
||||
for(float invalid:{0.f,4.f,6.f,NAN}){fuel2.litres=invalid;assert(!p.MaintenanceStock());}fuel2.litres=5;
|
||||
fuel1.capacity=5;assert(!p.MaintenanceStock());fuel1.capacity=20;
|
||||
string original=kitPrefab.name;kitPrefab.name="unrelated";assert(!p.MaintenanceStock());kitPrefab.name=original;
|
||||
std::vector<Item> unrelated(129);for(auto& item:unrelated)inventory.items.push_back(&item);assert(!p.MaintenanceStock());inventory.items={&kit,&can1,&can2};
|
||||
container.owned=false;assert(!p.MaintenanceStock());container.owned=true;
|
||||
container.inventory=nullptr;assert(!p.MaintenanceStock());container.inventory=&inventory;
|
||||
assert(p.MaintenanceStock());
|
||||
}
|
||||
''')
|
||||
tick=body(P,'int Tick(')
|
||||
assert tick.index('if (!MaintenanceStock()) return -1;') < tick.index('IEntity driver = Character(0)')
|
||||
print('PASS: actual maintenance native-stock check rejects missing/duplicate/wrong-identity/unowned/depleted/malformed stock and is read-only; native runtime not executed')
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synthetic v3 packaging/runtime boundary tests; no native process or real addon edits."""
|
||||
import copy,hashlib,json,os,re,shutil,sys,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
ROOT=Path(os.environ.get('ERR_TEST_REPO',str(Path(__file__).resolve().parents[1])))
|
||||
sys.path.insert(0,str(ROOT/'tools'))
|
||||
if os.environ.get('ERR_PACKED_TOOLS'):sys.path.insert(0,os.environ['ERR_PACKED_TOOLS'])
|
||||
import headless_game as legacy
|
||||
import packed_external as v3
|
||||
import external_addons as ext
|
||||
|
||||
class ExternalPackedTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory(prefix='erri-packed-chat-v3test-');self.addCleanup(self.tmp.cleanup);self.home=Path(self.tmp.name)
|
||||
self.root=self.home/'repo';(self.root/'docs').mkdir(parents=True)
|
||||
self.source=self.home/'installed';self.source.mkdir();self.value=ext.policy(ROOT)
|
||||
for i,(guid,deps) in enumerate(ext.ALLOWED.items()):
|
||||
folder=self.source/guid;folder.mkdir();text='GameProject { ID "External_'+str(i)+'" GUID "'+guid+'" Dependencies { '+' '.join('"'+d+'"' for d in sorted(deps))+' } }\n'
|
||||
(folder/'addon.gproj').write_text(text);self.value['addons'][guid]['project_sha256']=hashlib.sha256(text.encode()).hexdigest()
|
||||
data=b'fixture rdb\0'
|
||||
for path,item in self.value['resources'].items():
|
||||
if item['addon']==guid:data+=path.encode()+b'\0\x06\0\0\0\0\0'+bytes.fromhex(item['guid'])[::-1]
|
||||
(folder/'resourceDatabase.rdb').write_bytes(data);(folder/'data.pak').write_bytes(b'fixture '+guid.encode())
|
||||
(self.root/'docs/external-addons.json').write_text(json.dumps(self.value))
|
||||
self.candidate=self.home/'packages';self.candidate.mkdir()
|
||||
for key,(name,guid,deps) in v3.PROJECTS.items():
|
||||
folder=self.candidate/key;folder.mkdir();ids={'58D0FB3206B6F859',*(v3.PROJECTS[d][1] for d in deps),*ext.direct_for(key)}
|
||||
text='GameProject { ID "'+name+'" GUID "'+guid+'" TITLE "Fixture" Dependencies { '+' '.join('"'+d+'"' for d in sorted(ids))+' } }\n'
|
||||
(folder/'addon.gproj').write_text(text);(folder/'data.pak').write_bytes(b'synthetic '+key.encode());(folder/'resourceDatabase.rdb').write_bytes(b'synthetic '+key.encode())
|
||||
# Session is a new direct /tmp child, separate from the fixture parent.
|
||||
session=tempfile.mkdtemp(prefix='erri-packed-chat-v3-');self.session=Path(session);self.session.rmdir();self.addCleanup(lambda:shutil.rmtree(self.session,ignore_errors=True))
|
||||
self.profile=self.session/'profile';self.steam=self.home/'Steam';self.vanilla=self.steam/'steamapps/common/Arma Reforger/addons/data';self.vanilla.mkdir(parents=True)
|
||||
self.args=SimpleNamespace(project='chat',packed_addons=self.candidate,packed_core=None,packed_session=self.session,profile=Path('/tmp/erri-game-profile'),settle=15,external_addons=self.source)
|
||||
self.packed=v3.request(self.args,self.root)
|
||||
def prepare(self):
|
||||
legacy.prepare_packed(self.packed);(self.profile/'addons').mkdir(parents=True);(self.profile/'addons/ArmaReforger').symlink_to(self.vanilla,target_is_directory=True)
|
||||
v3.prepare_profile(self.packed,self.steam,self.profile)
|
||||
def snapshot(self):return legacy.packed_snapshot(self.packed,['fixture-native-command'],self.profile)
|
||||
def seal(self,raw):return legacy.packed_seal(self.packed,raw,None)
|
||||
def content(self):
|
||||
game=self.steam/'steamapps/common/Arma Reforger'
|
||||
records=[('5614BBCCBB55ED1C','core',game/'addons/core','core.gproj',2),('58D0FB3206B6F859','ArmaReforger',self.profile/'addons/ArmaReforger','ArmaReforger.gproj',14)]
|
||||
records += [(guid,name,self.packed['input']/key,'addon.gproj',1) for key,(name,guid,_) in v3.PROJECTS.items()]
|
||||
records += [(guid,self.packed['external_names'][guid],self.profile/'addons'/('ERRI_'+guid),'addon.gproj',1) for guid in self.packed['external_records']]
|
||||
text=f"00 ENGINE : dir: '{self.packed['input']}'\n00 ENGINE : dir: './addons'\n00 ENGINE : dir: '{self.profile}/addons'\n00 ENGINE : Loaded addons:\n"
|
||||
for guid,name,path,descriptor,count in records:text+=f"00 ENGINE : gproj: '{path/descriptor}' guid: '{guid}'\n"
|
||||
for i,(guid,name,path,descriptor,count) in enumerate(records):
|
||||
text+=f"00 ENGINE : FileSystem: Adding package '{path}/' (pak count: {count}) to filesystem under name {name}\n"
|
||||
text+=f"00 RESOURCES : ResourceDB: loading cache (id={i} name={name} path={path}/resourceDatabase.rdb)\n"
|
||||
return text+'''00 SCRIPT : Module: GameLib; defines: "PLATFORM_WINDOWS"
|
||||
00 SCRIPT : Module: Game; defines: "PLATFORM_WINDOWS"
|
||||
00 SCRIPT : ERR ORDINARY: header verified project=chat systems=MissionSystems
|
||||
00 SCRIPT : ERR ORDINARY: local participant verified project=chat phase=prison
|
||||
00 SCRIPT : Rereintroduced [mission]: phase 0 -> 1
|
||||
'''
|
||||
def evidence(self,text=None):return legacy.packed_evidence(self.content() if text is None else text,self.packed,self.steam,self.profile)
|
||||
def test_explicit_v3_and_frozen_v2_rejection(self):
|
||||
with patch.object(legacy,'ROOT',self.root):self.assertEqual(legacy.packed_request(self.args)['schema_version'],3)
|
||||
with self.assertRaises(ValueError):legacy.packed_payload_inventory(self.candidate,'chat')
|
||||
self.assertEqual(set(v3.inventory(self.candidate)),{key+'/'+name for key in v3.PROJECTS for name in v3.FILES})
|
||||
self.assertEqual(v3.PROJECTS,legacy.PACKED_PROJECTS)
|
||||
def test_request_gates(self):
|
||||
for changes in [{'project':'core'},{'packed_core':self.candidate},{'packed_addons':None},{'packed_session':None},{'settle':14},{'profile':self.home/'profile'},{'packed_session':self.home/'nested'},{'packed_session':self.home}]:
|
||||
with self.subTest(changes=changes):
|
||||
args=copy.copy(self.args);vars(args).update(changes)
|
||||
with self.assertRaises(ValueError):v3.request(args,self.root)
|
||||
def test_exact_identity_closure_and_no_loose_files(self):
|
||||
path=self.candidate/'ai/addon.gproj';original=path.read_text()
|
||||
for text in [original.replace('"'+ext.DIRECT[0]+'"','"5F16D7E4A1CBE075"'),original.replace('Dependencies {','Dependencies { "'+ext.DIRECT[0]+'"'),original.replace('ERR_Ai','Not_Ai')]:
|
||||
path.write_text(text)
|
||||
with self.assertRaises(ValueError):v3.inventory(self.candidate)
|
||||
path.write_text(original);(self.candidate/'core/loose.c').write_text('bad')
|
||||
with self.assertRaises(ValueError):v3.inventory(self.candidate)
|
||||
def test_private_copy_exact_six_links_and_schema(self):
|
||||
unrelated=self.source/'protected';unrelated.mkdir();(unrelated/'addon.gproj').write_text('GameProject {GUID "5F16D7E4A1CBE075"}')
|
||||
self.prepare();raw=self.snapshot();value=json.loads(raw)
|
||||
self.assertEqual(value['schema_version'],3);self.assertEqual(value['kind'],'packed-chat-external-runtime-input');self.assertEqual(len(value['external_addons']),6)
|
||||
self.assertEqual({p.name for p in (self.profile/'addons').iterdir()},{'ArmaReforger',*('ERRI_'+g for g in ext.ALLOWED)})
|
||||
self.assertEqual(self.seal(raw)['packed_inputs_unchanged_after_run'],True)
|
||||
self.assertNotEqual((self.candidate/'ai/data.pak').stat().st_ino,(self.packed['input']/'ai/data.pak').stat().st_ino)
|
||||
self.assertFalse(value.get('release_qualified',False))
|
||||
def test_candidate_and_copy_mutations_refused(self):
|
||||
self.prepare();raw=self.snapshot();(self.packed['input']/'core/data.pak').write_bytes(b'changed')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
with self.assertRaises(ValueError):self.snapshot()
|
||||
def test_mutated_candidate_before_copy_refused(self):
|
||||
(self.candidate/'core/data.pak').write_bytes(b'changed')
|
||||
with self.assertRaises(ValueError):legacy.prepare_packed(self.packed)
|
||||
self.assertFalse(self.session.exists())
|
||||
def test_external_runtime_mutation_refused(self):
|
||||
self.prepare();raw=self.snapshot();(self.source/ext.DIRECT[0]/'data.pak').write_bytes(b'changed')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_external_loose_source_refused(self):
|
||||
self.prepare();raw=self.snapshot();(self.source/ext.DIRECT[0]/'Scripts').mkdir()
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_external_link_retarget_refused(self):
|
||||
self.prepare();raw=self.snapshot();link=self.profile/'addons'/('ERRI_'+ext.DIRECT[0]);link.unlink();link.symlink_to(self.source/ext.DIRECT[1])
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_policy_and_cache_mutations_refused(self):
|
||||
self.prepare();raw=self.snapshot();policy=self.root/'docs/external-addons.json';old=policy.read_bytes();policy.write_bytes(old+b' ')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);policy.write_bytes(old)
|
||||
self.packed['cache_path'].write_text('{}')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_profile_contamination_and_vanilla_retarget_refused(self):
|
||||
self.prepare();raw=self.snapshot();(self.profile/'addons/original').mkdir()
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);(self.profile/'addons/original').rmdir()
|
||||
link=self.profile/'addons/ArmaReforger';link.unlink();link.symlink_to(self.source)
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_provenance_tamper_refused(self):
|
||||
self.prepare();raw=self.snapshot();(self.session/v3.MANIFEST).write_bytes(raw+b' ')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_valid_full_thirteen_mount_database_proof(self):
|
||||
result=self.evidence();self.assertTrue(result['ready'],result);self.assertFalse(result['release_qualified']);self.assertEqual(result['external_addon_count'],6)
|
||||
def test_runtime_evidence_rejects_redirects_extra_missing_and_loose(self):
|
||||
content=self.content();guid=ext.DIRECT[0];name=self.packed['external_names'][guid];folder=self.profile/'addons'/('ERRI_'+guid)
|
||||
variants=[content.replace(str(folder),'/unreviewed'),content.replace('(pak count: 1) to filesystem under name '+name,'(pak count: 2) to filesystem under name '+name),content+'00 ENGINE : dir: "ignored"\n'+f"00 ENGINE : dir: '{self.source}'\n",content+f"00 ENGINE : FileSystem: Adding relative directory '{folder}/Scripts' to filesystem under name {name}\n",re.sub(r'[^\n]*gproj:[^\n]*'+guid+r'[^\n]*\n','',content),re.sub(r'[^\n]*ResourceDB: loading cache[^\n]*name='+name+r' [^\n]*\n','',content),content.replace('PLATFORM_WINDOWS','PLATFORM_WINDOWS,WORKBENCH')]
|
||||
for text in variants:
|
||||
with self.subTest():self.assertFalse(self.evidence(text)['ready'])
|
||||
def test_crlf_project_and_exact_multiple_external_paks(self):
|
||||
path=self.candidate/'ai/addon.gproj';path.write_bytes(path.read_bytes().replace(b'\n',b'\r\n'))
|
||||
self.assertEqual(len(v3.inventory(self.candidate)),15)
|
||||
guid=ext.DIRECT[0];(self.source/guid/'data001.pak').write_bytes(b'second synthetic pak')
|
||||
self.packed=v3.request(self.args,self.root)
|
||||
name=self.packed['external_names'][guid];text=self.content().replace('(pak count: 1) to filesystem under name '+name,'(pak count: 2) to filesystem under name '+name)
|
||||
self.assertTrue(self.evidence(text)['ready']);self.assertFalse(self.evidence()['ready'])
|
||||
def test_redirected_profile_refused(self):
|
||||
self.prepare();raw=self.snapshot();moved=self.session/'redirected';self.profile.rename(moved);self.profile.symlink_to(moved,target_is_directory=True)
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run'])
|
||||
def test_saves_addon_and_native_provenance_tamper_refused(self):
|
||||
self.prepare();raw=self.snapshot();saves=self.profile/'addons/saves';saves.mkdir();(saves/'addon.gproj').write_text('unreviewed')
|
||||
self.assertFalse(self.seal(raw)['packed_inputs_unchanged_after_run']);shutil.rmtree(saves)
|
||||
native=self.profile/'logs/logs_2026-09-20_12-34-56';native.mkdir(parents=True);(native/v3.MANIFEST).write_bytes(raw+b' ')
|
||||
self.assertFalse(legacy.packed_seal(self.packed,raw,native)['packed_inputs_unchanged_after_run'])
|
||||
def test_native_errors_are_not_waived(self):
|
||||
result=self.evidence(self.content()+'00 RESOURCES (E): Native resource error\n')
|
||||
self.assertTrue(result['failed']);self.assertFalse(result['ready'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Validate maintenance stock joins the finite scenario without weakening clue/resource bounds."""
|
||||
from pathlib import Path
|
||||
import json,os,re
|
||||
from policy_harness import ROOT
|
||||
R=Path(os.environ.get('ERR_MAINTENANCE_ROOT',ROOT))
|
||||
def contents(text,start):
|
||||
left=text.index('{',start);depth=1;cursor=left+1
|
||||
while depth:
|
||||
depth+=(text[cursor]=='{')-(text[cursor]=='}');cursor+=1
|
||||
return text[left+1:cursor-1]
|
||||
s=(R/'addons/core/Configs/ERR/EveronSites.conf').read_text()
|
||||
sites={}
|
||||
for match in re.finditer(r'ERR_SiteDefinition\s*\{',s):
|
||||
block=contents(s,match.start());sites[re.search(r'\bId "([^"]+)"',block)[1]]=block
|
||||
stock={}
|
||||
for name,site in sites.items():
|
||||
stock[name]=[]
|
||||
for match in re.finditer(r'ERR_SiteStock\s*\{',site):
|
||||
item=contents(site,match.start());ref=re.search(r'Prefab "\{([A-F0-9]{16})\}([^"]+)"',item)
|
||||
count=int(re.search(r'Count (\d+)',item)[1]);assert 1<=count<=64
|
||||
stock[name].append((ref[1],ref[2],count,item))
|
||||
assert sum(item[2] for items in stock.values() for item in items)<=256
|
||||
# Both established routes to radio intel and the prison field map remain usable.
|
||||
for name in ('everon-supply-1','everon-supply-2'):
|
||||
assert 'RevealOnEscape 1' in sites[name]
|
||||
clues=[i for i in stock[name] if i[1]=='Prefabs/ERR/ObjectiveDocument.et']
|
||||
assert sum(i[2] for i in clues)>=2 and all('TargetId "everon-radio-1"' in i[3] for i in clues)
|
||||
assert any(i[1].endswith('PaperMap_01_folded.et') for i in stock['everon-prison-1'])
|
||||
kitpath='Prefabs/ERR/Items/EmergencyRepairKit.et'
|
||||
canpath='Prefabs/Items/Fuel/Jerrycan_01/Jerrycan_01_item.et'
|
||||
maintenance=[(name,i) for name,items in stock.items() for i in items if i[1] in (kitpath,canpath)]
|
||||
assert len(maintenance)==2 and all(name=='everon-supply-2' for name,_ in maintenance)
|
||||
assert {i[1]:i[2] for _,i in maintenance}=={kitpath:1,canpath:2}
|
||||
# Own container must be finite and must not turn off native resource eligibility.
|
||||
kit=(R/'addons/core'/kitpath).read_text()
|
||||
assert kit.startswith('GenericEntity : "{33B2DFDCD0EBA3DB}Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et"')
|
||||
assert kit.count('SCR_ResourceContainer ')==1 and 'm_fResourceValueCurrent 20' in kit and 'm_fResourceValueMax 20' in kit
|
||||
for forbidden in ('m_aGenerators','m_aDisabledResourceTypes','m_eFactionUsageCheck','m_fMaxHealScaled','SCR_RepairSupportStationComponent','SCR_ResourceConsumer '):
|
||||
assert forbidden not in kit,forbidden
|
||||
meta=(R/'addons/core'/Path(kitpath+'.meta')).read_text();guid=re.search(r'Name "\{([A-F0-9]{16})\}',meta)[1]
|
||||
assert [i[0] for _,i in maintenance if i[1]==kitpath]==[guid]
|
||||
vanilla=json.loads((R/'docs/vanilla-resources.json').read_text())
|
||||
assert vanilla[canpath]=='12D5AD21E383B768'
|
||||
assert vanilla['Prefabs/Items/Equipment/Kits/RepairKit_01/RepairKit_01_wrench.et']=='33B2DFDCD0EBA3DB'
|
||||
evidence=json.loads((R/'docs/vehicle-maintenance-source-evidence.json').read_text())
|
||||
assert evidence['finite_initial_stock']['litres_per_can']==5 and evidence['native_validation']=='pending'
|
||||
print('PASS: finite maintenance stock/resource identity, bounded scenario stock and preserved two-cache clue redundancy; native interaction not claimed')
|
||||
Reference in New Issue
Block a user