Files
EscapistsReintroduced/tests/test_automatic_equipment.py
T
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

158 lines
15 KiB
Python

"""Exercise production one-shot equipment lifecycle with async native boundaries."""
from policy_harness import ROOT, body, run
import re
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_AutomaticEquipment.c'
s=p.read_text();s=s[s.index('class ERR_AutomaticEquipmentGrant'):].split('modded class ArmaReforgerScripted', 1)[0]
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('ref ','').replace('class ','struct ')
s=s.replace('GetGame().ERR_ItemSelections().Allows(', 'ItemAllowed(')
s=s.replace('ResourceName','string').replace('array<IEntity>','Array<IEntity*>').replace('array<BaseMuzzleComponent>','Array<BaseMuzzleComponent*>')
s=s.replace('InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent))','actor->inventory')
s=s.replace('BaseWeaponComponent.Cast(m_Issued.FindComponent(BaseWeaponComponent))','m_Issued->weapon')
for t in ('ERR_GameMode','SCR_AIGroup','IEntity','InventoryStorageManagerComponent','BaseWeaponComponent','ERR_EnemyEquipmentCallback','ERR_AutomaticEquipmentItem','ERR_EnemyOptics'):
s=re.sub(r'\b'+t+r' (\w+)',t+r'* \1',s)
for obj in ('actor','mode','inventory','m_Operation','m_Choice','weapon','optics'):
s=s.replace(obj+'.',obj+'->')
s=s.replace('Settings().','Settings()->').replace('Content().','Content()->').replace('muzzles[0].','muzzles[0]->')
for obj in ('ERR_EnemyEquipmentRules','ERR_EnemyLoadoutRules','ERR_AutomaticEquipmentCatalog','ERR_EnemyLoadoutCatalog','ERR_HQ','ERR_EquipmentLog','Math','ERR_Inventory'):
s=s.replace(obj+'.',obj+'::')
s=s.replace('EStoragePurpose purpose','int purpose').replace('EStoragePurpose.','EStoragePurpose::')
s=s.replace('string.Format','Format').replace('.IsEmpty()', '.empty()').replace('null','nullptr')
s=re.sub(r'\b(bool|int|\w+\*) (\w+);',r'\1 \2 {};',s).replace('\n}\n','\n};\n')
run(r'''
#include <cassert>
#include <string>
#include <vector>
#include <algorithm>
using string=std::string;
bool itemAllowed=true; bool ItemAllowed(string){return itemAllowed;}
template<class T>struct Array:std::vector<T>{using std::vector<T>::operator=;int Count(){return this->size();}bool IsEmpty(){return this->empty();}bool Contains(T x){return std::find(this->begin(),this->end(),x)!=this->end();}void Copy(const Array<T>& v){*this=v;}void Clear(){this->clear();}};
struct ERR_EnemyEquipmentCallback{bool Finished=false,Succeeded=false;};
struct BaseMuzzleComponent{string ammo="AMMO";string GetDefaultMagazineOrProjectileName(){return ammo;}};
struct BaseWeaponComponent{Array<BaseMuzzleComponent*> muzzles;void GetMuzzlesList(Array<BaseMuzzleComponent*>& a){a=muzzles;}};
struct InventoryStorageManagerComponent;
struct IEntity{string prefab;bool eligible=true;InventoryStorageManagerComponent* inventory=nullptr;BaseWeaponComponent* weapon=nullptr;};
namespace EStoragePurpose{const int PURPOSE_DEPOSIT=1,PURPOSE_WEAPON_PROXY=2;}
struct InventoryStorageManagerComponent{Array<IEntity*> items;ERR_EnemyEquipmentCallback* pending=nullptr;int requests=0,purpose=0;bool accept=true;string last;
bool TrySpawnPrefabToStorage(string prefab,void*,int,int p,ERR_EnemyEquipmentCallback* op){requests++;pending=op;last=prefab;purpose=p;return accept;}
void Complete(bool ok=true){pending->Finished=true;pending->Succeeded=ok;}
};
namespace ERR_Inventory{void Items(InventoryStorageManagerComponent* inv,Array<IEntity*>& a){a=inv->items;}}
struct Settings{int percent=100;int Runtime(string key){assert(key=="automaticEquipmentChancePercent");return percent;}};
struct Content{bool allow=true;bool Allows(string){return allow;}};
struct ERR_GameMode{struct Settings settings;struct Content content;struct Settings* Settings(){return &settings;}struct Content* Content(){return &content;}};
struct SCR_AIGroup{};
struct ERR_EnemyOptics{int registrations=0;IEntity* issued=nullptr;void RegisterIssuedWeapon(IEntity* item){registrations++;issued=item;}};
namespace ERR_EnemyEquipmentRules{bool Eligible(ERR_GameMode*,SCR_AIGroup*,IEntity* a){return a&&a->eligible;}}
namespace Math{int roll=0;int RandomInt(int,int){return roll;}}
struct ERR_EnemyLoadoutRules{
'''+body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyLoadout.c','static bool Selected(')+r'''
};
struct ERR_AutomaticEquipmentItem{string Prefab="MOD_ITEM";bool Weapon=false;int Catalog=1,Weight=1;};
namespace ERR_AutomaticEquipmentCatalog{ERR_AutomaticEquipmentItem item;bool available=true;int picks=0;int TotalWeight(ERR_GameMode*){return 1;}ERR_AutomaticEquipmentItem* Pick(ERR_GameMode*){picks++;return available?&item:nullptr;}}
namespace ERR_EnemyLoadoutCatalog{bool Available(string){return true;}IEntity* Find(string prefab,Array<IEntity*>& items,Array<IEntity*>& excluded){for(auto* i:items)if(i->prefab==prefab&&!excluded.Contains(i))return i;return nullptr;}}
namespace ERR_HQ{void Event(string,string){}}
namespace ERR_EquipmentLog{std::vector<string> events;int Actor(IEntity*,SCR_AIGroup*){return 1;}void Write(string event,string){events.push_back(event);}}
template<class... T>string Format(string,T...){return "";}
'''+s+r'''
struct Fixture{ERR_GameMode mode;SCR_AIGroup group;IEntity actor,item,authored;BaseWeaponComponent weapon;BaseMuzzleComponent muzzle;InventoryStorageManagerComponent inv;ERR_AutomaticEquipmentGrant grant;
Fixture(){ERR_EquipmentLog::events.clear();itemAllowed=true;actor.inventory=&inv;item.prefab=authored.prefab="MOD_ITEM";weapon.muzzles.push_back(&muzzle);item.weapon=&weapon;inv.items.push_back(&authored);ERR_AutomaticEquipmentCatalog::available=true;ERR_AutomaticEquipmentCatalog::item.Weapon=false;Math::roll=0;}
void Tick(){grant.Check(&mode,&group,&actor);}void Insert(){inv.items.push_back(&item);inv.Complete();}
};
int main(){
{Fixture f;ERR_EnemyOptics optics;ERR_AutomaticEquipmentCatalog::item.Weapon=true;
f.grant.Check(&f.mode,&f.group,&f.actor,&optics);assert(optics.registrations==0);
f.Insert();f.grant.Check(&f.mode,&f.group,&f.actor,&optics);
assert(optics.registrations==1&&optics.issued==&f.item);
f.inv.Complete();f.grant.Check(&f.mode,&f.group,&f.actor,&optics);
assert(optics.registrations==1);}
{Fixture f;f.actor.inventory=nullptr;f.Tick();itemAllowed=false;f.actor.inventory=&f.inv;f.Tick();assert(f.grant.Done()&&f.inv.requests==0);}
{Fixture f;ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();f.Insert();itemAllowed=false;f.Tick();assert(f.grant.Done()&&f.inv.requests==1);}
{Fixture f;f.mode.settings.percent=0;f.Tick();f.mode.settings.percent=100;f.Tick();assert(f.grant.Done()&&f.inv.requests==0);}
{Fixture f;ERR_AutomaticEquipmentCatalog::available=false;f.Tick();assert(f.grant.Done()&&f.inv.requests==0);}
{Fixture f;f.Tick();assert(f.inv.requests==1&&f.inv.purpose==EStoragePurpose::PURPOSE_DEPOSIT);f.Insert();f.Tick();assert(f.grant.Done()&&f.grant.m_Issued==&f.item);f.inv.items.clear();for(int i=0;i<20;i++)f.Tick();assert(f.inv.requests==1);}
{Fixture f;f.inv.accept=false;f.Tick();assert(f.grant.Done()&&f.inv.requests==1&&f.inv.items[0]==&f.authored);}
{Fixture f;f.Tick();f.inv.Complete(false);f.Tick();assert(f.grant.Done()&&f.inv.requests==1);}
{Fixture f;f.Tick();for(int i=0;i<20;i++)f.Tick();assert(f.grant.Done()&&f.inv.requests==1);f.Insert();f.Tick();assert(f.inv.requests==1);}
{Fixture f;f.Tick();f.inv.Complete();f.Tick();assert(f.grant.Done()&&!f.grant.m_Issued&&f.inv.requests==1);}
{Fixture f;ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();assert(f.inv.purpose==EStoragePurpose::PURPOSE_WEAPON_PROXY);f.Insert();f.Tick();assert(f.inv.requests==2&&f.inv.last=="AMMO");f.inv.Complete();f.Tick();assert(f.inv.requests==3);f.inv.Complete();f.Tick();assert(f.grant.Done()&&f.grant.m_Magazines==2&&f.grant.m_AmmoConfirmed==2&&f.grant.m_ItemConfirmed);f.Tick();assert(f.inv.requests==3);}
// Failed ammo callback must not be reported as two successful magazines.
{Fixture f;ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();f.Insert();f.Tick();f.inv.Complete();f.Tick();f.inv.Complete(false);f.Tick();assert(f.grant.Done()&&f.grant.m_Magazines==2&&f.grant.m_AmmoConfirmed==1&&f.grant.m_ItemConfirmed);}
{Fixture f;f.mode.settings.percent=0;f.Tick();f.Tick();assert((ERR_EquipmentLog::events==std::vector<string>{"ROLL"}));}
{Fixture f;f.Tick();f.inv.accept=false;f.Insert();ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();assert(f.grant.m_ItemConfirmed&&f.grant.m_AmmoConfirmed==0);}
// A deleted issued entity must not be confused with an authored gun of the same prefab.
{Fixture f;ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();f.Insert();f.Tick();f.inv.items.pop_back();f.grant.m_Issued=nullptr;f.inv.Complete();f.Tick();assert(f.grant.Done()&&f.inv.requests==2);}
{Fixture f;ERR_AutomaticEquipmentCatalog::item.Weapon=true;f.Tick();f.Insert();f.mode.content.allow=false;f.Tick();assert(f.grant.Done()&&f.inv.requests==1);}
{Fixture f;f.Tick();f.actor.eligible=false;f.Insert();f.Tick();assert(f.grant.Done()&&f.inv.requests==1);}
{Fixture f;f.Tick();f.grant.Stop();f.Insert();f.Tick();assert(f.inv.requests==1);}
}
''')
print('PASS: automatic inventory one-shot roll, exact item ownership, finite compatible ammo, rejection, capture/stop, deletion, loot and late callback quarantine')
# The production catalog filter and insertion, including duplicate faction entries.
add=p.read_text().split(' protected static void Add',1)[1].split(' static int Count',1)[0]
add='void Add'+add
add=add.replace('SCR_ArsenalItem.Cast(entry.GetEntityDataOfType(SCR_ArsenalItem))','entry->Data()')
add=add.replace('array<SCR_EntityCatalogEntry>','Array<SCR_EntityCatalogEntry*>').replace('ResourceName','string')
for t in ('ERR_GameMode','SCR_EntityCatalog','SCR_EntityCatalogEntry','SCR_ArsenalItem','ERR_AutomaticEquipmentItem','Resource'):
add=re.sub(r'\b'+t+r' (\w+)',t+r'* \1',add)
for obj in ('catalog','entry','arsenal','mode','resource','item'):add=add.replace(obj+'.',obj+'->')
add=add.replace('GetGame().ERR_ItemSelections().Weight(prefab)', 'WeightFor(prefab)').replace('ERR_TerrainCatalog.Key(prefab)', 'prefab')
add=add.replace('GetGame().ERR_ItemSelections().Allows(prefab)', 'mode->Content()->Allows(prefab)')
add=add.replace('ERR_EquipmentLog.Sources(resource)', 'Sources(resource)').replace('ERR_EquipmentLog.Write', 'Log').replace('string.Format', 'Format')
add=add.replace('Content().','Content()->').replace('foreach (','for (').replace('SCR_EArsenalItemType.','SCR_EArsenalItemType::')
ticket=body(p,'static ERR_AutomaticEquipmentItem AtTicket(').replace('ERR_AutomaticEquipmentItem AtTicket', 'ERR_AutomaticEquipmentItem* AtTicket').replace('].Ceiling', ']->Ceiling').replace('null','nullptr')
filter=body(p,'static bool Supported(').replace('SCR_EArsenalItemType.','SCR_EArsenalItemType::').replace('SCR_EArsenalItemMode.','SCR_EArsenalItemMode::')
enums=''
for name in ('SCR_EArsenalItemType','SCR_EArsenalItemMode'):
# Known public enum values; filter logic itself is compiled from production.
names=(['RIFLE','PISTOL','LETHAL_THROWABLE','ROCKET_LAUNCHER','MACHINE_GUN','HEAL','BACKPACK','SNIPER_RIFLE','NON_LETHAL_THROWABLE','HEADWEAR','TORSO','VEST_AND_WAIST','LEGS','FOOTWEAR','RADIO_BACKPACK','EQUIPMENT','WEAPON_ATTACHMENT','EXPLOSIVES','HANDWEAR','MORTARS','HELICOPTER','VEHICLE'] if name.endswith('Type') else ['DEFAULT','WEAPON','WEAPON_VARIANTS','AMMUNITION','CONSUMABLE','ATTACHMENT','SUPPORT_STATION','PYLON'])
enums+='namespace '+name+'{'+''.join(f'const int {n}=1<<{i+1};' for i,n in enumerate(names))+'}\n'
run(r'''
#include <cassert>
#include <string>
#include <vector>
#include <algorithm>
#include <cctype>
struct string:std::string{using std::string::string;string(std::string v):std::string(v){}bool IsEmpty(){return empty();}int Length(){return size();}string Substring(int a,int b){return substr(a,b);}void ToLower(){std::transform(begin(),end(),begin(),[](unsigned char c){return std::tolower(c);});}};
template<class T>struct Array:std::vector<T>{bool IsEmpty(){return this->empty();}int Count(){return this->size();}void Insert(T x){this->push_back(x);}bool Contains(T x){return std::find(this->begin(),this->end(),x)!=this->end();}};
'''+enums+r'''
struct Resource{bool valid=true;bool IsValid(){return valid;}};
struct SCR_ArsenalItem{int type=SCR_EArsenalItemType::RIFLE,mode=SCR_EArsenalItemMode::WEAPON;Resource resource;bool enabled=true;int GetItemType(){return type;}int GetItemMode(){return mode;}Resource* GetItemResource(){return &resource;}};
struct SCR_EntityCatalogEntry{bool enabled=true;string prefab;SCR_ArsenalItem arsenal;bool IsEnabled(){return enabled;}string GetPrefab(){return prefab;}SCR_ArsenalItem* Data(){return arsenal.enabled?&arsenal:nullptr;}};
struct SCR_EntityCatalog{Array<SCR_EntityCatalogEntry*> entries;void GetEntityList(Array<SCR_EntityCatalogEntry*>& a){a=entries;}};
struct Content{bool Allows(string p){return p!="DISABLED";}};
struct ERR_GameMode{struct Content content;struct Content* Content(){return &content;}};
struct ERR_AutomaticEquipmentItem{string Prefab;bool Weapon=false;int Catalog=0;int Weight=1,Ceiling=0;};
int s_Catalog=1,s_Entries=0,s_Duplicates=0,s_TotalWeight=0;
int WeightFor(string p){return p=="ZERO"?0:(p=="{ABCDEF0123456789}Prefabs/A.et"?100:1);}
struct{void Set(string,int){}}s_Tickets;
template<class... T>string Format(string,T...){return "";}
void Log(string,string){}
Array<string> auditReasons;
void Audit(string,string reason,int=0,int=0,string=""){auditReasons.Insert(reason);}
string Sources(Resource*){return "test_mod";}
Array<ERR_AutomaticEquipmentItem*> s_Items;Array<string> s_Seen;bool s_Overflow=false;
'''+filter+'\n'+add+'\n'+ticket+r'''
int main(){
using namespace SCR_EArsenalItemType;using namespace SCR_EArsenalItemMode;
assert(Supported(RIFLE,WEAPON)&&Supported(HEAL,CONSUMABLE)&&Supported(EQUIPMENT,DEFAULT));
for(int t:{BACKPACK,ROCKET_LAUNCHER,WEAPON_ATTACHMENT,EXPLOSIVES,MORTARS,VEHICLE,HELICOPTER,HEADWEAR,0})assert(!Supported(t,DEFAULT));
for(int m:{AMMUNITION,ATTACHMENT,SUPPORT_STATION,PYLON,0})assert(!Supported(RIFLE,m));
assert(!Supported(RIFLE|VEHICLE,WEAPON));
ERR_GameMode mode;SCR_EntityCatalog catalog;SCR_EntityCatalogEntry a,b,c,d,e,f,g,z; z.prefab="ZERO";
a.prefab="{ABCDEF0123456789}Prefabs/A.et";b.prefab="{abcdef0123456789}Prefabs/OtherName.et";
c.prefab="MEDICINE";c.arsenal.type=HEAL;c.arsenal.mode=CONSUMABLE;
d.prefab="DISABLED";e.prefab="ENTRY_OFF";e.enabled=false;f.prefab="DATA_OFF";f.arsenal.enabled=false;g.prefab="INVALID";g.arsenal.resource.valid=false;
for(auto* item:{&a,&b,&c,&d,&e,&f,&g,&z})catalog.entries.Insert(item);
Add(&mode,&catalog);assert(s_Items.Count()==2&&s_Items[0]->Weapon&&!s_Items[1]->Weapon);
Add(&mode,&catalog);assert(s_Items.Count()==2&&s_Duplicates==4); // same catalog merged again gives no extra tickets
assert(s_TotalWeight==101&&s_Items[0]->Weight==100&&s_Items[1]->Weight==1);
assert(!AtTicket(-1)&&!AtTicket(101));
int rifles=0,medicine=0;for(int t=0;t<101;t++){auto* chosen=AtTicket(t);assert(chosen);if(chosen->Weapon)rifles++;else medicine++;}
assert(rifles==100&&medicine==1&&auditReasons.Contains("zero_weight"));
assert(auditReasons.Contains("entry_disabled")&&auditReasons.Contains("no_arsenal_data")&&auditReasons.Contains("content_disabled")&&auditReasons.Contains("invalid_resource")&&auditReasons.Contains("eligible"));
catalog.entries.resize(16385);Add(&mode,&catalog);assert(s_Overflow);
}
''')
print('PASS: enabled/valid catalog eligibility, category restrictions, content filtering, GUID deduplication across factions and bounded catalogs and exact weighted ticket boundaries (100:1, zero excluded)')