Files
EscapistsReintroduced/docs/evidence/hq-recovery-reserve-2026-11-05/fix.patch
T
jakub 9caed3f12d
continuous-integration/drone/push Build is passing
Embed RHS 1.9 compatibility; record Workshop 0.0.141
2026-11-05 16:39:35 +01:00

173 lines
14 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
@@ -1203,7 +1203,18 @@
else unit.CancelPending();
return;
}
- if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now) && !(task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.WITHDRAW)) { LostOrder(unit, now); return; }
+ if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now))
+ {
+ bool recoveryOrder = task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.WITHDRAW;
+ if (m_Coordinated && task.OperationId > 0 && task.OperationRole == ERR_HQOperationRole.RESERVE)
+ {
+ // Pending radio orders exclude this squad from ObserveForces. Its delivered
+ // recovery judgment stays current until the exact queued task is validated below.
+ ERR_HQSquadJudgment squad = m_Operations.Judgment(unit.Id);
+ recoveryOrder = squad && squad.Recovering;
+ }
+ if (!recoveryOrder) { LostOrder(unit, now); return; }
+ }
if (task.Investigation())
{
if (!WatchTaskValid(task)) { LostOrder(unit, now); return; }
--- a/tests/test_hq_ammo_dispatch.py
+++ b/tests/test_hq_ammo_dispatch.py
@@ -1,6 +1,8 @@
"""Delivered ammo gates new tasks without becoming active-order cancellation."""
from policy_harness import ROOT, body, run
-H=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'
+from pathlib import Path
+import os
+H=Path(os.environ.get('ERR_HQ_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c'))
for method in ('protected void Investigate(', 'protected void Retask(', 'protected void Assign('):
s=body(H,method)
assert 'if (m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue;' in s
@@ -8,20 +10,23 @@
s=body(H,'protected void DeliverOrder(')
s=s[:s.index(' if (task.Investigation())')]+' admitted++;\n }'
s=s.replace('protected ','').replace('null','nullptr').replace('.IsEmpty()', '.empty()').replace('ERR_HQUnit unit','ERR_HQUnit* unit').replace('ERR_HQOperationRole.','ERR_HQOperationRole::')
+s=s.replace('ERR_HQSquadJudgment squad', 'ERR_HQSquadJudgment* squad').replace('squad.', 'squad->')
s=s.replace('ERR_HQReport order','ERR_HQReport* order').replace('ERR_HQTask task','ERR_HQTask* task')
for name in ('unit','task','order'):s=s.replace(name+'.',name+'->')
run(r'''
#include <cassert>
#include <string>
struct ERR_HQReport{};
-enum class ERR_HQOperationRole{SEARCH,WITHDRAW};
+enum class ERR_HQOperationRole{SEARCH,WITHDRAW,RESERVE};
+struct ERR_HQSquadJudgment{bool Recovering=false;};
+struct Operations{ERR_HQSquadJudgment* squad=nullptr;ERR_HQSquadJudgment* Judgment(int id){assert(id==1);return squad;}};
struct ERR_HQTask{int OperationId=0;ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;std::string SecuritySite;float ExpiresAt=100000;bool ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;};
struct ERR_HQUnit{ERR_HQReport* PendingOrder=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQTask* Task=nullptr;
- std::string ForceSourceId="original-1";bool eligible=true;int cancellations=0;
+ int Id=1;std::string ForceSourceId="original-1";bool eligible=true;int cancellations=0;
bool Eligible(void*){return eligible;}void CancelPending(){cancellations++;PendingOrder=nullptr;PendingTask=nullptr;}};
struct Watch {bool empty=false;int calls=0;bool ArmedSearchBlocked(std::string s,float){assert(s=="original-1");calls++;return empty;}};
struct Journal {void Write(std::string,std::string){}};
-struct HQ{Watch m_Watch;Journal m_Journal;void* m_Mode=nullptr;int admitted=0,lost=0,released=0;
+struct HQ{bool m_Coordinated=false;Operations m_Operations;Watch m_Watch;Journal m_Journal;void* m_Mode=nullptr;int admitted=0,lost=0,released=0;
void LostOrder(ERR_HQUnit* u,float){lost++;u->CancelPending();}void ReleaseUnit(ERR_HQUnit* u,float){released++;u->Task=nullptr;u->CancelPending();}
'''+s+r'''
};
--- a/tests/test_hq_delivered_task.py
+++ b/tests/test_hq_delivered_task.py
@@ -7,13 +7,13 @@
def cpp(s):
s=s.replace('EngineerMine.', 'EngineerMine->')
s=s.replace('ref ', '')
- s=s.replace('protected ','').replace('null','nullptr').replace('string.Format','Format')
- for name in ('ERR_SupplyConvoyTactics','Math','ERR_GameMode','SCR_EntityHelper','ERR_HQPursuitRules','AIWaypoint','ERR_Actors','ERR_Resources','ERR_ForceDuty','ERR_HQRoadIntercept','ERR_ForceRules','ERR_HQPursuitRole','ERR_HQMemoryKind','ERR_HQOperationRole'):
+ s=s.replace('override ', '').replace('protected ','').replace('null','nullptr').replace('string.Format','Format')
+ for name in ('ERR_SupplyConvoyTactics','Math','ERR_GameMode','SCR_EntityHelper','ERR_HQPursuitRules','AIWaypoint','ERR_Actors','ERR_Resources','ERR_ForceDuty','ERR_HQRoadIntercept','ERR_ForceRules','ERR_HQPursuitRole','ERR_HQMemoryKind','ERR_HQOperationRole','ERR_HQExecutionRules'):
s=s.replace(name+'.',name+'::')
- for typ in ('ERR_HQUnit','ERR_HQTask','ERR_GameMode','ERR_HQReport','ERR_HQBelief','ERR_ForceRecord','ERR_HQHuntWindow','AIWaypoint'):
+ for typ in ('ERR_HQUnit','ERR_HQTask','ERR_GameMode','ERR_HQReport','ERR_HQBelief','ERR_ForceRecord','ERR_HQHuntWindow','AIWaypoint','ERR_HQSquadJudgment','ERR_HQOperationSlot'):
s=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',s)
s=re.sub(r'foreach \((ERR_HQUnit\*) (\w+) : ([^)]+)\)',r'for (\1 \2 : \3)',s)
- for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','waypoint','m_Mode','lostActive','lostPending'):
+ for name in ('unit','other','task','planned','order','belief','record','mode','Group','hunt','waypoint','m_Mode','lostActive','lostPending','squad','slot'):
s=s.replace(name+'.',name+'->')
s=s.replace('ResourceName ', 'int ').replace('.IsEmpty()', '.empty()')
s=s.replace('PendingOrder.', 'PendingOrder->').replace('Assignment.', 'Assignment->')
@@ -28,6 +28,8 @@
if 'protected void ReleaseLostForce(' in P.read_text(): methods=cpp(body(P,'protected void ReleaseLostForce('))+'\n'+methods
missing=cpp(body(P,'if (!unit.Group && !unit.Uplink)')).replace('m_Units.Remove(i)', 'm_Units.erase(m_Units.begin()+i)')
cleanup=cpp(body(P,' foreach (ERR_HQUnit unit : m_Units)\n {\n if (!unit.Assignment && !unit.PendingOrder) continue;\n bool security = SecurityAssigned(unit);'))
+C=Path(os.environ.get('ERR_HQ_COORDINATED_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQCoordinated.c'))
+methods=cpp(body(C,'override protected bool CoordinatedDelivery('))+'\n'+methods
R=Path(os.environ.get('ERR_HQ_PURSUIT_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQPursuit.c'))
rules='\n'.join(cpp(body(R,x)) for x in ('static bool Finite(', 'static bool PositionValid(', 'static bool Actionable(', 'static bool Heading(', 'static ERR_HQPursuitRole Role('))
eligible=cpp(body(ROAD,'static bool Eligible('))
@@ -49,7 +51,7 @@
'''+rules+r'''
};
struct Math{static float Min(float a,float b){return std::min(a,b);}static float Max(float a,float b){return std::max(a,b);}};
-struct ERR_HQTask{ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;int OperationId=0,ExecutionId=0;string SecuritySite;bool Maneuver=false,RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0;
+struct ERR_HQTask{ERR_HQOperationRole OperationRole=ERR_HQOperationRole::SEARCH;int OperationId=0,ExecutionId=0,OperationRevision=2,SupportSourceId=0;float SupportUntil=0;string SecuritySite;bool Maneuver=false,RoadIntercept=false,ReleaseOnly=false;ERR_HQTask* ReplacesTask=nullptr;string WatchSourceId;int WatchMessageId=0,WatchRequestId=0;
int BeliefId=1,BeliefRevision=2;ERR_HQPursuitRole Role=ERR_HQPursuitRole::SEARCH;vector Destination{40,0,50};float SearchRadiusMetres=60,QueuedAt=0,ExpiresAt=60000;bool Investigation(){return !WatchSourceId.empty();}string Name(){return "search";}};
struct ERR_HQReport{string TravelMode="on foot";int MessageId=10;float ObservedAt=0;};
struct ERR_HQRoute{int MessageId=20;};
@@ -67,7 +69,7 @@
struct TestWorld{operator bool(){return true;}float GetWorldTime(){return 0;}};struct TestGame{operator bool(){return true;}TestWorld GetWorld(){return {};}};TestGame GetGame(){return {};}struct ExecutionDouble{void Update(void*,float){}operator bool(){return false;}void Stop(float,bool){}void Receive(ERR_HQTask*,float){}};
struct ERR_HQUnit{ExecutionDouble Execution;MineJob* EngineerMine=nullptr;int Id=1;string ForceSourceId="original";SCR_AIGroup* Group=nullptr;AIWaypoint* Waypoint=nullptr;
ERR_HQTask* Task=nullptr;ERR_HQTask* PendingTask=nullptr;ERR_HQReport* Assignment=nullptr;ERR_HQReport* PendingOrder=nullptr;
- ERR_HQRoute* Downlink=nullptr;ERR_HQRoute* Uplink=nullptr;void* PendingConvoyOrder=nullptr;bool HomeGuard=false,PendingCheckin=false,eligible=true,fighting=false,PlanEligible=true;
+ ERR_HQRoute* Downlink=nullptr;ERR_HQRoute* Uplink=nullptr;void* PendingConvoyOrder=nullptr;int LogisticsReservation=0;bool ReservedForSupport=false,HomeGuard=false,PendingCheckin=false,eligible=true,fighting=false,PlanEligible=true;
bool Eligible(ERR_GameMode*){return eligible&&Group;}
'''+units+r'''
};
@@ -79,7 +81,11 @@
struct Watch{bool empty=false;int finished=0;bool ArmedSearchBlocked(string,float){return empty;}bool FinishInvestigation(string,int,int,float){finished++;return true;}};
struct Journal{int writes=0;void Write(string,string,int=0){writes++;}};
struct ERR_HQHuntWindow{float CompletedUntil=0;float NextHunt=200000;};
-struct HQ{bool CoordinatedDelivery(ERR_HQUnit*,ERR_HQTask*,float){return false;}struct Operations{void Release(int,int,float){}}m_Operations;ERR_GameMode* m_Mode;Memory m_Memory;Watch m_Watch;Journal m_Journal;std::vector<ERR_HQUnit*>m_Units;ERR_HQHuntWindow hunt,otherHunt;bool watchValid=true;
+struct ERR_HQSquadJudgment{bool Recovering=false;};
+struct ERR_HQOperationSlot{ERR_HQTask* Task=nullptr;};
+struct ERR_HQExecutionRules{static bool SupportFresh(float until,float now){return std::isfinite(until)&&until>now&&until<=now+20000;}};
+struct Operations{ERR_HQSquadJudgment* squad=nullptr;ERR_HQOperationSlot* slot=nullptr;ERR_HQSquadJudgment* Judgment(int id){assert(id==1);return squad;}ERR_HQOperationSlot* Find(int id){assert(id==1);return slot;}void Release(int,int,float){}};
+struct HQ{bool m_Coordinated=false;Operations m_Operations;ERR_GameMode* m_Mode;Memory m_Memory;Watch m_Watch;Journal m_Journal;std::vector<ERR_HQUnit*>m_Units;ERR_HQHuntWindow hunt,otherHunt;bool watchValid=true;
bool Fighting(ERR_HQUnit* u){return u->fighting;}bool WatchTaskValid(ERR_HQTask*){return watchValid;}ERR_HQHuntWindow* Hunt(int id){return id==2?&otherHunt:&hunt;}
'''+methods+'\nvoid Cleanup(float now,int assigned,int globalLimit){'+cleanup+'}\nvoid RemoveMissing(float now){for(int i=int(m_Units.size())-1;i>=0;i--){ERR_HQUnit* unit=m_Units[i];'+missing+'}}'+r'''
};
@@ -89,6 +95,50 @@
void Retained(){assert(unit.Task==&active&&unit.Assignment==&oldReport&&unit.Waypoint==&previous&&previous.deletes==0&&group.removals==0&&!unit.PendingTask&&!unit.PendingOrder&&!unit.Downlink);}
};
int main(){
+ // Real delivery and actual coordinated identity admission must permit rest for
+ // a known-empty recovering squad, without admitting ordinary reserve/attacks.
+ for(bool withdrawing:{false,true}){
+ Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot;
+ mood.Recovering=true;slot.Task=&f.next;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot;
+ f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.next.OperationId=1;f.next.ExecutionId=12;
+ f.next.OperationRole=withdrawing?ERR_HQOperationRole::WITHDRAW:ERR_HQOperationRole::RESERVE;
+ // WITHDRAW keeps its original exemption without a recovery judgment.
+ if(withdrawing)f.hq.m_Operations.squad=nullptr;
+ f.hq.DeliverOrder(&f.unit,1000);
+ assert(f.unit.Task==&f.next&&f.unit.Assignment==&f.newReport&&f.unit.Waypoint&&f.unit.Waypoint->resource==2);
+ assert(ERR_Actors::spawns==1&&f.group.additions==1&&f.previous.deletes==1&&!f.unit.PendingTask);
+ }
+ for(int invalid=0;invalid<15;invalid++){
+ Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot;ERR_HQTask foreign;
+ mood.Recovering=true;slot.Task=&f.next;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot;
+ f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.next.OperationId=1;f.next.ExecutionId=12;f.next.OperationRole=ERR_HQOperationRole::RESERVE;
+ if(invalid==0)mood.Recovering=false;
+ if(invalid==1)f.hq.m_Operations.squad=nullptr;
+ if(invalid==2)f.next.OperationId=0;
+ if(invalid==3)f.hq.m_Coordinated=false;
+ if(invalid==4)slot.Task=&foreign;
+ if(invalid==5)f.hq.m_Operations.slot=nullptr;
+ if(invalid==6)f.next.ExpiresAt=1000;
+ if(invalid==7)f.unit.eligible=false;
+ if(invalid==8)f.next.BeliefRevision=3;
+ if(invalid==9)f.next.OperationRevision=3;
+ if(invalid==10)f.unit.HomeGuard=true;
+ if(invalid==11)f.unit.ReservedForSupport=true;
+ if(invalid==12)f.unit.LogisticsReservation=1;
+ if(invalid==13)f.hq.m_Memory.belief=nullptr;
+ if(invalid==14)f.belief.Confidence=0;
+ f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0);
+ }
+ for(auto role:{ERR_HQOperationRole::SEARCH,ERR_HQOperationRole::OBSERVE,ERR_HQOperationRole::FIX,ERR_HQOperationRole::FLANK,ERR_HQOperationRole::BLOCK}){
+ Fixture f;ERR_HQSquadJudgment mood;ERR_HQOperationSlot slot;mood.Recovering=true;slot.Task=&f.next;
+ f.hq.m_Coordinated=true;f.hq.m_Watch.empty=true;f.hq.m_Operations.squad=&mood;f.hq.m_Operations.slot=&slot;
+ f.next.OperationId=1;f.next.OperationRole=role;f.hq.DeliverOrder(&f.unit,1000);f.Retained();assert(ERR_Actors::spawns==0);
+ }
+ // A tactical reserve still uses the ordinary ammo gate: with ammo present it
+ // is valid even without a recovery judgment; empty it is rejected above.
+ {Fixture f;ERR_HQOperationSlot slot;slot.Task=&f.next;f.hq.m_Coordinated=true;f.hq.m_Operations.slot=&slot;
+ f.next.OperationId=1;f.next.OperationRole=ERR_HQOperationRole::RESERVE;f.hq.DeliverOrder(&f.unit,1000);assert(f.unit.Task==&f.next&&ERR_Actors::spawns==1);}
+
// The copied radius is used before installation even if current uncertainty grows.
for(float radius:{45.f,60.f,110.f}){Fixture f;f.next.SearchRadiusMetres=radius;f.belief.Radius=500;
float deadline=f.next.ExpiresAt;f.hq.DeliverOrder(&f.unit,1000);