From 4b737c825f0e6c5e9143c632ade9d7f53624ae7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Mon, 9 Nov 2026 17:05:25 +0100 Subject: [PATCH] Reduce redundant AI equipment, radio and observation checks Preserve live ownership and detection rules while skipping redundant work. Record published Workshop version 0.0.150 and add operation-count regressions. --- CHANGELOG.md | 6 +- .../Game/ERR/10_AI/ERR_EnemyEquipment.c | 14 +- .../Game/ERR/10_AI/ERR_HQBodyObserver.c | 5 +- .../Scripts/Game/ERR/10_AI/ERR_HQReport.c | 6 +- .../Scripts/Game/ERR/10_AI/ERR_KnownMines.c | 3 +- tests/test_enemy_equipment.py | 19 ++- tests/test_enemy_gunner_skill.py | 23 ++- tests/test_enemy_radio.py | 3 +- tests/test_hq_body_observer.py | 47 +++++- tests/test_hq_radio_capability.py | 78 ++++++++++ tests/test_known_mine_collection.py | 147 ++++++++++++++++++ tools/check.py | 2 + 12 files changed, 335 insertions(+), 18 deletions(-) create mode 100644 tests/test_hq_radio_capability.py create mode 100644 tests/test_known_mine_collection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 969ce0b9..5cc44f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -Latest published Workshop version: **0.0.149** +Latest published Workshop version: **0.0.150** + +## 2026-11-09 — AI processing + +- Reduced redundant AI equipment, radio, corpse-observation and mine-avoidance checks. ## 2026-11-07 — Clearer setting choices diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c index 16396ea9..6a0194ec 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_EnemyEquipment.c @@ -36,7 +36,11 @@ modded class SCR_AICombatComponent void ERR_RefreshEnemySkill() { if (!m_ERRSkillMode || !ERR_EnemyEquipmentRules.Eligible(m_ERRSkillMode, m_ERRSkillGroup, GetOwner())) return; - EAISkill skill = ERR_EnemyGunnerSkill.Profile(m_ERRRequestedSkill, ERR_EnemyGunnerSkill.OriginalGunner(m_ERRSkillMode, m_ERRSkillGroup, GetOwner())); + bool originalGunner; + // A maximum floor already determines the profile; preserve the lower-floor path. + if (ERR_EnemyEquipmentRules.Floor(m_ERRRequestedSkill) < EAISkill.CYLON) + originalGunner = ERR_EnemyGunnerSkill.OriginalGunner(m_ERRSkillMode, m_ERRSkillGroup, GetOwner()); + EAISkill skill = ERR_EnemyGunnerSkill.Profile(m_ERRRequestedSkill, originalGunner); if (GetAISkill() != skill) super.SetAISkill(skill); } void ERR_ReleaseEnemySkill(ERR_GameMode mode) @@ -50,7 +54,10 @@ modded class SCR_AICombatComponent if (m_ERRSkillMode && ERR_EnemyEquipmentRules.Eligible(m_ERRSkillMode, m_ERRSkillGroup, GetOwner())) { m_ERRRequestedSkill = skill; - skill = ERR_EnemyGunnerSkill.Profile(skill, ERR_EnemyGunnerSkill.OriginalGunner(m_ERRSkillMode, m_ERRSkillGroup, GetOwner())); + bool originalGunner; + if (ERR_EnemyEquipmentRules.Floor(skill) < EAISkill.CYLON) + originalGunner = ERR_EnemyGunnerSkill.OriginalGunner(m_ERRSkillMode, m_ERRSkillGroup, GetOwner()); + skill = ERR_EnemyGunnerSkill.Profile(skill, originalGunner); } super.SetAISkill(skill); } @@ -184,7 +191,8 @@ class ERR_EnemyEquipment if (!ERR_EnemyEquipmentRules.Owned(m_Mode, entry.Group, entry.Actor)) { entry.Stop(m_Mode); continue; } // Incapacitation suspends work on the existing entry; it must not permanently // remove the skill binding or recreate completed equipment grants on recovery. - if (!ERR_EnemyEquipmentRules.Eligible(m_Mode, entry.Group, entry.Actor)) continue; + // Owned was checked immediately above; only the consciousness gate remains. + if (!ERR_Actors.CanFight(entry.Actor)) continue; // Combat may arrive after the group's initial membership notification. if (!entry.Combat && entry.SkillChecks < 5) { diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c index e79fc160..a9ef9eb4 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c @@ -127,6 +127,8 @@ class ERR_HQBodyObserver vector observerPosition = observer.GetOrigin(); vector delta = bodyPosition - observerPosition; float distance = delta.Length(); + // No facing can admit an invalid or distant body; avoid native transform work. + if (!ERR_HQBodyRules.InView(distance, 1)) return false; delta[1] = 0; vector transform[4]; observer.GetWorldTransform(transform); vector forward = transform[2]; forward[1] = 0; @@ -162,7 +164,8 @@ class ERR_HQBodyObserver report = null; if (!Replication.IsServer() || !ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return false; TickBudget(now); - if (!OwnedPhysical(group) || group.GetPlayerCount(true) > 0 || m_Bodies.IsEmpty()) return false; + // Refresh death candidates first, then skip ownership lookups if none exist. + if (m_Bodies.IsEmpty() || !OwnedPhysical(group) || group.GetPlayerCount(true) > 0) return false; ERR_HQBodyScan state; foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) state = scan; if (!state) return false; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c index e508ab50..0464e63e 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQReport.c @@ -17,11 +17,13 @@ class ERR_HQRadio InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(character.FindComponent(InventoryStorageManagerComponent)); if (!inventory) return 0; - array items = {}; - inventory.GetItems(items); int result; ERR_HQ hq = ERR_HQ.Get(); if (hq && hq.Hunters()) result = hq.Hunters().VehicleRadioCapability(character); + // A live physical vehicle manpack already supplies the maximum capability. + if (result == 2) return 2; + array items = {}; + inventory.GetItems(items); foreach (IEntity item : items) { if (!item || !item.FindComponent(BaseRadioComponent)) diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_KnownMines.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_KnownMines.c index 677fb159..02141c72 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_KnownMines.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_KnownMines.c @@ -88,7 +88,8 @@ class ERR_KnownMines protected bool Collect(IEntity actor) { if (m_Near.Count() >= 128) return false; - if (FriendlyActor(actor) && !m_Near.Contains(actor)) m_Near.Insert(actor); + // Reject props cheaply; overlapping queries need not reclassify accepted soldiers. + if (ChimeraCharacter.Cast(actor) && !m_Near.Contains(actor) && FriendlyActor(actor)) m_Near.Insert(actor); return true; } protected ERR_KnownMine Blocking(vector start, vector goal, float radius) diff --git a/tests/test_enemy_equipment.py b/tests/test_enemy_equipment.py index 33045e21..b821c138 100644 --- a/tests/test_enemy_equipment.py +++ b/tests/test_enemy_equipment.py @@ -12,7 +12,7 @@ for cls in ('ERR_GameMode','SCR_AIGroup','ERR_EnemyRadio','ERR_EnemyEquipmentGro 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('Mines.ReadyForResupply','Mines->ReadyForResupply').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','ERR_EquipmentLog'):coordinator=coordinator.replace(cls+'.',cls+'::') +for cls in ('Replication','ERR_GameMode','ERR_EnemyEquipmentRules','ERR_EquipmentLog','ERR_Actors'):coordinator=coordinator.replace(cls+'.',cls+'::') coordinator=coordinator.replace('string.Format', 'Format') coordinator=coordinator.replace('void ERR_EnemyEquipment(', 'ERR_EnemyEquipment(') coordinator=coordinator.replace('GetGame().GetWorld().GetWorldTime()','worldTime').replace('foreach (','for (').replace('null','nullptr') @@ -31,10 +31,11 @@ struct SCR_AIGroup; struct ERR_GameMode{static inline ERR_GameMode* current=nullptr;bool active=true;bool Active(){return active;}static ERR_GameMode* Get(){return current;}}; namespace ERR_EquipmentLog{void Round(ERR_GameMode*){}void Write(string,string){}} struct SCR_AICombatComponent{int binds=0,refreshes=0;void ERR_BindEnemySkill(ERR_GameMode*,SCR_AIGroup*){binds++;}void ERR_RefreshEnemySkill(){refreshes++;}}; -struct IEntity{SCR_AICombatComponent* combat=nullptr;bool eligible=true,unconscious=false;}; +struct IEntity{SCR_AICombatComponent* combat=nullptr;bool eligible=true,unconscious=false,alive=true,player=false,captured=false;}; struct AIAgent{IEntity* actor=nullptr;IEntity* GetControlledEntity(){return actor;}}; struct SCR_AIGroup{Array agents;void GetAgents(Array& out){out=agents;}}; -namespace ERR_EnemyEquipmentRules{bool Owned(ERR_GameMode*,SCR_AIGroup*,IEntity* actor){return actor&&actor->eligible;}bool Eligible(ERR_GameMode* m,SCR_AIGroup* g,IEntity* actor){return Owned(m,g,actor)&&!actor->unconscious;}} +namespace ERR_Actors{bool CanFight(IEntity* actor){return actor&&actor->alive&&!actor->unconscious;}} +namespace ERR_EnemyEquipmentRules{int ownedChecks=0;bool Owned(ERR_GameMode*,SCR_AIGroup*,IEntity* actor){++ownedChecks;return actor&&actor->eligible&&actor->alive&&!actor->player&&!actor->captured;}bool Eligible(ERR_GameMode* m,SCR_AIGroup* g,IEntity* actor){return Owned(m,g,actor)&&ERR_Actors::CanFight(actor);}} struct ERR_EnemyOptics{ IEntity* actor;SCR_AIGroup* group;int checks=0,stops=0; ERR_EnemyOptics(IEntity* a,SCR_AIGroup* g):actor(a),group(g){} @@ -66,7 +67,9 @@ int main(){ service.m_Stopped=true;assert(!service.MineSupplyReady(&group,&soldier));service.m_Stopped=false; service.m_Entries.resize(2049,nullptr);assert(!service.MineSupplyReady(&group,&soldier));service.m_Entries.resize(1); service.Tick(3001);assert(entry->checks==1&&entry->Optics->checks==1&&combat.refreshes==1); + ERR_EnemyEquipmentRules::ownedChecks=0; service.Tick(6000);assert(entry->checks==2&&entry->Optics->checks==2&&combat.binds==1&&combat.refreshes==2); + assert(ERR_EnemyEquipmentRules::ownedChecks==1); // Immediate Eligible must not repeat successful Owned. service.UnregisterGroup(&group);service.Tick(9000);assert(service.m_Entries[0]->Released&&service.m_Entries[0]->checks==2&&entry->Optics->checks==2&&entry->Optics->stops==1); service.RegisterGroup(&group);service.Tick(12000);assert(service.m_Entries.Count()==1&&combat.binds==1&&combat.refreshes==2); service.Stop();service.RegisterGroup(&group);service.Tick(50000);assert(service.m_Groups.Count()==0&&service.m_Stopped); @@ -88,6 +91,16 @@ int main(){ auto capturedOptics=capture.m_Entries[0]->Optics;capturedBody.eligible=false;capture.Tick(6000); assert(capturedOptics->checks==1&&capturedOptics->stops==1&&capture.m_Entries[0]->Released); capturedBody.eligible=true;capture.Tick(9000);assert(capturedOptics->checks==1); + // Every pass still performs ownership vetoes before skill or equipment work. + for(int reason=0;reason<3;reason++){ + ERR_EnemyEquipment ownership(&mode);SCR_AIGroup ownedGroup;IEntity ownedBody;AIAgent ownedAgent;ownedAgent.actor=&ownedBody; + ownedGroup.agents.Insert(&ownedAgent);ownership.RegisterGroup(&ownedGroup);ownership.Tick(3000);auto ownedEntry=ownership.m_Entries[0]; + if(reason==0)ownedBody.alive=false;else if(reason==1)ownedBody.player=true;else ownedBody.captured=true; + ERR_EnemyEquipmentRules::ownedChecks=0;ownership.Tick(6000); + assert(ownedEntry->Released&&ownedEntry->stops==1&&ownedEntry->checks==1&&ERR_EnemyEquipmentRules::ownedChecks==1); + ownedBody.alive=true;ownedBody.player=false;ownedBody.captured=false;ownership.Tick(9000); + assert(ownedEntry->checks==1&&ownedEntry->stops==1); // Terminal release never restarts completed grants. + } } ''') print('PASS: actual equipment coordinator registration dedupe, startup delay, five member scans, finite component retries, unregister/stop terminal behavior and 256-group bound') diff --git a/tests/test_enemy_gunner_skill.py b/tests/test_enemy_gunner_skill.py index c7450184..feebc0d1 100644 --- a/tests/test_enemy_gunner_skill.py +++ b/tests/test_enemy_gunner_skill.py @@ -24,10 +24,17 @@ rules=convert(rules.replace('class ERR_EnemyEquipmentRules','struct ERR_EnemyEqu # Declare helper before rules, then define its real method bodies after rules. helper=helper[helper.index(' static ERR_ConvoyTask TaskForGroup'):helper.rfind('}')] helper=convert(helper).replace('static ERR_ConvoyTask* TaskForGroup','ERR_ConvoyTask* ERR_EnemyGunnerSkill::TaskForGroup').replace('static bool CapturedGroup','bool ERR_EnemyGunnerSkill::CapturedGroup').replace('static bool OriginalGunner','bool ERR_EnemyGunnerSkill::OriginalGunner').replace('static EAISkill Profile','EAISkill ERR_EnemyGunnerSkill::Profile') +# Count entry into the real query; changing only the floor models a future policy. +helper,count=re.subn(r'(bool ERR_EnemyGunnerSkill::OriginalGunner\([^)]*\)\s*\{)',r'\1 originalGunnerCalls++;',helper) +assert count==1 +assert rules.count('return EAISkill::CYLON;')==1 +rules=rules.replace('return EAISkill::CYLON;','return useRequestedFloor ? skill : EAISkill::CYLON;') run(r''' #include #include enum class EAISkill{ROOKIE=20,REGULAR=50,EXPERT=80,CYLON=100}; +bool useRequestedFloor=false; +int originalGunnerCalls=0; enum class ERR_ForceKind{CONVOY,GUARD}; enum class ECompartmentType{TURRET,CARGO}; templatestruct Array:std::vector{using std::vector::vector;using std::vector::operator=;int Count(){return this->size();}}; @@ -97,7 +104,9 @@ int main(){ } ERR_ConvoyRules::capture=true;assert(!ERR_EnemyGunnerSkill::OriginalGunner(&mode,&group,&actor));ERR_ConvoyRules::capture=false; // Current turret specialization must not overwrite the underlying requested skill. + originalGunnerCalls=0; SCR_AICombatComponent combat;combat.owner=&actor;combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::CYLON); + assert(originalGunnerCalls==0); combat.ResetAISkill();assert(combat.skill==EAISkill::CYLON); combat.SetAISkill(static_cast(90));assert(combat.skill==EAISkill::CYLON); actor.vehicle=nullptr;slot.occupant=nullptr;combat.ERR_RefreshEnemySkill();assert(combat.skill==EAISkill::CYLON); @@ -114,6 +123,18 @@ int main(){ hq.task=nullptr;combat.SetAISkill(EAISkill::REGULAR);combat.ERR_BindEnemySkill(&mode,&group);assert(combat.skill==EAISkill::CYLON); combat.SetAISkill(static_cast(90));assert(combat.skill==EAISkill::CYLON); combat.ERR_ReleaseEnemySkill(&mode);assert(combat.skill==EAISkill::REGULAR); + assert(originalGunnerCalls==0); // Both refresh and setter skip irrelevant identity scans. + // A future lower floor still queries real turret identity and applies its profile. + useRequestedFloor=true;hq.task=&task;slot.occupant=&actor;actor.vehicle=&vehicle; + SCR_AICombatComponent lower;lower.owner=&actor; + lower.ERR_BindEnemySkill(&mode,&group);assert(lower.skill==EAISkill::CYLON&&originalGunnerCalls==1); + lower.SetAISkill(EAISkill::ROOKIE);assert(lower.skill==EAISkill::CYLON&&originalGunnerCalls==2); + slot.occupant=nullptr;lower.ERR_RefreshEnemySkill();assert(lower.skill==EAISkill::ROOKIE&&originalGunnerCalls==3); + lower.SetAISkill(EAISkill::REGULAR);assert(lower.skill==EAISkill::REGULAR&&originalGunnerCalls==4); + lower.SetAISkill(EAISkill::CYLON);lower.ERR_RefreshEnemySkill();assert(lower.skill==EAISkill::CYLON&&originalGunnerCalls==4); + ERR_ConvoyRules::capture=true;int guardedWrites=lower.writes;lower.ERR_RefreshEnemySkill(); + assert(lower.writes==guardedWrites&&originalGunnerCalls==4);ERR_ConvoyRules::capture=false; + lower.ERR_ReleaseEnemySkill(&mode);assert(lower.skill==EAISkill::REGULAR); } ''') -print('PASS: actual original turret identity, native/canonical guards, scoped maximum-skill profile, reset, dismount/reboard, requested-skill preservation and restoration') +print('PASS: actual original turret identity and ownership guards; zero irrelevant query calls at maximum floor; lower-floor query/profile fallback; reset, dismount/reboard and restoration') diff --git a/tests/test_enemy_radio.py b/tests/test_enemy_radio.py index ac264b96..0eb08ec8 100644 --- a/tests/test_enemy_radio.py +++ b/tests/test_enemy_radio.py @@ -31,7 +31,7 @@ for cls in ('ERR_GameMode','ERR_EnemyRadio','ERR_EnemyEquipmentGroup','ERR_Enemy 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('Mines.ReadyForResupply','Mines->ReadyForResupply').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','ERR_EquipmentLog'):coordinator=coordinator.replace(cls+'.',cls+'::') +for cls in ('Replication','ERR_GameMode','ERR_EnemyEquipmentRules','ERR_EquipmentLog','ERR_Actors'):coordinator=coordinator.replace(cls+'.',cls+'::') coordinator=coordinator.replace('string.Format', 'Format') coordinator=coordinator.replace('void ERR_EnemyEquipment(', 'ERR_EnemyEquipment(') coordinator=coordinator.replace('GetGame().GetWorld().GetWorldTime()','worldTime').replace('foreach (','for (').replace('null','nullptr') @@ -70,6 +70,7 @@ struct SCR_Global{inline static bool editor=false;static bool IsEditMode(){retur struct Math{static int Max(int a,int b){return std::max(a,b);}}; struct ERR_HQ{static ERR_HQ* Get(){return nullptr;} ERR_HQ* Hunters(){return this;} int VehicleRadioCapability(IEntity){return 0;} inline static Arrayevents;static void Event(string,string message){events.Insert(message);}}; struct ERR_EnemyEquipmentRules{static bool Owned(ERR_GameMode*m,Group*g,IEntity a){return m&&g&&a&&a->eligible&&a->group==g;}static bool Eligible(ERR_GameMode*m,Group*g,IEntity a){return Owned(m,g,a);}}; +struct ERR_Actors{static bool CanFight(IEntity a){return a!=nullptr;}}; struct Inventory{Arrayitems;Arrayrequests;bool basicFits=true,manpackFits=true,accept=true;int callback=0;ScriptedInventoryOperationCallback*last=nullptr; void GetItems(Array&out){out=items;} int Kind(ResourceName p){return p.find("R107M")!=string::npos?2:1;} diff --git a/tests/test_hq_body_observer.py b/tests/test_hq_body_observer.py index 5e10cde4..963dc422 100644 --- a/tests/test_hq_body_observer.py +++ b/tests/test_hq_body_observer.py @@ -2,6 +2,8 @@ from policy_harness import ROOT, run from pathlib import Path import re, os +expected_empty=int(os.environ.get('ERR_EXPECT_EMPTY_LOOKUPS','0')) +expected_far=int(os.environ.get('ERR_EXPECT_FAR_TRANSFORMS','0')) path=Path(os.environ.get('ERR_BODY_SOURCE', str(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQBodyObserver.c'))) s=path.read_text() assert 'GroupLookup' not in s and 'Spawn(' not in s and 'Accept(' not in s and 'm_Contacts' not in s @@ -31,19 +33,21 @@ code=re.sub(r'\b(bool|int|float) (\w+);',r'\1 \2=0;',code) code=re.sub(r'\b(IEntity|SCR_AIGroup) (\w+);',r'\1 \2=nullptr;',code) code=code.replace('ERR_HQBodyScan* state;','ERR_HQBodyScan* state=nullptr;').replace('ERR_HQBodyScan* selected;','ERR_HQBodyScan* selected=nullptr;') code=code.replace('\n}\n','\n};\n').replace('class ', 'struct ') -run(r''' +run(f"#define EXPECT_EMPTY {expected_empty}\n#define EXPECT_FAR {expected_far}\n"+r''' #include #include #include #include #include #include +#include using string=std::string; +int normalizations=0; struct vector {float x=0,y=0,z=0;float& operator[](int i){return i==0?x:i==1?y:z;} vector operator-(vector v)const{return {x-v.x,y-v.y,z-v.z};} vector operator+(vector v)const{return {x+v.x,y+v.y,z+v.z};} float Length()const{return std::sqrt(x*x+y*y+z*z);} - vector Normalized()const{float n=Length();return {x/n,y/n,z/n};} + vector Normalized()const{normalizations++;float n=Length();return {x/n,y/n,z/n};} static float Dot(vector a,vector b){return a.x*b.x+a.y*b.y+a.z*b.z;}}; template struct Array:std::vector{using std::vector::vector; int Count(){return this->size();}bool IsEmpty(){return this->empty();} @@ -51,8 +55,8 @@ template struct Array:std::vector{using std::vector::vector; void Remove(int n){this->erase(this->begin()+n);} bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}}; struct ECharacterLifeState{static constexpr int DEAD=1;}; -struct Entity {vector position,forward{0,0,1};bool dead=false,awake=true,inVehicle=false,character=true;int reads=0; - vector GetOrigin(){reads++;return position;}void GetWorldTransform(vector v[4]){v[2]=forward;} +struct Entity {vector position,forward{0,0,1};bool dead=false,awake=true,inVehicle=false,character=true;int reads=0,transformReads=0; + vector GetOrigin(){reads++;return position;}void GetWorldTransform(vector v[4]){transformReads++;v[2]=forward;} Entity* GetCharacterController(){return character?this:nullptr;} int GetLifeState(){return dead?1:0;}}; using IEntity=Entity*;Entity* CastCharacter(Entity* p){return p&&p->character?p:nullptr;} @@ -61,7 +65,7 @@ struct Group {Array agents;int faction=1,players=0; int GetFaction(){return faction;}int GetPlayerCount(bool){return players;} void GetAgents(Array& out){out=agents;}};using SCR_AIGroup=Group*; struct ForceRecord {bool Physical=true,Eliminated=false;};using ERR_ForceRecord=ForceRecord*; -struct Ledger {ForceRecord force;ERR_ForceRecord FindGroup(Group*){return &force;}}; +struct Ledger {ForceRecord force;int finds=0;ERR_ForceRecord FindGroup(Group*){finds++;return &force;}}; struct GameMode {Ledger ledger;static GameMode* current; static GameMode* Get(){return current;}int EnemyFaction(){return 1;}Ledger* Forces(){return &ledger;}}; GameMode* GameMode::current=nullptr; @@ -156,6 +160,39 @@ int main(){ service.m_Groups[0]->LastAsked=0;service.m_Groups[1]->NextScan=1; assert(!service.Observe(&contenders[1],1000,report)); service.Observe(&contenders[1],6000,report);assert(service.m_Groups[1]->NextScan==21000); + // Independently count the empty-body and impossible-distance paths in actual source. + service.Stop();Entity healthy;Agent healthyAgent;healthyAgent.actor=&healthy;Group healthyGroup;healthyGroup.agents={&healthyAgent}; + auto* healthyRoster=Roster(&healthyGroup);service.RegisterGroup(&healthyGroup,healthyRoster); + mode.ledger.finds=0;int beforeEmptyTraces=game.world.traces; + for(int i=0;i<100;i++){assert(!service.Observe(&healthyGroup,0,report));assert(!report);} + assert(service.m_Now==0&&service.m_NextBudget==5000&&service.m_Traces==12); // budget still refreshes + assert(service.m_Groups[0]->LastAsked==-1&&service.m_Groups[0]->NextScan==0); + assert(game.world.traces==beforeEmptyTraces&&mode.ledger.finds==EXPECT_EMPTY); + std::cout<<"BODY_WORK empty_calls=100 ledger_lookups="<LastAsked==-1);mode.ledger.force.Physical=true; + healthyGroup.players=1;assert(!service.Observe(&healthyGroup,5000,report));healthyGroup.players=0; + healthyGroup.faction=2;assert(!service.Observe(&healthyGroup,5000,report));healthyGroup.faction=1; + service.Stop();service.m_Traces=12;observer.position={0,0,0};observer.forward={0,0,1};observer.awake=true;observer.inVehicle=false; + Entity remote;remote.dead=true;remote.position={0,0,1000};observer.transformReads=0;normalizations=0; + int beforeFarTraces=game.world.traces; + for(int i=0;i<128;i++)assert(!service.Visible(&observer,&remote,point)); + assert(observer.transformReads==EXPECT_FAR&&normalizations==EXPECT_FAR*2); + assert(game.world.traces==beforeFarTraces&&service.m_Traces==12); + std::cout<<"BODY_WORK far_pairs=128 transforms="<storage;') +hq=hq.replace('array items = {};','std::vector items;').replace('inventory.GetItems','inventory->GetItems').replace('ERR_HQ hq = ERR_HQ.Get();','HQ* hq = HQ::current;').replace('hq.Hunters()','hq->Hunters()').replace('Hunters().','Hunters()->') +hq=hq.replace('foreach (IEntity item : items)','for (Entity* item : items)').replace('item.FindComponent(BaseRadioComponent)','HasBaseRadio(item)').replace('SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));','Radio* gadget = GetRadio(item);').replace('gadget.','gadget->').replace('Math.Max','std::max').replace('int result;','int result=0;') +vehicle=body(ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c','int VehicleRadioCapability(') +vehicle=vehicle.replace('IEntity actor','Entity* actor').replace('foreach (ERR_HunterVehicle unit : m_Hunters)','for (Unit* unit : m_Hunters)').replace('InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(unit.Entity.FindComponent(InventoryStorageManagerComponent));','Storage* storage = unit->Entity->storage;') +vehicle=vehicle.replace('array items = {};','std::vector items;').replace('foreach (IEntity item : items)','for (Entity* item : items)').replace('item.FindComponent(BaseRadioComponent)','HasBaseRadio(item)').replace('SCR_RadioComponent radio = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));','Radio* radio = GetRadio(item);') +for obj in ('m_Mode','unit','storage','radio'):vehicle=vehicle.replace(obj+'.',obj+'->') +vehicle=vehicle.replace('ERR_Actors.Alive','Alive').replace('CompartmentAccessComponent.GetVehicleIn','VehicleIn').replace('ERR_ConvoyRules.Captured','Captured') +run(r''' +#include +#include +#include +#include +struct Radio{bool pack=false;bool ERR_IsHQManpack(){return pack;}}; +struct Storage; +struct Entity{bool alive=true,fit=true,baseRadio=false,captured=false;Entity* vehicle=nullptr;Storage* storage=nullptr;Radio* radio=nullptr;int componentQueries=0;}; +struct Storage{int enumerations=0;std::vectoritems;void GetItems(std::vector&out){enumerations++;out=items;}}; +struct Members:std::vector{bool Contains(Entity*p){return std::find(begin(),end(),p)!=end();}}; +struct Mode{bool active=true;bool Active(){return active;}}; +struct Unit{struct Entity* Entity=nullptr;void* Group=nullptr;bool Released=false,valid=true;struct Members Members;bool CrewValid(Mode*){return valid;}}; +bool Alive(Entity*e){return e&&e->alive;}bool CanFight(Entity*e){return Alive(e)&&e->fit;} +Entity* VehicleIn(Entity*e){return e->vehicle;}bool Captured(Entity*e,void*){return e->captured;} +bool HasBaseRadio(Entity*e){e->componentQueries++;return e->baseRadio;}Radio* GetRadio(Entity*e){e->componentQueries++;return e->radio;} +struct Service{Mode* m_Mode=nullptr;bool m_Stopped=false;std::vectorm_Hunters; +'''+vehicle+r''' +}; +struct HQ{static inline HQ*current=nullptr;Service* service=nullptr;Service*Hunters(){return service;}}; +struct Query{ +'''+hq+r''' +}; +int main(){ + HQ h;Service service;Mode mode;HQ::current=&h;h.service=&service;service.m_Mode=&mode; + Entity actor,car,manpack,basic,junk;Storage carried,mounted;Radio packRadio,basicRadio;packRadio.pack=true; + actor.storage=&carried;car.storage=&mounted;manpack.baseRadio=true;manpack.radio=&packRadio;basic.baseRadio=true;basic.radio=&basicRadio; + Unit unit;unit.Entity=&car;unit.Members.push_back(&actor);service.m_Hunters={&unit};actor.vehicle=&car;mounted.items={&manpack}; + assert(Query::Capability(nullptr)==0);actor.fit=false;assert(Query::Capability(&actor)==0&&carried.enumerations==0&&mounted.enumerations==0);actor.fit=true; + actor.alive=false;assert(Query::Capability(&actor)==0);actor.alive=true; + actor.storage=nullptr;assert(Query::Capability(&actor)==0&&mounted.enumerations==0);actor.storage=&carried; + // The real vehicle helper still validates ownership and physical inventory. + //128 carried objects must not be enumerated or scanned when it returns max2. + carried.items.assign(128,&junk); + assert(Query::Capability(&actor)==2); + std::cout<<"OPCOUNTS actor_inventory="<').replace('faction.', 'faction->') +friendly = friendly.replace('EnemyFaction().', 'EnemyFaction()->') +candidate = candidate.replace('ChimeraCharacter.Cast', 'ChimeraCharacter::Cast') +run(r''' +#include +#include +#include +#include +struct Actor {bool friendly=false;bool character=true;}; +using IEntity=Actor*; +int casts=0; +struct ChimeraCharacter {static IEntity Cast(IEntity actor) { + ++casts;return actor && actor->character ? actor : nullptr; +}}; +struct ERR_Actors { + static bool CanFight(IEntity actor) {return actor && actor->friendly;} + static bool Player(IEntity) {return false;} +}; +struct CompartmentAccessComponent {static bool GetVehicleIn(IEntity) {return false;}}; +struct FactionState {bool IsFactionEnemy(FactionState*) {return false;}}; +using Faction=FactionState*; +FactionState faction; +struct SCR_FactionManager {static Faction SGetFaction(IEntity) {return &faction;}}; +struct Mode {Faction EnemyFaction() {return &faction;}}; +struct Entities:std::vector { + mutable int contains=0; + int Count() const {return size();} + bool Contains(IEntity actor) const { + ++contains; + return std::find(begin(),end(),actor)!=end(); + } + void Insert(IEntity actor) {push_back(actor);} +}; +struct Collector { + Entities m_Near; + int calls=0,castCalls=0; + Mode mode;Mode* m_Mode=&mode; +''' + friendly + '\n' + candidate + '\n' + baseline + r''' +}; +bool Step(Collector& before,Collector& after,IEntity actor) { + int stamp=casts;bool b=before.Baseline(actor);before.castCalls+=casts-stamp; + stamp=casts;bool a=after.Collect(actor);after.castCalls+=casts-stamp; + assert(b==a); + assert(before.m_Near==after.m_Near); // identity, order and multiplicity + return a; +} +int main() { + { + Collector b,a;Actor actor{true}; + for(int i=0;i<32;i++) assert(Step(b,a,&actor)); + assert(a.m_Near.size()==1 && b.calls==32 && a.calls==1); + // Already accepted actors remain in this pass even if later classified false. + actor.friendly=false;assert(Step(b,a,&actor)); + assert(b.calls==33 && a.calls==1); + std::cout<<"duplicate_accepted baseline_calls=33 candidate_calls=1 members=1\n"; + } + { + Collector b,a;Actor actor{false}; + assert(Step(b,a,&actor));assert(Step(b,a,&actor)); + assert(a.m_Near.empty() && b.calls==2 && a.calls==2); + actor.friendly=true;assert(Step(b,a,&actor)); + assert(Step(b,a,&actor)); + assert(a.m_Near.size()==1 && b.calls==4 && a.calls==3); + std::cout<<"rejected_then_friendly baseline_calls=4 candidate_calls=3 members=1\n"; + } + { + Collector b,a; + assert(Step(b,a,nullptr));assert(Step(b,a,nullptr)); + assert(a.m_Near.empty() && b.calls==2 && a.calls==0); + assert(b.castCalls==2 && a.castCalls==2); + assert(b.m_Near.contains==0 && a.m_Near.contains==0); + std::cout<<"null baseline_calls=2 candidate_calls=0 casts_each=2 contains_each=0 members=0\n"; + } + { + Collector b,a;Actor actors[129]; + for(int i=0;i<128;i++) {actors[i].friendly=true;assert(Step(b,a,&actors[i]));} + assert(a.m_Near.size()==128 && b.calls==128 && a.calls==128); + actors[128].friendly=true; + assert(!Step(b,a,&actors[128])); // new eligible actor + assert(!Step(b,a,&actors[0])); // accepted duplicate + assert(!Step(b,a,nullptr)); // rejected/null callback + assert(b.calls==128 && a.calls==128); + std::cout<<"capacity baseline_calls=128 candidate_calls=128 blocked_callbacks=3 members=128\n"; + } + { + Collector b,a;Actor actors[3]{{true},{false},{true}}; + for(IEntity actor:{&actors[2],&actors[0],&actors[2],&actors[1],IEntity(nullptr),&actors[0]}) + assert(Step(b,a,actor)); + assert(a.m_Near[0]==&actors[2] && a.m_Near[1]==&actors[0]); + assert(b.calls==6 && a.calls==3); + // A fresh tick must reconsider every actor; no cache survives m_Near.Clear(). + b.m_Near.clear();a.m_Near.clear(); + assert(Step(b,a,&actors[0]));assert(Step(b,a,&actors[2])); + assert(b.calls==8 && a.calls==5); + std::cout<<"mixed_and_fresh_pass baseline_calls=8 candidate_calls=5 members=2\n"; + } + { + Collector b,a;Actor accepted{true},prop{true,false}; + assert(Step(b,a,&accepted)); + int bc=b.castCalls,ac=a.castCalls,bl=b.m_Near.contains,al=a.m_Near.contains; + for(int i=0;i<1000;i++) assert(Step(b,a,&prop)); + assert(b.castCalls-bc==1000 && a.castCalls-ac==1000); + assert(b.m_Near.contains==bl && a.m_Near.contains==al); + assert(b.calls==1001 && a.calls==1); + std::cout<<"props callbacks=1000 casts_each=1000 contains_each=0 baseline_classifications=1000 candidate_classifications=0\n"; + } + { + Collector b,a;Actor accepted{true}; + assert(Step(b,a,&accepted)); + assert(b.castCalls==1 && a.castCalls==2); // Explicit first-seen character overhead. + for(int i=0;i<31;i++) assert(Step(b,a,&accepted)); + assert(b.castCalls==32 && a.castCalls==33); + assert(b.calls==32 && a.calls==1); + std::cout<<"character_cast_tradeoff callbacks=32 baseline_casts=32 candidate_casts=33 baseline_classifications=32 candidate_classifications=1\n"; + } + { + Collector b,a;Actor accepted[127],rejected{false}; + for(auto& actor:accepted) {actor.friendly=true;assert(Step(b,a,&actor));} + int bl=b.m_Near.contains,al=a.m_Near.contains; + for(int i=0;i<100;i++) assert(Step(b,a,&rejected)); + assert(b.m_Near.contains==bl && a.m_Near.contains-al==100); + assert(a.m_Near.size()==127 && b.calls==227 && a.calls==227); + std::cout<<"rejected_character_tradeoff callbacks=100 accepted_members=127 baseline_contains=0 candidate_contains=100 classifications_each=100\n"; + } +} +''') +print('PASS: actual Collect output/order/capacity equivalence and classification operation counts') diff --git a/tools/check.py b/tools/check.py index 54597ff7..d1358d41 100644 --- a/tools/check.py +++ b/tools/check.py @@ -21,6 +21,8 @@ def check(): subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_round_statistics.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_ai_driver_horn.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_hq_radio_capability.py')],check=True) + subprocess.run([sys.executable,str(ROOT/'tests/test_known_mine_collection.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry_credentials.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry_server_name.py')],check=True)