--- /dev/null +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyMineStock.c @@ -0,0 +1,108 @@ +// Optional initial stock only. Spawn callbacks supply exact identities; these +// never enter the magazine readiness barrier or acquire a separate spent ledger. +class ERR_ConvoyMineCargo : ScriptedInventoryOperationCallback +{ + bool Finished; + bool Succeeded; + IEntity Item; + protected void Finish(bool success) + { + if (Finished) return; + Finished = true; Succeeded = success; + if (!success) return; + // GetItem is supported for Spawn, unlike the later Move/Swap operations. + RplComponent replicated = RplComponent.Cast(Replication.FindItem(GetItem())); + if (replicated) Item = replicated.GetEntity(); + } + void Refused() { Finish(false); } + protected override void OnComplete() { Finish(true); } + protected override void OnAllComplete() { Finish(true); } + protected override void OnFailed() { Finish(false); } + protected override void OnAllFailed() { Finish(false); } +} + +class ERR_ConvoyMineStock +{ + protected ERR_GameMode m_Mode; + protected ERR_SupplyConvoyTask m_Task; + protected ERR_ConvoyVehicle m_Original; + protected IEntity m_Truck; + protected InventoryStorageManagerComponent m_Inventory; + protected ref array m_Cargo = {}; + protected ref array m_Items = {}; + protected ref array m_Before = {}; + protected float m_Deadline = -1; + protected float m_LastTick = -1; + protected bool m_Attempted; + protected bool m_Closed; + void ERR_ConvoyMineStock(ERR_GameMode mode, ERR_SupplyConvoyTask task) + { + m_Mode = mode; m_Task = task; + if (!task || task.Vehicles.Count() != 6) return; + m_Original = task.Vehicles[1]; + if (!m_Original) return; + m_Truck = m_Original.Entity; + if (m_Truck) m_Inventory = InventoryStorageManagerComponent.Cast(m_Truck.FindComponent(InventoryStorageManagerComponent)); + } + // These are initial identities, not a live stock count or transfer authority. + int Count() { return m_Items.Count(); } + IEntity At(int index) + { + if (index < 0 || index >= m_Items.Count()) return null; + return m_Items[index]; + } + bool Complete() { return m_Closed; } + void Stop() { m_Closed = true; m_Before.Clear(); } + protected bool Current(bool ownerValid) + { + if (!ownerValid || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return false; + if (!m_Task || m_Task.Stopped || !m_Task.Ready || m_Task.Vehicles.Count() != 6 || m_Task.Vehicles[1] != m_Original) return false; + if (!m_Original || m_Original.Entity != m_Truck || !m_Truck || !m_Inventory || m_Inventory.GetOwner() != m_Truck) return false; + return m_Truck.FindComponent(InventoryStorageManagerComponent) == m_Inventory; + } + protected bool ReadyItem(IEntity item, array items) + { + if (!item || m_Before.Contains(item) || !items.Contains(item) || !item.GetPrefabData() + || item.GetPrefabData().GetPrefabName() != ERR_EnemyLoadoutCatalog.MINE) return false; + InventoryItemComponent carried = InventoryItemComponent.Cast(item.FindComponent(InventoryItemComponent)); + if (!carried || !carried.GetParentSlot() || carried.GetParentSlot().GetAttachedEntity() != item + || !ERR_InfantryAmmoTransfer.StoredUnder(item, m_Truck)) return false; + if (!ERR_Actors.Alive(item) || !item.FindComponent(SCR_AntiPersonnelMineCollisionHandlerComponent)) return false; + SCR_PressureTriggerComponent trigger = SCR_PressureTriggerComponent.Cast(item.FindComponent(SCR_PressureTriggerComponent)); + return trigger && !trigger.IsActivated() && !trigger.IsActivatedByDefault(); + } + void Tick(float now, bool ownerValid) + { + if (m_Closed) return; + if (!Current(ownerValid) || !ERR_ForceRules.Finite(now) || now < 0 || now < m_LastTick) { Stop(); return; } + m_LastTick = now; + if (m_Deadline < 0) m_Deadline = now + 90000; + if (now >= m_Deadline) { Stop(); return; } + if (!m_Attempted) + { + if (!m_Task.CargoIssued || !m_Task.MagazinesIssued) return; + int primary = m_Task.Cargo.Count(); + if (primary > 128) { Stop(); return; } + foreach (ERR_ConvoyCargoCallback operation : m_Task.Cargo) + if (!operation || !operation.Finished) return; + // All primary insertions have settled before measuring physical headroom. + // A refused optional request still spends one of the two initial attempts. + m_Attempted = true; + ERR_Inventory.Items(m_Inventory, m_Before); + if (m_Before.Count() > 128 || !ERR_EnemyLoadoutCatalog.Available(ERR_EnemyLoadoutCatalog.MINE)) { Stop(); return; } + for (int index = 0; index < 2 && index < 128 - m_Before.Count() && index < 128 - primary; index++) + { + ERR_ConvoyMineCargo load = new ERR_ConvoyMineCargo(); m_Cargo.Insert(load); + if (!m_Inventory.TrySpawnPrefabToStorage(ERR_EnemyLoadoutCatalog.MINE, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load)) load.Refused(); + } + } + foreach (ERR_ConvoyMineCargo pending : m_Cargo) if (!pending.Finished) return; + array items = {}; ERR_Inventory.Items(m_Inventory, items); + // One detached freeze: missing, captured or unsupported native identities + // are omitted, never adopted later. Failed/late operations are never retried. + if (items.Count() <= 128) + foreach (ERR_ConvoyMineCargo completed : m_Cargo) + if (completed.Succeeded && ReadyItem(completed.Item, items) && !m_Items.Contains(completed.Item)) m_Items.Insert(completed.Item); + Stop(); + } +} --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c @@ -144,6 +144,7 @@ ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task); if (!supply || supply.CargoIssued || supply.Stopped) return; supply.CargoIssued = true; + supply.MineStock = new ERR_ConvoyMineStock(m_Mode, supply); InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(supply.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent)); if (!inventory) { supply.MagazinesIssued = true; return; } ERR_Inventory.Items(inventory, supply.CargoBefore); @@ -266,6 +267,7 @@ task.Ready = true; IssueCargo(task); } IssueMagazines(task, now); + if (task.MineStock) task.MineStock.Tick(now, MountedStockOwner(task, 1, ERR_HQ.Cast(m_Mode.Command()))); foreach (ERR_ConvoyVehicle checked : task.Vehicles) if (!checked.Released && !checked.CrewValid(m_Mode)) checked.Release(m_Mode); if (task.Tactics && task.Tactics.Tick(now)) continue; --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c @@ -176,10 +176,12 @@ bool MagazinesIssued; ref array MountedStock = {}; ref array CargoBefore = {}; + ref ERR_ConvoyMineStock MineStock; override void Stop(ERR_GameMode mode, string reason) { if (Stopped) return; if (Tactics) Tactics.Stop(); + if (MineStock) MineStock.Stop(); foreach (ERR_SupplyTeam team : Teams) team.Release(mode); super.Stop(mode, reason); } --- /dev/null +++ b/tests/test_convoy_mine_stock.py @@ -0,0 +1,117 @@ +"""Execute optional finite PMN4 stock and exact spawn callback methods; no engine proof.""" +from pathlib import Path +import os,re +from policy_harness import ROOT,body,run +BASE=Path(os.environ.get('ERR_MINE_STOCK_ROOT',ROOT));D=BASE/'addons/core/Scripts/Game/ERR/10_AI' +s=(D/'ERR_ConvoyMineStock.c').read_text() +assert s.count('TrySpawnPrefabToStorage(')==1 +for forbidden in ('TryMove','SetOrigin','SetAmmoCount','DeleteEntity','ActivateTrigger','m_Spent','ERR_FiniteMagazineReservation'): + assert forbidden not in s,forbidden +assert 'm_Task.Cargo.Insert' not in s +code=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('override ','').replace('ref ','').replace('class ','struct ').replace(' : ScriptedInventoryOperationCallback',' : ScriptedInventoryOperationCallback') +code=code.replace('void ERR_ConvoyMineStock(', 'ERR_ConvoyMineStock(').replace('foreach (','for (').replace('array<','Array<').replace('null','nullptr') +for old,new in { + 'RplComponent.Cast(Replication.FindItem(GetItem()))':'Replication::FindItem(GetItem())', + 'InventoryStorageManagerComponent.Cast(m_Truck.FindComponent(InventoryStorageManagerComponent))':'m_Truck->inventory', + 'm_Truck.FindComponent(InventoryStorageManagerComponent)':'m_Truck->inventory', + 'InventoryItemComponent.Cast(item.FindComponent(InventoryItemComponent))':'item->carried', + 'item.FindComponent(SCR_AntiPersonnelMineCollisionHandlerComponent)':'item->collision', + 'SCR_PressureTriggerComponent.Cast(item.FindComponent(SCR_PressureTriggerComponent))':'item->trigger', +}.items():code=code.replace(old,new) +for typ in ('ERR_GameMode','ERR_SupplyConvoyTask','ERR_ConvoyVehicle','InventoryStorageManagerComponent','RplComponent','InventoryItemComponent','SCR_PressureTriggerComponent','ERR_ConvoyMineCargo','ERR_ConvoyCargoCallback'): + code=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',code) +code=code.replace('Array','Array') +code=code.replace('Array items)', 'Array& items)') +for ns in ('ERR_GameMode','ERR_Actors','Replication','ERR_Inventory','ERR_InfantryAmmoTransfer','ERR_ForceRules','ERR_EnemyLoadoutCatalog','EStoragePurpose'): + code=code.replace(ns+'.',ns+'::') +for var in ('m_Mode','m_Task','m_Original','m_Truck','m_Inventory','task','replicated','item','carried','trigger','operation','load','pending','completed'): + code=re.sub(r'\b'+var+r'\.',var+'->',code) +for call in ('GetPrefabData()','GetParentSlot()'):code=code.replace(call+'.',call+'->') +code=re.sub(r'\b(bool|IEntity|ERR_GameMode\*|ERR_SupplyConvoyTask\*|ERR_ConvoyVehicle\*|InventoryStorageManagerComponent\*) (\w+);',r'\1 \2{};',code) +code=code.replace('\n}\n','\n};\n') +run(r''' +#include +#include +#include +#include +#include +#include +#include +using string=std::string; +templatestruct Array:std::vector{using std::vector::vector;int Count(){return this->size();}void Insert(T v){this->push_back(v);}void Clear(){this->clear();}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}}; +struct Entity;using IEntity=Entity*;struct InventoryStorageManagerComponent; +struct RplComponent{IEntity entity=nullptr;IEntity GetEntity(){return entity;}}; +struct Replication{inline static bool server=true;static bool IsServer(){return server;}static RplComponent*FindItem(RplComponent* id){return id;}}; +struct ScriptedInventoryOperationCallback{RplComponent*item=nullptr;virtual ~ScriptedInventoryOperationCallback()=default;RplComponent*GetItem(){return item;}virtual void OnComplete(){}virtual void OnAllComplete(){}virtual void OnFailed(){}virtual void OnAllFailed(){}}; +struct Prefab{string name="PMN4";string GetPrefabName(){return name;}}; +struct SCR_PressureTriggerComponent{bool active=false,byDefault=false;bool IsActivated(){return active;}bool IsActivatedByDefault(){return byDefault;}}; +struct Slot{IEntity attached=nullptr;IEntity GetAttachedEntity(){return attached;}}; +struct InventoryItemComponent{Slot*slot=nullptr;Slot*GetParentSlot(){return slot;}}; +struct Entity{InventoryStorageManagerComponent*inventory=nullptr;Prefab*prefab=nullptr;InventoryItemComponent*carried=nullptr;SCR_PressureTriggerComponent*trigger=nullptr;IEntity stored=nullptr;bool alive=true,collision=true;Prefab*GetPrefabData(){return prefab;}}; +struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;bool active=true;static ERR_GameMode*Get(){return current;}bool Active(){return active;}}; +struct ERR_ConvoyCargoCallback{bool Finished=true;}; +struct ERR_ConvoyVehicle{IEntity Entity_=nullptr;IEntity&Entity=Entity_;}; +struct ERR_SupplyConvoyTask{bool Stopped=false,Ready=true,CargoIssued=true,MagazinesIssued=true;ArrayVehicles;ArrayCargo;}; +struct ERR_Actors{static bool Alive(IEntity e){return e&&e->alive;}}; +struct ERR_ForceRules{static bool Finite(float n){return std::isfinite(n);}}; +struct ERR_EnemyLoadoutCatalog{inline static string MINE="PMN4";inline static bool available=true;static bool Available(string){return available;}}; +struct ERR_InfantryAmmoTransfer{static bool StoredUnder(IEntity e,IEntity root){return e&&root&&e->stored==root;}}; +struct EStoragePurpose{static const int PURPOSE_DEPOSIT=1;}; +struct InventoryStorageManagerComponent{IEntity owner=nullptr;Arrayitems;Arraycallbacks;int calls=0;bool accept=true;std::functioneffect; + IEntity GetOwner(){return owner;}bool TrySpawnPrefabToStorage(string prefab,void*,int slot,int purpose,ScriptedInventoryOperationCallback*cb){assert(prefab=="PMN4"&&slot==-1&&purpose==1);int index=calls++;callbacks.Insert(cb);if(effect)effect(index,cb);return accept;}}; +struct ERR_Inventory{static void Items(InventoryStorageManagerComponent*i,Array&a){a=i->items;}}; +'''+code+r''' +struct Fixture{ERR_GameMode mode;ERR_SupplyConvoyTask task;ERR_ConvoyVehicle truck;Entity root,foreign;InventoryStorageManagerComponent inventory;ERR_ConvoyCargoCallback primary; + Entity mines[2];Prefab prefabs[2];InventoryItemComponent carried[2];Slot slots[2];SCR_PressureTriggerComponent triggers[2];RplComponent replicated[2]; + Fixture(){ERR_GameMode::current=&mode;ERR_EnemyLoadoutCatalog::available=true;Replication::server=true;truck.Entity=&root;root.inventory=&inventory;inventory.owner=&root; + task.Vehicles.resize(6,nullptr);task.Vehicles[1]=&truck;task.Cargo={&primary}; + for(int i=0;i<2;i++){mines[i].prefab=&prefabs[i];mines[i].carried=&carried[i];mines[i].trigger=&triggers[i];mines[i].stored=&root;carried[i].slot=&slots[i];slots[i].attached=&mines[i];replicated[i].entity=&mines[i];}} + ERR_ConvoyMineStock Job(){return ERR_ConvoyMineStock(&mode,&task);} + void Finish(int i){inventory.items.Insert(&mines[i]);inventory.callbacks[i]->item=&replicated[i];inventory.callbacks[i]->OnComplete();} +}; +int main(){ + {Fixture f;auto s=f.Job();s.Tick(0,true);assert(f.inventory.calls==2&&!s.Complete()&&s.Count()==0&&f.task.Cargo.Count()==1); + f.Finish(0);s.Tick(1000,true);assert(!s.Complete()&&s.Count()==0);f.Finish(1);s.Tick(2000,true); + assert(s.Complete()&&s.Count()==2&&s.At(0)==&f.mines[0]&&s.At(1)==&f.mines[1]&&!s.At(-1)&&!s.At(2)); + f.inventory.items.clear();s.Tick(3000,true);assert(s.Count()==2&&f.inventory.calls==2);} + // Primary unknown/missing callbacks never become optional-magazine blockers. + for(int k=0;k<3;k++){Fixture f;if(k==0)f.primary.Finished=false;if(k==1)f.task.Cargo[0]=nullptr;if(k==2)f.task.MagazinesIssued=false; + auto s=f.Job();s.Tick(0,true);assert(f.inventory.calls==0&&!s.Complete());s.Tick(90000,true);assert(s.Complete()); + f.primary.Finished=true;f.task.Cargo[0]=&f.primary;f.task.MagazinesIssued=true;s.Tick(90001,true);assert(f.inventory.calls==0);} + // Physical item and combined callback headroom independently limit the extras. + for(int k=0;k<4;k++){Fixture f;if(k<2)f.inventory.items.resize(127+k,&f.foreign);else f.task.Cargo.resize(125+k,&f.primary); + auto s=f.Job();s.Tick(0,true);assert(f.inventory.calls==(k%2==0?1:0));} + {Fixture f;f.inventory.items.resize(129,&f.foreign);auto s=f.Job();s.Tick(0,true);assert(s.Complete()&&f.inventory.calls==0);} + {Fixture f;f.task.Cargo.resize(129,&f.primary);auto s=f.Job();s.Tick(0,true);assert(s.Complete()&&f.inventory.calls==0);} + // Native refusal consumes an attempt; inline completion beats false return. + {Fixture f;f.inventory.accept=false;auto s=f.Job();s.Tick(0,true);assert(s.Complete()&&s.Count()==0&&f.inventory.calls==2);s.Tick(1,true);assert(f.inventory.calls==2);} + {Fixture f;f.inventory.accept=false;f.inventory.effect=[&](int i,ScriptedInventoryOperationCallback*cb){f.inventory.items.Insert(&f.mines[i]);cb->item=&f.replicated[i];cb->OnComplete();}; + auto s=f.Job();s.Tick(0,true);assert(s.Complete()&&s.Count()==2&&f.inventory.calls==2);} + // Unknown, captured or lost optional callbacks cannot reopen a closed snapshot. + for(int k=0;k<6;k++){Fixture f;auto s=f.Job();s.Tick(0,true);if(k==0)s.Stop();if(k==1)s.Tick(1,false);if(k==2)s.Tick(90000,true); + if(k==3){f.truck.Entity=&f.foreign;s.Tick(1,true);}if(k==4){f.root.inventory=nullptr;s.Tick(1,true);}if(k==5){f.mode.active=false;s.Tick(1,true);} + assert(s.Complete());f.Finish(0);f.Finish(1);s.Tick(90001,true);assert(s.Count()==0&&f.inventory.calls==2&&f.task.Cargo.Count()==1);} + // Only exact callback-produced, current disarmed owned PMN4 items freeze. + for(int k=0;k<12;k++){Fixture f;auto s=f.Job();if(k==0){f.inventory.items.Insert(&f.mines[0]);}s.Tick(0,true);f.Finish(0);f.inventory.callbacks[1]->OnFailed(); + switch(k){case 1:f.prefabs[0].name="other";break;case 2:f.mines[0].prefab=nullptr;break;case 3:f.mines[0].carried=nullptr;break;case 4:f.carried[0].slot=nullptr;break; + case 5:f.slots[0].attached=&f.foreign;break;case 6:f.mines[0].stored=&f.foreign;break;case 7:f.mines[0].alive=false;break;case 8:f.mines[0].collision=false;break; + case 9:f.mines[0].trigger=nullptr;break;case 10:f.triggers[0].active=true;break;case 11:f.triggers[0].byDefault=true;break;} + s.Tick(1,true);assert(s.Complete()&&s.Count()==0);} + {Fixture f;auto s=f.Job();s.Tick(0,true);f.Finish(0);f.inventory.callbacks[1]->item=&f.replicated[0];f.inventory.callbacks[1]->OnComplete();s.Tick(1,true);assert(s.Count()==1);} + {Fixture f;auto s=f.Job();s.Tick(0,true);f.inventory.callbacks[0]->OnComplete();f.inventory.callbacks[1]->OnComplete();s.Tick(1,true);assert(s.Count()==0&&s.Complete());} + // All current-server/task/truck/manager guards fail before optional spawning. + for(int k=0;k<10;k++){Fixture f;auto s=f.Job();ERR_GameMode other;ERR_ConvoyVehicle replaced; + switch(k){case 0:Replication::server=false;break;case 1:f.mode.active=false;break;case 2:ERR_GameMode::current=&other;break;case 3:f.task.Stopped=true;break; + case 4:f.task.Ready=false;break;case 5:f.task.Vehicles[1]=&replaced;break;case 6:f.truck.Entity=&f.foreign;break;case 7:f.inventory.owner=&f.foreign;break;case 8:f.root.inventory=nullptr;break;case 9:ERR_EnemyLoadoutCatalog::available=false;break;} + s.Tick(0,true);assert(s.Complete()&&f.inventory.calls==0);} + {Fixture f;auto s=f.Job();s.Tick(std::numeric_limits::quiet_NaN(),true);assert(s.Complete()&&f.inventory.calls==0);} + {Fixture f;auto s=f.Job();s.Tick(10,true);s.Tick(9,true);assert(s.Complete());f.Finish(0);f.Finish(1);s.Tick(11,true);assert(s.Count()==0);} + {Fixture f;auto s=f.Job();s.Stop();s.Tick(0,true);assert(f.inventory.calls==0&&s.Count()==0);} +} +'''.replace(';if(', ';\n if(')) +service=(D/'ERR_SupplyConvoyService.c').read_text();task=(D/'ERR_SupplyConvoyTactics.c').read_text() +issue=body(D/'ERR_SupplyConvoyService.c','protected override void IssueCargo(') +assert issue.index('supply.CargoIssued = true;')