--- a/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c +++ b/addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c @@ -36,6 +36,10 @@ protected bool m_ERRTestMountedAmmo; protected ref ERR_NativeMountedAmmoProbe m_ERRMountedAmmo; protected ref ERR_NativeMountedCourierProbe m_ERRMountedCourier; + protected bool m_ERRTestSupplyTravel; + protected DamageManagerComponent m_ERRSupplyTravelObserverDamage; + protected bool m_ERRSupplyTravelObserverDamageEnabled; + protected ref ERR_NativeSupplyTravelProbe m_ERRSupplyTravel; protected bool m_ERRTestConvoy; protected ref ERR_NativeConvoyProbe m_ERRConvoyProbe; protected ref ERR_NativeHQMemoryProbe m_ERRHQMemoryProbe; @@ -94,6 +98,7 @@ m_ERRTestAlarmHearing = outcome == "alarm-hearing"; m_ERRTestMountedAmmo = outcome == "mounted-ammo"; m_ERRTestConvoy = outcome == "convoy" || outcome == "supply-convoys"; + m_ERRTestSupplyTravel = outcome == "supply-travel"; m_ERRTestMortar = outcome == "mortar-inventory"; m_ERRTestMapOwnership = outcome == "map-ownership"; m_ERRTestAircraftField = outcome == "aircraft-field"; @@ -129,6 +134,7 @@ if (m_ERRTestMortarSalvo) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000; if (m_ERRTestMortarFire) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000; if (m_ERRTestPatrolHQ) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 420000; + if (m_ERRTestSupplyTravel) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 420000; OnStop().Insert(ERR_StopHQPatrol); OnPhaseChanged().Insert(ERR_SmokePhase); GetGame().GetCallqueue().CallLater(ERR_SmokeTick, 500, true); @@ -243,6 +249,7 @@ if (m_ERRAircraftField) m_ERRAircraftField.Stop(); if (m_ERRHQResponseProof) m_ERRHQResponseProof.Stop(); if (m_ERRConvoyProbe) m_ERRConvoyProbe.Stop(); + ERR_StopSupplyTravel(); if (m_ERRMountedAmmo) m_ERRMountedAmmo.Stop(); if (m_ERRMountedCourier) m_ERRMountedCourier.Stop(); if (m_ERRAlarmHearing) m_ERRAlarmHearing.Stop(); @@ -288,6 +295,15 @@ protected void ERR_SmokePhase(ERR_Phase previous, ERR_Phase current) { if (m_ERRSmokeDone || !ERR_MissionRules.Terminal(current)) return; + if (m_ERRTestSupplyTravel) + { + if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 3201, "production supply travel checked before explicit fixture reset")) return; + m_ERRSmokeDone = true; + GetGame().GetCallqueue().Remove(ERR_SmokeTick); + ERR_StopHQPatrol(); + Print("ERR SMOKE COMPLETE: production supply convoy original six-vehicle travel; tactics and interception unqualified"); + return; + } if (m_ERRTestAircraftField) { if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 241, "actual aircraft field fixture completed before explicit reset")) return; @@ -504,12 +520,44 @@ if (!ERR_Check(damage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRSmokePlayer), m_ERRSmokePlayer), "explicit terminal fixture fatal loss authorized")) return; m_ERRSmokeWait = now + 4000; } + protected void ERR_StopSupplyTravel() + { + ERR_NativeSupplyTravelProbe probe = m_ERRSupplyTravel; + DamageManagerComponent damage = m_ERRSupplyTravelObserverDamage; + bool enabled = m_ERRSupplyTravelObserverDamageEnabled; + m_ERRSupplyTravel = null; m_ERRSupplyTravelObserverDamage = null; + if (probe) probe.Stop(); + if (damage) damage.EnableDamageHandling(enabled); + } + protected void ERR_BeginSupplyTravel(float now) + { + if (!ERR_Check(Players() && Players().CurrentActor(m_ERRSmokePlayer), "supply travel retains the current admitted observer")) return; + if (!ERR_Check(!m_ERRSupplyTravel && !m_ERRSupplyTravelObserverDamage, "supply travel setup occurs once")) return; + m_ERRSupplyTravelObserverDamage = DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)); + if (!ERR_Check(m_ERRSupplyTravelObserverDamage != null, "supply travel admitted observer damage component exists")) return; + m_ERRSupplyTravelObserverDamageEnabled = m_ERRSupplyTravelObserverDamage.IsDamageHandlingEnabled(); + m_ERRSupplyTravelObserverDamage.EnableDamageHandling(false); + Print("ERR SUPPLY TRAVEL FIXTURE: admitted observer damage protected; enemy forces/AI budget/routes unchanged"); + // Ordinary release starts production AI. No legacy convoy fixture setup, + // manual admission or friendly perception override. + if (!ERR_Check(UnlockPrison(m_ERRSmokePlayer), "supply travel fixture opens prison for ordinary automatic AI")) return; + m_ERRSupplyTravel = new ERR_NativeSupplyTravelProbe(this, now); + m_ERRSmokeStep = 3200; + } + protected void ERR_TickSupplyTravel(float now) + { + if (!ERR_Check(m_ERRSupplyTravel != null, "production supply travel probe exists")) return; + int result = m_ERRSupplyTravel.Tick(ERR_HQ.Cast(Command()), now); + if (result < 0) { ERR_Check(false, "production supply convoy travel; inspect original-identity diagnostics"); return; } + if (result == 1) { m_ERRSmokeStep = 3201; Transition(ERR_Phase.DEFEAT); } + } protected void ERR_SmokeTick() { float now = GetWorld().GetWorldTime(); if (m_ERRSmokeDone) return; if (now > m_ERRSmokeDeadline) { ERR_Check(false, "timeout at step " + m_ERRSmokeStep.ToString()); return; } if (now < m_ERRSmokeWait) return; + if (m_ERRSmokeStep == 3200) { ERR_TickSupplyTravel(now); return; } if (m_ERRSmokeStep == 240) { int result = m_ERRAircraftField.Tick(now); @@ -598,6 +646,7 @@ if (!ERR_Check(CanStartSpawnPreload() && m_ERRSpawnPreloadRequested && !m_SpawnPreload, "prisoner admitted only after enabled native spawn preload finishes")) return; m_ERRSmokePlayer = players[0]; if (!ERR_CheckRoster() || !ERR_NativeContractsProbe.Run(this, m_ERRSmokePlayer) || !ERR_CheckCompanionCommands()) return; + if (m_ERRTestSupplyTravel) { ERR_BeginSupplyTravel(now); return; } if (m_ERRTestAircraftField) { DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false); --- /dev/null +++ b/tests/test_native_supply_travel_hook.py @@ -0,0 +1,66 @@ +"""Actual opt-in setup/tick/terminal/cleanup methods, native boundaries doubled.""" +from pathlib import Path +import os,re +from policy_harness import ROOT,body,run +p=Path(os.environ.get('ERR_SMOKE_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/40_Chat/ERR_NativeSmoke.c')) +s=p.read_text() +methods='\n'.join(body(p,x) for x in ('void ERR_StopSupplyTravel(', 'void ERR_BeginSupplyTravel(', 'void ERR_TickSupplyTravel(')) +methods=methods.replace('protected ','').replace('null','nullptr') +methods=methods.replace('DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent))','m_ERRSmokePlayer->damage').replace('ERR_HQ.Cast(Command())','Command()').replace('ERR_Phase.DEFEAT','2') +for typ in ['ERR_NativeSupplyTravelProbe','DamageManagerComponent']: + methods=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',methods) +for obj in ['probe','damage','m_ERRSupplyTravel','m_ERRSupplyTravelObserverDamage']: + methods=methods.replace(obj+'.',obj+'->') +methods=methods.replace('Players().','Players()->') +terminal=body(p,'if (m_ERRTestSupplyTravel)\n\t\t{') +terminal=terminal.replace('ERR_Phase.DEFEAT','2').replace('GetGame().GetCallqueue().Remove(ERR_SmokeTick);','removed++;').replace('ERR_StopHQPatrol();','ERR_StopSupplyTravel();') +# Actual startup expressions/deadline, not a duplicate selector model. +a=s.index('m_ERRTestLostHelicopter = System.GetCLIParam(');b=s.index('OnStop().Insert(ERR_StopHQPatrol);',a) +selector=s[a:b].replace('System.GetCLIParam("errSmokeOutcome", outcome) && ','').replace('GetWorld().GetWorldTime()','now') +selector=re.sub(r'ERR_Native\w+Probe.Enabled\(\)','false',selector) +flags=sorted(set(re.findall(r'\bm_ERRTest\w+',selector))) +run(r''' +#include +#include +using string=std::string; +struct DamageManagerComponent{bool enabled=true;int writes=0;bool IsDamageHandlingEnabled(){return enabled;}void EnableDamageHandling(bool e){enabled=e;writes++;}}; +struct Actor{DamageManagerComponent*damage=nullptr;};struct ERR_HQ{};struct Fixture; +struct ERR_NativeSupplyTravelProbe{static inline int created=0;int result=0,stops=0,ticks=0;float begun=0;ERR_HQ*seen=nullptr;ERR_NativeSupplyTravelProbe(Fixture*,float now){created++;begun=now;}void Stop(){stops++;}int Tick(ERR_HQ*h,float){seen=h;ticks++;return result;}}; +struct Roster{Actor*current=nullptr;bool CurrentActor(Actor*a){return a&&a==current;}}; +struct Fixture{Actor actor;DamageManagerComponent damage;Roster roster;ERR_HQ hq;Actor*m_ERRSmokePlayer=&actor;ERR_NativeSupplyTravelProbe*m_ERRSupplyTravel=nullptr;DamageManagerComponent*m_ERRSupplyTravelObserverDamage=nullptr;bool m_ERRSupplyTravelObserverDamageEnabled=false,m_ERRSmokeDone=false,m_ERRTestSupplyTravel=true,unlock=true;int m_ERRSmokeStep=0,failed=0,unlocks=0,transition=0,removed=0;string printed; + Fixture(){actor.damage=&damage;roster.current=&actor;}Roster*Players(){return &roster;}ERR_HQ*Command(){return &hq;}void Print(string s){printed=s;}bool UnlockPrison(Actor*a){assert(a==&actor&&!damage.enabled);unlocks++;return unlock;}void Transition(int p){transition=p;}bool ERR_Check(bool ok,string){if(!ok){failed++;m_ERRSmokeDone=true;ERR_StopSupplyTravel();}return ok;} +METHODS + void Finish(int current){TERMINAL} +}; +void selector(string outcome,float now){[[maybe_unused]] bool FLAGS;float m_ERRSmokeDeadline=0;SELECTOR + if(outcome=="supply-travel"){assert(m_ERRTestSupplyTravel&&!m_ERRTestConvoy&&!m_ERRTestPlayerDeath&&m_ERRSmokeDeadline==now+420000);}else assert(!m_ERRTestSupplyTravel); +} +int main(){ + selector("supply-travel",0);selector("supply-travel",1234);selector("convoy",0);selector("supply-convoys",0);selector("normal-escape-route",0); + Fixture f;f.ERR_BeginSupplyTravel(100);auto p=f.m_ERRSupplyTravel;assert(p&&p->begun==100&&f.m_ERRSmokeStep==3200&&f.unlocks==1&&!f.damage.enabled); + f.ERR_TickSupplyTravel(500);assert(p->ticks==1&&p->seen==&f.hq&&f.transition==0); + p->result=1;f.ERR_TickSupplyTravel(1000);assert(f.m_ERRSmokeStep==3201&&f.transition==2); + f.Finish(2);assert(f.m_ERRSmokeDone&&f.removed==1&&p->stops==1&&f.damage.enabled&&!f.m_ERRSupplyTravel&&!f.m_ERRSupplyTravelObserverDamage); + assert(f.printed=="ERR SMOKE COMPLETE: production supply convoy original six-vehicle travel; tactics and interception unqualified"); + int writes=f.damage.writes;f.ERR_StopSupplyTravel();assert(f.damage.writes==writes&&p->stops==1); + Fixture disabled;disabled.damage.enabled=false;disabled.ERR_BeginSupplyTravel(100);disabled.ERR_StopSupplyTravel();assert(!disabled.damage.enabled); + Fixture refused;refused.unlock=false;int before=ERR_NativeSupplyTravelProbe::created;refused.ERR_BeginSupplyTravel(100);assert(refused.failed==1&&refused.damage.enabled&&!refused.m_ERRSupplyTravel&&ERR_NativeSupplyTravelProbe::created==before); + Fixture replaced;replaced.ERR_BeginSupplyTravel(100);Actor next;DamageManagerComponent nextDamage;next.damage=&nextDamage;replaced.m_ERRSmokePlayer=&next;replaced.ERR_StopSupplyTravel();assert(replaced.damage.enabled&&nextDamage.writes==0); + Fixture lost;lost.roster.current=nullptr;lost.ERR_BeginSupplyTravel(100);assert(lost.failed==1&&lost.unlocks==0&&lost.damage.writes==0); + Fixture missing;missing.actor.damage=nullptr;missing.ERR_BeginSupplyTravel(100);assert(missing.failed==1&&missing.unlocks==0&&!missing.m_ERRSupplyTravel); + Fixture bad;bad.ERR_BeginSupplyTravel(100);auto bp=bad.m_ERRSupplyTravel;bp->result=-1;bad.ERR_TickSupplyTravel(500);assert(bad.failed==1&&bad.damage.enabled&&bp->stops==1&&bad.transition==0); + Fixture premature;premature.ERR_BeginSupplyTravel(100);premature.Finish(2);assert(premature.failed==1&&premature.damage.enabled&&premature.printed.find("COMPLETE")==string::npos); +} +'''.replace('METHODS',methods).replace('TERMINAL',terminal).replace('FLAGS',','.join(f+'=false' for f in flags)).replace('SELECTOR',selector)) +assert s.startswith('// Explicit, local Workbench diagnostic only.') and '#ifdef WORKBENCH' in s +setup=body(p,'void ERR_BeginSupplyTravel(') +for forbidden in ['StartConvoy','SetPerceivedFaction','SetOrigin','Teleport','Hunters()', 'Settings(', 'StartSupply', 'EnemyFaction']: + assert forbidden not in setup,forbidden +assert 'ERR_StopSupplyTravel();' in body(p,'void ERR_StopHQPatrol(') +assert 'ERR_StopHQPatrol();' in body(p,'bool ERR_Check(') +assert 'OnStop().Insert(ERR_StopHQPatrol);' in s +step=body(p,'void ERR_SmokeTick(') +assert step.index('now > m_ERRSmokeDeadline') < step.index('m_ERRSmokeStep == 3200') +assert step.index('ERR_CheckCompanionCommands()') < step.index('if (m_ERRTestSupplyTravel) { ERR_BeginSupplyTravel(now); return; }') < step.index('if (m_ERRTestConvoy)') +assert 'm_ERRSmokeDeadline' not in methods +print('PASS: actual distinct supply-travel hook, fixed420s selector, original observer protection/restoration, failure/terminal cleanup and no legacy convoy setup')