Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
211 lines
14 KiB
Diff
211 lines
14 KiB
Diff
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
|
|
@@ -116,6 +116,8 @@
|
|
int RouteIndex;
|
|
bool Released;
|
|
bool Registered;
|
|
+ // Latched only for an exact tracked hunter at authoritative player entry.
|
|
+ bool PlayerCaptureObserved;
|
|
ref ERR_ConvoyCombatState Combat = new ERR_ConvoyCombatState();
|
|
int SupplyToken;
|
|
bool SupplyHeld;
|
|
@@ -244,7 +246,7 @@
|
|
ERR_HQ hq;
|
|
if (mode) hq = ERR_HQ.Cast(mode.Command());
|
|
bool retained = false;
|
|
- if (Registered && hq && !ERR_ConvoyRules.Captured(Entity, Group)) retained = hq.ReleaseConvoySurvivors(Group);
|
|
+ if (Registered && !PlayerCaptureObserved && hq && !ERR_ConvoyRules.Captured(Entity, Group)) retained = hq.ReleaseConvoySurvivors(Group);
|
|
if (!retained && mode && mode.Command()) mode.Command().UnregisterGroup(Group);
|
|
Group.SetPermanentLOD(-1);
|
|
array<AIAgent> agents = {}; Group.GetAgents(agents);
|
|
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
|
|
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
|
|
@@ -30,7 +30,7 @@
|
|
{
|
|
if (Released) return;
|
|
if (Recovery) Recovery.Stop();
|
|
- if (!mode || !mode.Active() || ERR_ConvoyRules.Captured(Entity, Group)) { super.Release(mode); return; }
|
|
+ if (PlayerCaptureObserved || !mode || !mode.Active() || ERR_ConvoyRules.Captured(Entity, Group)) { Downlink = null; super.Release(mode); return; }
|
|
Released = true; Downlink = null; ClearOrder();
|
|
ERR_ForceRecord record = mode.Forces().FindGroup(Group);
|
|
if (record) record.Pinned = false;
|
|
@@ -82,6 +82,24 @@
|
|
}
|
|
}
|
|
return 0;
|
|
+ }
|
|
+ // Entry is an ownership event, not a later occupancy sample. Keep the fact
|
|
+ // even if the player exits/disconnects before the one-second hunter tick.
|
|
+ void PlayerEntered(IEntity actor, IEntity entered)
|
|
+ {
|
|
+ if (!Replication.IsServer() || m_Stopped || !m_Mode || m_Mode != ERR_GameMode.Get()) return;
|
|
+ if (!m_Mode.Active() || !actor || !entered || !m_Mode.Players()) return;
|
|
+ ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
|
|
+ if (!hq || hq.Hunters() != this || !m_Mode.Players().CurrentActor(actor)) return;
|
|
+ if (m_Hunters.Count() > 15) return;
|
|
+ foreach (ERR_HunterVehicle unit : m_Hunters)
|
|
+ {
|
|
+ // Released survivors already have independent HQ ownership. Do not revoke
|
|
+ // their infantry role just because the abandoned original vehicle is entered.
|
|
+ if (!unit || unit.Released || !unit.Entity || !ERR_ConvoyRules.PartOf(entered, unit.Entity)) continue;
|
|
+ unit.PlayerCaptureObserved = true;
|
|
+ unit.Release(m_Mode);
|
|
+ }
|
|
}
|
|
protected bool PrepareVehicleRadio(ERR_HunterVehicle unit)
|
|
{
|
|
@@ -396,3 +414,20 @@
|
|
foreach (ERR_HunterVehicle unit : m_Hunters) { unit.Downlink = null; unit.Release(m_Mode); }
|
|
}
|
|
}
|
|
+
|
|
+// Match the original hunter before native callbacks can erase current player
|
|
+// occupancy. Other vehicles and already-released crews are not adopted here.
|
|
+modded class SCR_CompartmentAccessComponent
|
|
+{
|
|
+ override protected void OnCompartmentEntered(IEntity targetEntity, BaseCompartmentManagerComponent manager, int mgrID, int slotID, bool move)
|
|
+ {
|
|
+ if (Replication.IsServer())
|
|
+ {
|
|
+ ERR_GameMode mode = ERR_GameMode.Get();
|
|
+ ERR_HQ hq;
|
|
+ if (mode) hq = ERR_HQ.Cast(mode.Command());
|
|
+ if (hq && hq.Hunters()) hq.Hunters().PlayerEntered(GetOwner(), targetEntity);
|
|
+ }
|
|
+ super.OnCompartmentEntered(targetEntity, manager, mgrID, slotID, move);
|
|
+ }
|
|
+}
|
|
--- a/tests/test_hunter_lifecycle.py
|
|
+++ b/tests/test_hunter_lifecycle.py
|
|
@@ -1,6 +1,8 @@
|
|
"""Production hunter release and objective-role policy, without native spawning."""
|
|
+import os
|
|
+from pathlib import Path
|
|
from policy_harness import ROOT, body, run
|
|
-source=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c'
|
|
+source=Path(os.environ.get('ERR_HUNTER_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c'))
|
|
s=body(source,'override void Release(ERR_GameMode mode)')
|
|
s=s.replace('override ','').replace('super.','Native::').replace('null','nullptr')
|
|
s=s.replace('ERR_GameMode mode','ERR_GameMode* mode').replace('ERR_ForceRecord record','ERR_ForceRecord* record')
|
|
@@ -21,7 +23,7 @@
|
|
struct ERR_GameMode{bool active=true;Ledger ledger;ERR_HQ hq;bool Active(){return active;}Ledger* Forces(){return &ledger;}ERR_HQ* Command(){return &hq;}};
|
|
namespace ERR_ConvoyRules{bool captured=false;bool Captured(void*,GroupType*){return captured;}}
|
|
struct RecoveryTask{int stops=0;void Stop(){stops++;}};
|
|
-struct Native{RecoveryTask* Recovery=nullptr;bool Released=false;int delegated=0,clears=0;GroupType* Group=nullptr;void* Entity=nullptr;void* Downlink=nullptr;
|
|
+struct Native{RecoveryTask* Recovery=nullptr;bool Released=false,PlayerCaptureObserved=false;int delegated=0,clears=0;GroupType* Group=nullptr;void* Entity=nullptr;void* Downlink=nullptr;
|
|
void Release(ERR_GameMode*){delegated++;Released=true;}void ClearOrder(){clears++;}
|
|
};
|
|
struct Hunter:Native{
|
|
--- a/tests/test_convoy_survivors.py
|
|
+++ b/tests/test_convoy_survivors.py
|
|
@@ -55,11 +55,11 @@
|
|
void UnregisterGroup(SCR_AIGroup*g){unregistrations++;equipmentStops++;for(auto u:m_Units)if(u->Group==g){u->Group=nullptr;u->Utility=nullptr;u->reports=false;}}
|
|
'''+claims+retain+r'''
|
|
};
|
|
-struct VehicleOwner{bool Released=false,Registered=true;void*InterceptOrder=nullptr;SCR_AIGroup*Group=nullptr;void*Entity=this;struct{bool Held=true;}Combat;int SupplyToken=101;bool SupplyHeld=true;int clears=0,intercepts=0;
|
|
+struct VehicleOwner{bool Released=false,Registered=true,PlayerCaptureObserved=false;void*InterceptOrder=nullptr;SCR_AIGroup*Group=nullptr;void*Entity=this;struct{bool Held=true;}Combat;int SupplyToken=101;bool SupplyHeld=true;int clears=0,intercepts=0;
|
|
void ClearIntercept(void*){intercepts++;}void ClearOrder(){clears++;}
|
|
'''+vehicle+r'''
|
|
};
|
|
-struct TeamOwner{bool Released=false,Registered=true;SCR_AIGroup*Group=nullptr;int clears=0;void Clear(){clears++;}
|
|
+struct TeamOwner{bool Released=false,Registered=true,PlayerCaptureObserved=false;SCR_AIGroup*Group=nullptr;int clears=0;void Clear(){clears++;}
|
|
'''+team+r'''
|
|
};
|
|
struct Case{ERR_GameMode mode;ERR_HQ hq;SCR_AIGroup group,foreign;IEntity one,two,extra;AIAgent a,b,c;Sampler sampler;ERR_HQUnit unit;VehicleOwner vehicle;TeamOwner team;
|
|
--- /dev/null
|
|
+++ b/tests/test_hunter_capture.py
|
|
@@ -0,0 +1,93 @@
|
|
+"""Execute actual event, tracked-hunter match, bounded ancestry and both release layers."""
|
|
+import os,re
|
|
+from pathlib import Path
|
|
+from policy_harness import ROOT,body,run
|
|
+base=Path(os.environ.get('ERR_HUNTER_CAPTURE_ROOT',ROOT))
|
|
+p=base/'addons/core/Scripts/Game/ERR/10_AI'
|
|
+h=p/'ERR_HunterService.c';c=p/'ERR_ConvoyService.c'
|
|
+def tr(s):
|
|
+ s=re.sub(r'//[^\n]*','',s).replace('override ','').replace('protected ','').replace('null','nullptr')
|
|
+ s=s.replace('ERR_HQ.Cast(m_Mode.Command())','m_Mode.Command()').replace('ERR_HQ.Cast(mode.Command())','mode.Command()')
|
|
+ s=s.replace('array<AIAgent>','Array<AIAgent*>')
|
|
+ s=re.sub(r'foreach \(([^:]+):',r'for (\1:',s)
|
|
+ for t in ('IEntity','ERR_GameMode','ERR_HQ','ERR_HunterVehicle','BaseCompartmentManagerComponent','ERR_ForceRecord','AIAgent'):
|
|
+ s=re.sub(r'\b'+t+r' (\w+)',t+r'* \1',s)
|
|
+ for n in ('m_Mode','mode','hq','unit','Group','agent','record','Recovery','entity'):
|
|
+ s=re.sub(r'\b'+n+r'\.',n+'->',s)
|
|
+ s=s.replace('Forces().','Forces()->').replace('Players().','Players()->').replace('Command().','Command()->').replace('Hunters().','Hunters()->')
|
|
+ for n in ('Replication','ERR_GameMode','ERR_ConvoyRules','SCR_EntityHelper'):s=s.replace(n+'.',n+'::')
|
|
+ s=s.replace('ERR_HQ* hq;','ERR_HQ* hq=nullptr;').replace('int depth;','int depth=0;')
|
|
+ return s
|
|
+service=tr(body(h,'void PlayerEntered(')) if 'void PlayerEntered(' in h.read_text() else 'void PlayerEntered(IEntity*,IEntity*){}'
|
|
+event=tr(body(h,'override protected void OnCompartmentEntered(')).replace('super.','NativeAccess::') if 'override protected void OnCompartmentEntered(' in h.read_text() else 'void OnCompartmentEntered(IEntity* t,BaseCompartmentManagerComponent*m,int i,int s,bool b){NativeAccess::OnCompartmentEntered(t,m,i,s,b);}'
|
|
+base_release=tr(body(c,'void Release(ERR_GameMode mode)'))
|
|
+hunter_release=tr(body(h,'override void Release(ERR_GameMode mode)')).replace('super.','VehicleOwner::')
|
|
+part=tr(body(c,'static bool PartOf('))
|
|
+run(r'''
|
|
+#include <cassert>
|
|
+#include <vector>
|
|
+template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;int Count(){return this->size();}};
|
|
+struct IEntity{IEntity*parent=nullptr;IEntity*GetParent(){return parent;}};
|
|
+struct AIAgent{int lod=0;void SetPermanentLOD(int n){lod=n;}};
|
|
+struct GroupType{int lod=0;AIAgent a;void SetPermanentLOD(int n){lod=n;}void GetAgents(Array<AIAgent*>&v){v={&a};}};
|
|
+struct BaseCompartmentManagerComponent{};
|
|
+struct Service;struct ERR_HQ;
|
|
+struct Roster{IEntity*current=nullptr;void*CurrentActor(IEntity*a){return a&&a==current?this:nullptr;}};
|
|
+struct ERR_ForceRecord{bool Pinned=true;};struct Ledger{ERR_ForceRecord r;ERR_ForceRecord*FindGroup(GroupType*){return &r;}};
|
|
+struct ERR_GameMode{inline static ERR_GameMode*current=nullptr;bool active=true;ERR_HQ*command=nullptr;Roster roster;Ledger ledger;static ERR_GameMode*Get(){return current;}bool Active(){return active;}ERR_HQ*Command(){return command;}Roster*Players(){return &roster;}Ledger*Forces(){return &ledger;}};
|
|
+namespace Replication{bool server=true;bool IsServer(){return server;}}
|
|
+namespace ERR_ConvoyRules{bool occupied=false;bool Captured(IEntity*,GroupType*){return occupied;}
|
|
+'''+part+r'''
|
|
+}
|
|
+struct ERR_HQ{Service*service=nullptr;int retained=0,unregistered=0,recoveries=0;Service*Hunters(){return service;}bool ReleaseConvoySurvivors(GroupType*){retained++;return true;}void UnregisterGroup(GroupType*){unregistered++;}void ReleaseRecovery(GroupType*){recoveries++;}};
|
|
+struct RecoveryTask{int stops=0;bool*captured=nullptr;void Stop(){stops++;if(captured)assert(*captured);}};
|
|
+struct VehicleOwner{bool Released=false,Registered=true,PlayerCaptureObserved=false;IEntity*Entity=nullptr;GroupType*Group=nullptr;void*InterceptOrder=nullptr;struct{bool Held=true;}Combat;int SupplyToken=7;bool SupplyHeld=true;int orders=1,clears=0;void ClearIntercept(void*){}void ClearOrder(){orders=0;clears++;}
|
|
+'''+base_release+r'''
|
|
+};
|
|
+struct ERR_HunterVehicle:VehicleOwner{RecoveryTask*Recovery=nullptr;void*Downlink=this;
|
|
+'''+hunter_release+r'''
|
|
+};
|
|
+struct Service{bool m_Stopped=false;ERR_GameMode*m_Mode=nullptr;Array<ERR_HunterVehicle*>m_Hunters;
|
|
+'''+service+r'''
|
|
+};
|
|
+struct NativeAccess{IEntity*owner=nullptr;int nativeCalls=0;IEntity*GetOwner(){return owner;}void OnCompartmentEntered(IEntity*,BaseCompartmentManagerComponent*,int,int,bool){nativeCalls++;if(ERR_GameMode::current)ERR_GameMode::current->roster.current=nullptr;ERR_ConvoyRules::occupied=false;}};
|
|
+struct Access:NativeAccess{
|
|
+'''+event+r'''
|
|
+};
|
|
+struct Case{ERR_GameMode mode,other;ERR_HQ hq,otherHQ;Service service,foreign;ERR_HunterVehicle unit;GroupType group;IEntity player,vehicle,turret,unrelated;RecoveryTask recovery;Access access;
|
|
+ Case(){Replication::server=true;ERR_GameMode::current=&mode;mode.command=&hq;mode.roster.current=&player;hq.service=&service;service.m_Mode=&mode;service.m_Hunters={&unit};unit.Entity=&vehicle;unit.Group=&group;unit.Recovery=&recovery;turret.parent=&vehicle;access.owner=&player;}
|
|
+ void Enter(IEntity*t=nullptr){access.OnCompartmentEntered(t?t:&turret,nullptr,1,2,false);}
|
|
+};
|
|
+int main(){
|
|
+ {IEntity root;assert(ERR_ConvoyRules::PartOf(&root,&root));}
|
|
+ {Case c;c.recovery.captured=&c.unit.PlayerCaptureObserved;
|
|
+ // Native occupancy is not visible yet; super removes current identity to model
|
|
+ // disconnect/body loss before next one-second poll. Event must win that gap.
|
|
+ c.Enter();assert(c.unit.PlayerCaptureObserved&&c.unit.Released);assert(c.access.nativeCalls==1);
|
|
+ assert(c.hq.unregistered==1&&c.hq.retained==0&&c.hq.recoveries==0);
|
|
+ assert(c.recovery.stops==1&&c.unit.Downlink==nullptr&&c.unit.orders==0&&c.unit.clears==1);
|
|
+ assert(!c.mode.ledger.r.Pinned&&c.unit.Entity==&c.vehicle&&c.unit.Group==&c.group);
|
|
+ c.mode.roster.current=&c.player;c.Enter();assert(c.access.nativeCalls==2&&c.recovery.stops==1&&c.hq.unregistered==1);
|
|
+ }
|
|
+ for(int bad=0;bad<9;bad++){Case c;
|
|
+ if(bad==0)Replication::server=false;
|
|
+ if(bad==1)c.service.m_Stopped=true;
|
|
+ if(bad==2)c.mode.active=false;
|
|
+ if(bad==3)c.service.m_Mode=&c.other;
|
|
+ if(bad==4)c.mode.command=&c.otherHQ;
|
|
+ if(bad==5)c.hq.service=&c.foreign;
|
|
+ if(bad==6)c.mode.roster.current=&c.unrelated;
|
|
+ if(bad==7)c.unit.Entity=nullptr;
|
|
+ if(bad==8)c.service.m_Hunters.resize(16,&c.unit);
|
|
+ c.Enter();assert(!c.unit.PlayerCaptureObserved&&!c.unit.Released&&c.hq.unregistered==0&&c.access.nativeCalls==1);
|
|
+ }
|
|
+ {Case c;c.Enter(&c.unrelated);assert(!c.unit.Released&&c.access.nativeCalls==1);}
|
|
+ {Case c;IEntity chain[33];for(int i=0;i<32;i++)chain[i].parent=&chain[i+1];chain[32].parent=&c.vehicle;c.Enter(&chain[0]);assert(!c.unit.Released);}
|
|
+ {Case c;IEntity loop;loop.parent=&loop;c.Enter(&loop);assert(!c.unit.Released);}
|
|
+ {Case c;c.Enter(&c.vehicle);assert(c.unit.Released);} // root as well as turret child
|
|
+ {Case c;c.unit.Release(&c.mode);assert(c.unit.Released&&c.hq.recoveries==1&&c.hq.unregistered==0);c.Enter();assert(!c.unit.PlayerCaptureObserved&&c.hq.unregistered==0&&c.hq.recoveries==1);} // abandoned asset does not revoke independent survivors
|
|
+ {Case c;c.unit.PlayerCaptureObserved=true;c.unit.Release(&c.mode);assert(c.hq.unregistered==1&&c.hq.retained==0&&!c.unit.Downlink);} // latch reaches both release layers without current occupant
|
|
+ {Case c;c.service.PlayerEntered(nullptr,&c.vehicle);c.service.PlayerEntered(&c.player,nullptr);assert(!c.unit.Released);}
|
|
+}
|
|
+''')
|
|
+print('PASS: actual player-entry hook latches original hunter capture before native callbacks; both release layers cancel ownership without new entities/refund; bounded current-owner guards and prior-release policy')
|