--- before/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c +++ after/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c @@ -114,6 +114,10 @@ protected float m_ApproachStarted; protected float m_ApproachProgress; protected vector m_ApproachPosition; + protected int m_ApproachRetries; + protected int m_CoverRetries; + protected float m_BlindSince = -1; + protected ref array m_RejectedStages = {}; protected bool m_UnsupportedSearch; protected ref ERR_HunterGunTeam m_GunTeam; protected ERR_GameMode m_Mode; @@ -399,6 +403,48 @@ Radio = hq.Downlink(Unit.Group, "HUNTER TEAM", Unit.Source + " " + Label() + " stage=" + Stage.ToString() + " sector=" + Sector.ToString(), Attack.Message); return Radio != null; } + protected bool AlternateStage(float now) + { + // Reserve failed positions as well as the other teams. Do not repeatedly + // choose the same screened corner or send two vehicles to one firing point. + array occupied = {}; + foreach (vector rejected : m_RejectedStages) occupied.Insert(rejected); + occupied.Insert(Unit.Entity.GetOrigin()); occupied.Insert(Stage); + foreach (ERR_HunterTactics other : Attack.Teams) + if (other != this && other.Living()) occupied.Insert(other.Stage); + vector oldStage = Stage, oldApproach = Approach, oldSector = Sector; + if (!Plan(occupied) || !Move(Stage, 35)) + { Stage = oldStage; Approach = oldApproach; Sector = oldSector; return false; } + m_RejectedStages.Insert(oldStage); + State = 1; m_BlindSince = -1; m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin(); + Deadline = Math.Min(now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance), m_ApproachStarted + 300000); + Note("alternate approach assigned=" + Stage.ToString()); + return true; + } + protected bool RecoverApproach(float now) + { + if (m_ApproachRetries >= 2) { Note("approach still blocked after two recoveries; releasing assignment"); Stop(); return false; } + m_ApproachRetries++; + bool assigned; + if (m_ApproachRetries == 1) assigned = Move(Stage, 35); + else assigned = AlternateStage(now); + // A failed allocation or geometry search gets one bounded retry interval, + // not another expensive search every tick. + m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin(); + Note("approach stationary for 30 seconds; recovery=" + m_ApproachRetries.ToString() + " assigned=" + assigned.ToString()); + return true; + } + protected bool UpdateCover(float now) + { + if (VisualCover()) { m_BlindSince = -1; return true; } + if (m_BlindSince < 0) m_BlindSince = now; + if (now - m_BlindSince < 20000) return true; + if (m_CoverRetries >= 3) { Note("no visual cover after three alternate positions; releasing assignment"); Stop(); return false; } + m_CoverRetries++; m_BlindSince = now; + Note("cover position has no visual contact; looking for another firing angle"); + AlternateStage(now); + return true; + } bool Tick(ERR_HQ hq, float now) { if (!Living()) { Stop(); return false; } @@ -450,6 +496,7 @@ { Deadline = Math.Min(now + 30000, m_ApproachStarted + 300000); Note("approach still progressing; allowing another bounded route interval"); } else { Note("approach made no recent progress or exhausted five-minute budget; release this assignment"); Stop(); return false; } } + else if (now - m_ApproachProgress >= 30000 && !RecoverApproach(now)) return false; } if (State == 8 && (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || now >= Deadline)) { @@ -500,6 +547,7 @@ { State = 6; Deadline = now + 90000; Board(true); Note("bounded search complete; regrouping original survivors"); } if (State == 3 && Role == 0 && now - Attack.Started >= 300000) { Stop(); return false; } + if (State == 3 && Role == 0 && !UpdateCover(now)) return false; if (State == 6 && (Aboard(true) || now >= Deadline)) { Note("regroup ended; ordinary HQ travel may resume"); Stop(); return false; } return Delivered && !Stopped; } --- before/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c +++ after/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c @@ -11,6 +11,10 @@ vector SearchPosition; int SearchStep; bool SearchPlanned; + // Mounted support watchdog; separate from infantry search and boarding. + float SupportProgressAt = -1; + vector SupportProgressPosition; + int SupportSearchStep; ref ERR_ConvoyBoardingProgress BoardingProgress; float BoardingRetryAt; protected Vehicle m_WalkVehicle; @@ -396,7 +400,7 @@ PlanFlank(i, false, team.SearchStep + 1); } } - protected bool SupportPosition(int index, vector desired, out vector position) + protected bool SupportPosition(int index, vector desired, out vector position, bool requireLane = true) { ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index]; SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent(); @@ -404,7 +408,7 @@ vector forward = ERR_MountedCover.Approach(vehicle.Entity.GetOrigin(), m_Contact, m_Forward); bool armed = index != 1 && index != 2; vector road; - if (ERR_SupplyConvoySystem.Approach(desired, road, 70) && FreePosition(vehicle.Group, road) && vector.Dot(road - m_Contact, forward) <= -100 && utility.IsPositionAllowed(road) && (!armed || ERR_MountedCover.ClearLane(vehicle.Entity, road, m_Contact))) { position = road; return true; } + if (ERR_SupplyConvoySystem.Approach(desired, road, 70) && FreePosition(vehicle.Group, road) && vector.Dot(road - m_Contact, forward) <= -100 && utility.IsPositionAllowed(road) && (!armed || (vector.DistanceXZ(road, vehicle.Entity.GetOrigin()) >= 55 && (!requireLane || ERR_MountedCover.ClearLane(vehicle.Entity, road, m_Contact))))) { position = road; return true; } if (index == 1 || index == 2) return false; // Original armed escorts can seek a nearby open firing position off the road. ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); @@ -414,7 +418,8 @@ if (attempt > 0) candidate += Vector(Math.RandomFloat(-80, 80), 0, Math.RandomFloat(-80, 80)); candidate = ERR_Actors.Ground(candidate); if (!FreePosition(vehicle.Group, candidate) || vector.Dot(candidate - m_Contact, forward) > -100 || !utility.IsPositionAllowed(candidate)) continue; - if (!ERR_MountedCover.ClearLane(vehicle.Entity, candidate, m_Contact)) continue; + if (vector.DistanceXZ(candidate, vehicle.Entity.GetOrigin()) < 55) continue; + if (requireLane && !ERR_MountedCover.ClearLane(vehicle.Entity, candidate, m_Contact)) continue; if (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0), 2.5)) { position = candidate; return true; } } return false; @@ -424,6 +429,9 @@ ERR_SupplyTeam orders = m_Support[index]; if (!Usable(index)) { orders.Clear(); return; } ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index]; + float now = GetGame().GetWorld().GetWorldTime(); + if (orders.SupportProgressAt < 0 || vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.SupportProgressPosition) >= 15) + { orders.SupportProgressAt = now; orders.SupportProgressPosition = vehicle.Entity.GetOrigin(); } // An idle mounted driver stops without a Defend order asking the crew to exit. if (index == 2 && holdTruck) { orders.Clear(); return; } bool armed = index != 1 && index != 2; @@ -432,12 +440,17 @@ // just to satisfy the convoy formation's fixed rear/side offsets. if (armed && range >= 100 && range <= 600 && ERR_MountedCover.SeesSector(vehicle.Group, m_Contact)) { - orders.Clear(); Claim(vehicle.Group, vehicle.Entity.GetOrigin(), GetGame().GetWorld().GetWorldTime()); + orders.Clear(); orders.SupportSearchStep = 0; orders.SupportProgressAt = now; + Claim(vehicle.Group, vehicle.Entity.GetOrigin(), now); ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": holding visual contact at " + vehicle.Entity.GetOrigin().ToString()); return; } // Periodic cover checks must not restart a native driver still approaching. - if (retainApproach && !orders.Orders.IsEmpty() && orders.Orders[0] && vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.Orders[0].GetOrigin()) > 30) return; + if (retainApproach && !orders.Orders.IsEmpty() && orders.Orders[0] && vector.DistanceXZ(vehicle.Entity.GetOrigin(), orders.Orders[0].GetOrigin()) > 30) + { + if (now - orders.SupportProgressAt < 30000) return; + ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": approach stationary for 30 seconds; finding another route"); + } float rear = 180, side; if (index == 0) side = 150; if (index == 3) side = -150; @@ -447,13 +460,36 @@ vector support = vehicle.Entity.GetOrigin(); vector forward = ERR_MountedCover.Approach(vehicle.Entity.GetOrigin(), m_Contact, m_Forward); vector left = Vector(-forward[2], 0, forward[0]); + if (armed) + { + // Sample different flanks on successive checks instead of retrying the + // same patch indefinitely. Work remains bounded to the 30-second cadence. + int phase = orders.SupportSearchStep % 5; + if (phase == 1) side += 140; + if (phase == 2) side -= 140; + if (phase == 3) { side += 230; rear += 50; } + if (phase == 4) { side -= 230; rear += 50; } + orders.SupportSearchStep = (phase + 1) % 5; + } vector desired = m_Contact - forward * rear + left * side; vector chosen; - if (SupportPosition(index, desired, chosen)) support = chosen; + bool found = SupportPosition(index, desired, chosen); + if (!found && armed && range > 350) + { + // A distant escort may be screened by an entire village. Move a short, + // checked leg toward the reported sector before requiring a firing lane. + // This is an approach, never a claim that the gun is covering infantry. + vector advance = vehicle.Entity.GetOrigin() + forward * 150; + if (orders.SupportSearchStep % 2 == 0) advance += left * 70; + else advance -= left * 70; + found = SupportPosition(index, advance, chosen, false); + if (found) ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": advancing toward reported sector without visual cover"); + } + if (found) support = chosen; else if (armed) { orders.Clear(); Claim(vehicle.Group, support, GetGame().GetWorld().GetWorldTime()); - ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": no clear firing lane found; holding and retrying at " + support.ToString()); + ERR_HQ.Event("CONVOY SUPPORT", m_Task.Receipt.SourceId + " vehicle " + index.ToString() + ": no safe approach found; next check searches another angle from " + support.ToString()); return; } if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20) @@ -461,6 +497,7 @@ AIWaypoint movement = Move(support); if (!movement) return; Command(movement); orders.Clear(); orders.Add(movement); + orders.SupportProgressAt = now; orders.SupportProgressPosition = vehicle.Entity.GetOrigin(); ERR_HQ.Event("CONVOY SUPPORT", string.Format("%1 vehicle %2: position=%3 destination=%4 reported contact=%5 armed=%6", m_Task.Receipt.SourceId, index, vehicle.Entity.GetOrigin(), support, m_Contact, armed)); } else orders.Clear(); --- before/tests/test_hunter_tactics.py +++ after/tests/test_hunter_tactics.py @@ -38,7 +38,7 @@ } } ''') -tick=body(p,'bool Tick(ERR_HQ hq, float now)') +tick='\n'.join(body(p,x) for x in ['protected bool RecoverApproach(', 'protected bool UpdateCover(', 'bool Tick(ERR_HQ hq, float now)']).replace('protected ', '').replace('bool assigned;', 'bool assigned=false;').replace('m_ApproachRetries.ToString()', 'std::to_string(m_ApproachRetries)').replace('assigned.ToString()', 'std::to_string(assigned)') for typ in ['ERR_HQ','IEntity','AIWaypoint']: tick=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',tick) for n in ['Radio','hq','Unit','Attack','search','coordination','m_GunTeam']: @@ -60,6 +60,8 @@ struct AttackType{Vec ReportPosition;float Started=0;bool alive=true,ready=false;bool CoverAlive(){return alive;}bool CoverReady(){return ready;}}; struct GunTeam{int ticks=0;void Tick(float){ticks++;}void Pause(){}}; struct Tactics{ + int m_ApproachRetries=0,m_CoverRetries=0,alternates=0;float m_BlindSince=-1;bool alternateWorks=true; + bool AlternateStage(float now){alternates++;if(!alternateWorks)return false;State=1;m_BlindSince=-1;m_ApproachProgress=now;Stage={500,0,-250};return true;} GunTeam*m_GunTeam=nullptr;bool align=false,facing=false;bool TryAlign(){return align;}bool FacingSector(){return facing;} UnitType*Unit=nullptr;AttackType*Attack=nullptr;RadioType*Radio=nullptr;int Role=1,State=0,Retries=0;float m_TravelDistance=0,Deadline=0,NextRetry=0,m_ApproachStarted=0,m_ApproachProgress=0;Vec m_ApproachPosition;bool m_UnsupportedSearch=false,Delivered=false,Stopped=false,living=true,aboard=true,moveWorks=true,boardWorks=true,searchWorks=true;int clears=0,moves=0,boards=0;Vec Stage,Approach,Sector,position;AIWaypoint search; bool visual=false;bool VisualCover(){return visual;}bool Living(){return living;}void Stop(){Stopped=true;Radio=nullptr;Clear();if(Delivered)Unit->CombatHeld=false;}bool Move(Vec,float=20){moves++;return moveWorks;}void Clear(){clears++;}bool Board(bool){boards++;return boardWorks;}bool Aboard(bool required){return aboard==required;}Vec Position(){return position;}void VehicleUse(bool){}void Note(string){}void ObserveState(float){}void Add(AIWaypoint*){}AIWaypoint*SpawnSearch(Vec){return searchWorks?&search:nullptr;} @@ -69,6 +71,20 @@ Fixture(){car.pos={0,0,-250};unit.Entity=&car;unit.Group=&group;unit.Members={&crew};t.Unit=&unit;t.Attack=&attack;t.Radio=&radio;t.Stage=car.pos;t.Approach={-170,0,-85};t.Sector={-105,0,35};t.position=car.pos;} }; int main(){ + {Fixture f;f.radio.result=1;f.t.Role=0;f.t.Tick(&f.hq,1000);assert(f.t.State==3); + f.t.Tick(&f.hq,20999);assert(f.t.alternates==0);f.t.Tick(&f.hq,21000);assert(f.t.alternates==1&&f.t.State==1);} + {Fixture f;f.radio.result=1;f.t.Role=0;f.t.visual=true;f.t.Tick(&f.hq,1000);f.t.Tick(&f.hq,120000);assert(f.t.State==3&&f.t.alternates==0);} + {Fixture f;f.radio.result=1;f.t.Role=0;f.t.alternateWorks=false;f.t.Tick(&f.hq,1000); + f.t.Tick(&f.hq,21000);f.t.Tick(&f.hq,22000);assert(f.t.alternates==1); + f.t.Tick(&f.hq,41000);f.t.Tick(&f.hq,61000);assert(f.t.alternates==3&&!f.t.Stopped); + assert(!f.t.Tick(&f.hq,81000)&&f.t.Stopped);} + {Fixture f;f.radio.result=1;f.t.Role=0;f.t.alternateWorks=false;f.t.m_TravelDistance=1000;f.car.pos={0,0,-1100};f.t.Stage={0,0,-210}; + f.t.Tick(&f.hq,1000);f.t.Tick(&f.hq,30999);assert(f.t.moves==1); + f.t.Tick(&f.hq,31000);assert(f.t.moves==2&&f.t.m_ApproachRetries==1); + f.t.Tick(&f.hq,61000);assert(f.t.alternates==1&&f.t.m_ApproachRetries==2); + assert(!f.t.Tick(&f.hq,91000)&&f.t.Stopped);} + {Fixture f;f.radio.result=1;f.t.Role=0;f.t.m_TravelDistance=1000;f.car.pos={0,0,-1100};f.t.Stage={0,0,-210}; + f.t.Tick(&f.hq,1000);for(int i=1;i<=5;i++){f.car.pos.z+=20;f.t.Tick(&f.hq,1000+i*20000);}assert(f.t.m_ApproachRetries==0&&!f.t.Stopped);} {Fixture f;f.radio.result=1;f.t.Role=0;f.t.Stage={0,0,-500};f.t.visual=true; assert(f.t.Tick(&f.hq,1000)&&f.t.State==3); // sees fight at 250m: stop, don't back away to stage holds=0; @@ -194,3 +210,35 @@ assert(Approach(contact,contact,fallback).z==1); } ''') + +# Execute alternate-stage commit/rollback and reservations, not a duplicate planner. +alternate=body(p,'protected bool AlternateStage(').replace('protected ','').replace('array', 'Array').replace('foreach (', 'for (').replace('vector ', 'Vec ').replace('ERR_HunterTactics other', 'Tactics* other') +for name in ['Unit','Attack','other']: + alternate=alternate.replace(name+'.',name+'->') +alternate=alternate.replace('Entity.GetOrigin()', 'Entity->GetOrigin()').replace('Math.Min', 'std::min').replace('ERR_HunterAttackRules.ManeuverBudget', 'ManeuverBudget').replace('m_ApproachStarted + 300000', 'm_ApproachStarted + 300000.f') +run(stubs.replace('templateusing Array=std::vector;', 'templatestruct Array:std::vector{using std::vector::vector;void Insert(T v){this->push_back(v);}};')+r''' +struct Entity{Vec point{0,0,-600};Vec GetOrigin(){return point;}}; +struct UnitType{struct Entity entity;struct Entity*Entity=&entity;}; +struct Tactics;struct AttackType{ArrayTeams;}; +float ManeuverBudget(float){return 240000;} +struct Tactics{ + UnitType unit;UnitType*Unit=&unit;AttackType attack;AttackType*Attack=&attack; + Vec Stage{0,0,-210},Approach{155,0,-240},Sector{105,0,35},m_ApproachPosition; + Arraym_RejectedStages,reservations;int State=3;float m_BlindSince=1000,m_ApproachProgress=0,m_ApproachStarted=1000,m_TravelDistance=1000,Deadline=100; + bool live=true,plan=true,move=true;bool Living(){return live;} + bool Plan(Array occupied){reservations=occupied;Stage={200,0,-210};Approach={200,0,-85};Sector={105,0,35};return plan;} + bool Move(Vec,float){return move;}void Note(string){} +'''+alternate+r''' +}; +int main(){ + for(int failure=0;failure<2;failure++){ + Tactics t;t.plan=failure!=0;t.move=failure!=1; + assert(!t.AlternateStage(21000));assert(t.Stage.x==0&&t.Approach.x==155&&t.State==3&&t.m_RejectedStages.empty()); + } + Tactics t,other,dead;dead.live=false;other.Stage={-200,0,-210};t.attack.Teams={&t,&other,&dead};t.m_RejectedStages.Insert({-100,0,-300}); + assert(t.AlternateStage(21000));assert(t.reservations.size()==4&&t.reservations.back().x==-200); + assert(t.State==1&&t.Stage.x==200&&t.m_BlindSince==-1&&t.m_ApproachProgress==21000&&t.m_RejectedStages.size()==2); + assert(t.Deadline==261000);assert(t.AlternateStage(200000)&&t.Deadline==301000); // retries cannot extend the total approach budget +} +''') +print('PASS: alternate positions exclude failed and occupied stages, roll back on failed planning/allocation, and preserve the total time budget') --- before/tests/test_convoy_column.py +++ after/tests/test_convoy_column.py @@ -327,15 +327,15 @@ support=support.replace(name+'.',name+'->') support=support.replace('m_Task->Receipt.', 'm_Task->Receipt->').replace('Entity.GetOrigin()', 'Entity->GetOrigin()').replace('Orders[0].', 'Orders[0]->') support=support.replace('vector ', 'Vec ').replace('Vector(', 'Vec(').replace('vector.DistanceXZ', 'Distance') -support=support.replace('ERR_MountedCover.', 'ERR_MountedCover::').replace('GetGame().GetWorld().GetWorldTime()', '1000') +support=support.replace('ERR_MountedCover.', 'ERR_MountedCover::').replace('GetGame().GetWorld().GetWorldTime()', 'clockNow') support=support.replace('ERR_HQ.Event', 'Event').replace('string.Format', 'Format').replace('index.ToString()', 'std::to_string(index)') -support=support.replace('float rear = 180, side;', 'float rear = 180, side=0;') +support=support.replace('float rear = 180, side;', 'float rear = 180, side=0;').replace('advance += left * 70', 'advance = advance + left * 70').replace('advance -= left * 70', 'advance = advance - left * 70') run(r''' #include #include #include #include -using string=std::string; +using string=std::string;float clockNow=1000; struct Vec{float x=0,y=0,z=0;Vec(){}Vec(float a,float b,float c):x(a),y(b),z(c){}float operator[](int i){return i==0?x:i==1?y:z;}Vec operator-(Vec b){return{x-b.x,y-b.y,z-b.z};}Vec operator+(Vec b){return{x+b.x,y+b.y,z+b.z};}Vec operator*(float n){return{x*n,y*n,z*n};}string ToString(){return "point";}}; float Distance(Vec a,Vec b){return std::hypot(a.x-b.x,a.z-b.z);} templatestruct Array:std::vector{using std::vector::vector;bool IsEmpty(){return this->empty();}}; @@ -343,17 +343,26 @@ struct AIWaypoint{Vec point;Vec GetOrigin(){return point;}}; struct Entity{Vec point{0,0,-250};Vec GetOrigin(){return point;}}; struct ERR_ConvoyVehicle{struct Entity entity;struct Entity* Entity=&entity;void* Group=nullptr;}; -struct ERR_SupplyTeam{ArrayOrders;int clears=0;void Clear(){Orders.clear();clears++;}void Add(AIWaypoint*p){Orders.push_back(p);}}; +struct ERR_SupplyTeam{float SupportProgressAt=-1;Vec SupportProgressPosition;int SupportSearchStep=0;ArrayOrders;int clears=0;void Clear(){Orders.clear();clears++;}void Add(AIWaypoint*p){Orders.push_back(p);}}; struct Receipt{string SourceId="convoy";};struct Task{struct Receipt receipt;struct Receipt* Receipt=&receipt;ArrayVehicles;}; namespace ERR_MountedCover{bool sees=false;bool SeesSector(void*,Vec){return sees;}Vec Approach(Vec,Vec,Vec){return{0,0,1};}} struct Tactics{Task task;Task*m_Task=&task;Arraym_Support;Vec m_Contact,m_Forward{0,0,1};bool usable=true,position=true,spawn=true;int plans=0,moves=0,claims=0;AIWaypoint waypoint; - bool Usable(int){return usable;}bool SupportPosition(int,Vec,Vec&out){plans++;out={100,0,-180};return position;}AIWaypoint*Move(Vec p){moves++;waypoint.point=p;return spawn?&waypoint:nullptr;}void Command(AIWaypoint*){}void Claim(void*,Vec,float){claims++;} + bool fallback=false;Array attempted;bool Usable(int){return usable;}bool SupportPosition(int,Vec desired,Vec&out,bool lane=true){plans++;attempted.push_back(desired);out=desired;return lane?position:fallback;}AIWaypoint*Move(Vec p){moves++;waypoint.point=p;return spawn?&waypoint:nullptr;}void Command(AIWaypoint*){}void Claim(void*,Vec,float){claims++;} '''+support+r''' }; struct Case{Tactics t;ERR_ConvoyVehicle car;ERR_SupplyTeam orders;AIWaypoint old; - Case(){t.task.Vehicles=Array(6,&car);t.m_Support=Array(6,&orders);old.point={0,0,-500};orders.Orders={&old};ERR_MountedCover::sees=false;} + Case(){clockNow=1000;t.task.Vehicles=Array(6,&car);t.m_Support=Array(6,&orders);old.point={0,0,-500};orders.Orders={&old};ERR_MountedCover::sees=false;} }; int main(){ + {Case c;c.t.Support(4,false,true);clockNow=30999;c.t.Support(4,false,true);assert(c.t.plans==0); + clockNow=31000;c.t.Support(4,false,true);assert(c.t.moves==1&&c.t.plans==1);} + {Case c;c.t.Support(4,false,true);clockNow=21000;c.car.entity.point.x+=20;c.t.Support(4,false,true); + clockNow=41000;c.car.entity.point.x+=20;c.t.Support(4,false,true);assert(c.t.plans==0&&c.t.moves==0);} + {Case c;c.t.position=false;c.t.Support(4,false);c.t.Support(4,false);c.t.Support(4,false); + assert(c.t.attempted.size()==3&&c.t.attempted[0].x!=c.t.attempted[1].x&&c.t.attempted[1].x!=c.t.attempted[2].x);} + {Case c;c.car.entity.point={0,0,-650};c.t.position=false;c.t.fallback=true;c.t.Support(4,false); + assert(c.t.plans==2&&c.t.moves==1&&c.t.waypoint.point.z==-500);} + {Case c;c.car.entity.point={0,0,-650};c.t.position=false;c.t.Support(4,false);assert(c.t.plans==2&&c.t.moves==0);} {Case c;ERR_MountedCover::sees=true;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.plans==0&&c.t.moves==0&&c.t.claims==1);} {Case c;c.t.Support(4,false,true);assert(c.orders.Orders[0]==&c.old&&c.t.plans==0&&c.t.moves==0);} // driver in transit {Case c;c.old.point=c.car.entity.point;c.t.Support(4,false,true);assert(c.t.plans==1&&c.t.moves==1&&c.orders.Orders[0]==&c.t.waypoint);} // masked arrived gun retries @@ -364,3 +373,53 @@ } ''') print('PASS: actual convoy support preserves visual cover and ongoing approach, retries masked arrived positions, withholds unsafe moves and preserves ammo withdrawal') + +# Exercise the actual geometry boundary for both firing positions and the +# deliberately non-firing intermediate approach used by distant escorts. +position=body(q,'protected bool SupportPosition(').replace('protected ','').replace('out vector ', 'Vec& ').replace('vector ', 'Vec ').replace('Vector(', 'Vec(') +for typ in ('ERR_ConvoyVehicle','SCR_AIGroupUtilityComponent'): + position=position.replace(typ+' ',typ+'* ') +position=position.replace('ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();','ERR_ConvoyPlacement placement;') +for name in ('m_Task','vehicle','utility'): + position=position.replace(name+'.',name+'->') +position=position.replace('Group.GetGroupUtilityComponent()', 'Group->GetGroupUtilityComponent()').replace('Entity.GetOrigin()', 'Entity->GetOrigin()') +for cls in ('ERR_MountedCover','ERR_SupplyConvoySystem','ERR_Actors','ERR_ForceRules','Math','vector'): + position=position.replace(cls+'.',cls+'::') +position=position.replace('candidate += Vec(', 'candidate = candidate + Vec(') +run(r''' +#include +#include +#include +struct Vec{float x=0,y=0,z=0;Vec(){}Vec(float a,float b,float c):x(a),y(b),z(c){}Vec operator-(Vec b){return{x-b.x,y-b.y,z-b.z};}Vec operator+(Vec b){return{x+b.x,y+b.y,z+b.z};}}; +namespace vector{float DistanceXZ(Vec a,Vec b){return std::hypot(a.x-b.x,a.z-b.z);}float Dot(Vec a,Vec b){return a.x*b.x+a.y*b.y+a.z*b.z;}} +struct SCR_AIGroupUtilityComponent{bool allowed=true;bool IsPositionAllowed(Vec){return allowed;}}; +struct Group{SCR_AIGroupUtilityComponent utility;auto GetGroupUtilityComponent(){return &utility;}}; +struct Entity{Vec pos{0,0,-650};Vec GetOrigin(){return pos;}}; +struct ERR_ConvoyVehicle{struct Entity entity;struct Entity*Entity=&entity;struct Group group;struct Group*Group=&group;}; +struct Task{std::vectorVehicles;}; +namespace ERR_MountedCover{bool lane=true;Vec Approach(Vec,Vec,Vec){return{0,0,1};}bool ClearLane(Entity*,Vec,Vec){return lane;}} +namespace ERR_SupplyConvoySystem{bool road=false;bool Approach(Vec p,Vec&out,float){out=p;return road;}} +namespace ERR_Actors{Vec Ground(Vec p){return p;}} +namespace ERR_ForceRules{float Heading(Vec,Vec,int){return 0;}} +namespace Math{float RandomFloat(float,float){return 0;}} +struct ERR_ConvoyPlacement{inline static bool clear=true;bool Clear(Vec,float,float){return clear;}}; +struct Tactics{Task task;Task*m_Task=&task;Vec m_Contact,m_Forward;bool free=true;bool FreePosition(Group*,Vec){return free;} +'''+position+r''' +}; +int main(){ + Tactics t;ERR_ConvoyVehicle car;t.task.Vehicles=std::vector(6,&car);Vec out; + for(bool road : {false,true}){ + ERR_SupplyConvoySystem::road=road;ERR_MountedCover::lane=false; + assert(!t.SupportPosition(4,{0,0,-500},out)); // masked point is not firing cover + assert(t.SupportPosition(4,{0,0,-500},out,false)); // explicit intermediate approach + assert(!t.SupportPosition(4,{0,0,-650},out,false)); // cannot select the current blind hold + assert(!t.SupportPosition(4,{0,0,-90},out,false)); // cannot rush through the reported sector + t.free=false;assert(!t.SupportPosition(4,{0,0,-500},out,false));t.free=true; + car.group.utility.allowed=false;assert(!t.SupportPosition(4,{0,0,-500},out,false));car.group.utility.allowed=true; + ERR_MountedCover::lane=true;assert(t.SupportPosition(4,{0,0,-500},out)); + } + ERR_SupplyConvoySystem::road=false;ERR_ConvoyPlacement::clear=false;assert(!t.SupportPosition(4,{0,0,-500},out,false)); + assert(!t.SupportPosition(1,{0,0,-500},out)); // trucks still require roads +} +''') +print('PASS: production support geometry rejects occupied, disallowed, blocked and too-close positions; only explicit intermediate approaches waive the firing-lane requirement')