--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c @@ -52,13 +52,13 @@ bool CoverAlive() { foreach (ERR_HunterTactics team : Teams) - if (team.Role == 0 && team.Delivered && team.ArmedCover()) return true; + if (team.Delivered && team.ArmedCover()) return true; return false; } bool CoverReady() { foreach (ERR_HunterTactics team : Teams) - if (team.Role == 0 && team.Covering()) return true; + if (team.Covering()) return true; return false; } } @@ -73,7 +73,7 @@ vector Sector; ref ERR_HQRoute Radio; ref array Orders = {}; - int State; // awaiting radio, approach, dismount, wait for cover, flank, search, return, finished + int State; // 0 radio, 1 approach, 2 dismount, 3 cover, 4 flank, 5 search, 6 return, 8 short alignment float Deadline; float NextRetry; int Retries; @@ -86,6 +86,7 @@ protected float m_ApproachProgress; protected vector m_ApproachPosition; protected bool m_UnsupportedSearch; + protected ref ERR_HunterGunTeam m_GunTeam; protected ERR_GameMode m_Mode; void ERR_HunterTactics(ERR_GameMode mode, ERR_HunterVehicle unit, ERR_HunterAttack attack, int role) { @@ -109,12 +110,18 @@ foreach (IEntity member : Unit.Members) { if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Unit.Group) continue; + if (!required && m_GunTeam && m_GunTeam.Retained(member)) continue; if ((CompartmentAccessComponent.GetVehicleIn(member) == Unit.Entity) != required) return false; } return true; } vector Position() { + // The native group leader can be the stationary gunner. Infantry progress + // must follow an original dismounted member rather than the parked vehicle. + if (m_GunTeam) + foreach (IEntity member : Unit.Members) + if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Unit.Group && !CompartmentAccessComponent.GetVehicleIn(member)) return member.GetOrigin(); IEntity leader = Unit.Group.GetLeaderEntity(); if (leader) return leader.GetOrigin(); return Unit.Entity.GetOrigin(); @@ -141,8 +148,18 @@ ERR_HQMountedAmmoSnapshot ammo = ERR_HQMountedAmmoSampler.Inspect(unit.Entity); return ammo && ammo.Complete && ammo.Rounds + ammo.Chambers > 0; } - bool ArmedCover() { return Living() && Delivered && Role == 0 && State == 3 && CoverCrew(Unit, m_Mode); } - bool Covering() { return ArmedCover() && VisualCover(); } + bool ArmedCover() + { + if (!Living() || !Delivered) return false; + if (m_GunTeam && State >= 3 && State <= 5) return m_GunTeam.Cover(); + return Role == 0 && State == 3 && CoverCrew(Unit, m_Mode); + } + bool Covering() + { + if (!ArmedCover()) return false; + if (m_GunTeam) return m_GunTeam.Visual(Attack.ReportPosition); + return VisualCover(); + } protected void Clear() { foreach (AIWaypoint waypoint : Orders) @@ -179,6 +196,15 @@ } } SCR_AIWaypoint commanded = SCR_AIWaypoint.Cast(move); + if (m_GunTeam) + { + vector via; if (corner) via = corner.GetOrigin(); + bool issued = m_GunTeam.Move(position, via); + SCR_EntityHelper.DeleteEntityAndChildren(move); + if (corner) SCR_EntityHelper.DeleteEntityAndChildren(corner); + if (!issued) return false; + Clear(); m_TravelDistance = travel; return true; + } if (commanded) commanded.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER); SCR_AIWaypoint first = SCR_AIWaypoint.Cast(corner); if (first) first.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER); @@ -196,6 +222,22 @@ } protected bool Board(bool enter) { + if (!enter) + { + if (!m_GunTeam) + { + m_GunTeam = new ERR_HunterGunTeam(Unit); + if (!m_GunTeam.Start()) m_GunTeam = null; + } + if (m_GunTeam) + { + Clear(); + if (Unit.Recovery) Unit.Recovery.SuspendBoarding(); + VehicleUse(false); + Note("driver and passengers dismount; original loaded gunner stays to cover"); + return true; + } + } AIWaypoint created = ERR_SupplyConvoyTactics.Board(Unit.Entity, enter); SCR_BoardingWaypoint waypoint = SCR_BoardingWaypoint.Cast(created); if (!waypoint) @@ -203,10 +245,37 @@ if (created) SCR_EntityHelper.DeleteEntityAndChildren(created); return false; } - if (enter) VehicleUse(true); + if (enter) + { + if (m_GunTeam) { m_GunTeam.Stop(); m_GunTeam = null; } + VehicleUse(true); + } waypoint.SetPriorityLevel(SCR_AIActionBase.PRIORITY_LEVEL_PLAYER); waypoint.SetAllowance(true, true, true); Clear(); Add(waypoint); return true; + } + protected bool FacingSector() + { + vector direction = Attack.ReportPosition - Unit.Entity.GetOrigin(); direction[1] = 0; + if (direction.Length() < 1) return true; + vector transform[4]; Unit.Entity.GetTransform(transform); + vector forward = transform[2]; forward[1] = 0; + return vector.Dot(forward.Normalized(), direction.Normalized()) >= 0.707; + } + protected bool TryAlign() + { + // Best-effort native steering, not rotation/teleporting. Never delay an + // already engaged crew or drive a vehicle into the reported enemy position. + if (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || !ERR_HunterGunTeam.LoadedGunner(Unit)) return false; + vector origin = Unit.Entity.GetOrigin(); + vector direction = Attack.ReportPosition - origin; direction[1] = 0; + if (direction.Length() < 180) return false; + vector point = ERR_Actors.Ground(origin + direction.Normalized() * 20); + SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent(); + if (!utility || !utility.IsPositionAllowed(point) || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) return false; + ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement(); + if (!placement.Clear(point, ERR_ForceRules.Heading(origin, point, 0))) return false; + return Move(point, 5); } protected void Note(string detail) { @@ -328,7 +397,12 @@ // around it to reach the ideal staging point. Continue that last leg on foot. bool onFootReach = ERR_HunterAttackRules.DismountRange(vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition)); if (Role == 0 && near) { Clear(); State = 3; Note("in covering position; native gunner owns target acquisition and fire"); } - else if (Role > 0 && (near || local || onFootReach) && Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; Note("within infantry approach range; dismounting original crew"); } + else if (Role > 0 && (near || local || onFootReach)) + { + if (TryAlign()) { State = 8; Deadline = now + 20000; Note("short native positioning move toward reported sector before dismount"); } + else if (Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; Note("within infantry approach range; dismounting assault crew"); } + else if (now >= Deadline) { Stop(); return false; } + } else if (now >= Deadline) { if (ERR_HunterAttackRules.ExtendApproach(m_ApproachStarted, m_ApproachProgress, now)) @@ -336,6 +410,12 @@ else { Note("approach made no recent progress or exhausted five-minute budget; release this assignment"); Stop(); return false; } } } + if (State == 8 && (FacingSector() || ERR_HunterCombat.Active(Unit.Group) || now >= Deadline)) + { + if (Board(false)) { State = 2; Deadline = now + 45000; NextRetry = now + 15000; } + else if (now >= Deadline) { Stop(); return false; } + } + if (m_GunTeam && State >= 2 && State <= 5) m_GunTeam.Tick(now); if (State == 2) { if (Aboard(false)) { Clear(); VehicleUse(false); State = 3; Deadline = now + 45000; Note("on foot; waiting for covering team"); } @@ -362,11 +442,18 @@ } } if (State == 4 && !m_UnsupportedSearch && !Attack.CoverAlive()) - { Clear(); State = 3; Deadline = now + 45000; Note("covering crew or mounted ammunition unavailable; manoeuvre paused"); } + { if (m_GunTeam) m_GunTeam.Pause(); Clear(); State = 3; Deadline = now + 45000; Note("covering crew or mounted ammunition unavailable; manoeuvre paused"); } if (State == 4 && vector.DistanceXZ(Position(), Approach) <= 30) { - AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), Sector)); - if (search) { search.SetCompletionRadius(25); Clear(); Add(search); State = 5; Deadline = now + 60000; Note("flank reached; searching reported sector=" + Sector.ToString()); } + if (m_GunTeam) + { + if (Move(Sector, 25)) { State = 5; Deadline = now + 60000; Note("flank reached; original infantry investigating sector under mounted cover=" + Sector.ToString()); } + } + else + { + AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), Sector)); + if (search) { search.SetCompletionRadius(25); Clear(); Add(search); State = 5; Deadline = now + 60000; Note("flank reached; searching reported sector=" + Sector.ToString()); } + } } if ((State == 4 || State == 5) && now >= Deadline) { State = 6; Deadline = now + 90000; Board(true); Note("bounded search complete; regrouping original survivors"); } @@ -378,6 +465,7 @@ void Stop() { if (Stopped) return; + if (m_GunTeam) { m_GunTeam.Stop(); m_GunTeam = null; } VehicleUse(true); Stopped = true; Radio = null; Clear(); ERR_HQ coordination = ERR_HQ.Get(); --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterCloseContact.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterCloseContact.c @@ -59,7 +59,7 @@ foreach (IEntity member : m_Unit.Members) { if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != m_Unit.Group) continue; - if (IsTurretOccupant(member)) { m_Gunner = member; ERR_ConvoyMountedCrew.Hold(member, m_Unit.Entity); continue; } + if (IsTurretOccupant(member) && ERR_HunterGunTeam.LoadedGunner(m_Unit) == member) { m_Gunner = member; ERR_ConvoyMountedCrew.Hold(member, m_Unit.Entity); continue; } ERR_ConvoyMountedCrew.Release(member); ERR_HunterExitTask task = new ERR_HunterExitTask(); task.Actor = member; m_Tasks.Insert(task); } @@ -70,11 +70,17 @@ m_Unit.CombatHeld = true; m_Unit.CombatQuietUntil = m_QuietUntil; // Cancel a prior driver recovery action before it can compete with getting out. if (m_Unit.Recovery) m_Unit.Recovery.SuspendBoarding(); + if (m_Gunner && ERR_HunterGunTeam.LoadedGunner(m_Unit) != m_Gunner) + { + ERR_ConvoyMountedCrew.Release(m_Gunner); + ERR_HunterExitTask released = new ERR_HunterExitTask(); released.Actor = m_Gunner; m_Tasks.Insert(released); m_Gunner = null; + ERR_HQ.Event("HUNTER CLOSE CONTACT", m_Unit.Source + ": gun or vehicle no longer usable; gunner allowed to dismount"); + } foreach (ERR_HunterExitTask exitTask : m_Tasks) { IEntity actor = exitTask.Actor; if (!actor) continue; - if (!ERR_HunterCloseRules.Dismount(m_Unit.Members.Contains(actor), ERR_Actors.CanFight(actor), ERR_Actors.Player(actor), ERR_Actors.Group(actor) == m_Unit.Group, CompartmentAccessComponent.GetVehicleIn(actor) == m_Unit.Entity, IsTurretOccupant(actor))) continue; + if (!ERR_HunterCloseRules.Dismount(m_Unit.Members.Contains(actor), ERR_Actors.CanFight(actor), ERR_Actors.Player(actor), ERR_Actors.Group(actor) == m_Unit.Group, CompartmentAccessComponent.GetVehicleIn(actor) == m_Unit.Entity, actor == m_Gunner)) continue; Physics physics = m_Unit.Entity.GetPhysics(); if (!physics || physics.GetVelocity().Length() > 3) continue; SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(actor)); --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterGunTeam.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterGunTeam.c @@ -0,0 +1,140 @@ +// One original crew, two roles: mounted gun support and dismounted infantry. +// Individual exit orders avoid a whole-group GetOut activity pulling the gunner out. +class ERR_HunterFootTask +{ + IEntity Actor; + ref SCR_AIMoveIndividuallyBehavior Action; + vector Destination; + bool Via; +} +class ERR_HunterGunTeam +{ + protected ERR_HunterVehicle m_Unit; + protected IEntity m_Gunner; + protected ref array m_Exits = {}; + protected ref array m_Foot = {}; + void ERR_HunterGunTeam(ERR_HunterVehicle unit) { m_Unit = unit; } + static IEntity LoadedGunner(ERR_HunterVehicle unit) + { + if (!unit || !unit.Entity || !unit.Group || unit.Released || ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) return null; + SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(unit.Entity.FindComponent(SCR_VehicleDamageManagerComponent)); + if (!damage || damage.GetState() == EDamageState.DESTROYED || damage.GetHealthScaled() <= 0.25 || !damage.GetEngineFunctional() || !damage.GetGearboxFunctional()) return null; + ERR_HQMountedAmmoSnapshot ammo = ERR_HQMountedAmmoSampler.Inspect(unit.Entity); + if (!ammo || !ammo.Complete || ammo.Rounds + ammo.Chambers <= 0) return null; + foreach (BaseCompartmentSlot seat : unit.Seats) + { + if (!seat || seat.GetType() != ECompartmentType.TURRET) continue; + IEntity actor = seat.GetOccupant(); + if (unit.Members.Contains(actor) && ERR_Actors.CanFight(actor) && !ERR_Actors.Player(actor) && ERR_Actors.Group(actor) == unit.Group) return actor; + } + return null; + } + bool Retained(IEntity actor) { return actor && actor == m_Gunner && CompartmentAccessComponent.GetVehicleIn(actor) == m_Unit.Entity; } + bool Cover() { return m_Gunner && LoadedGunner(m_Unit) == m_Gunner; } + bool Visual(vector sector) + { + if (!Cover()) return false; + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_Gunner)); + if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo || !agent.m_UtilityComponent.m_AIInfo.m_Perception) return false; + array targets = {}; + agent.m_UtilityComponent.m_AIInfo.m_Perception.GetTargetsList(targets, ETargetCategory.ENEMY); + foreach (BaseTarget target : targets) + if (target && ERR_HQObservation.IsClearSighting(target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure()) && vector.DistanceXZ(target.GetLastSeenPosition(), sector) <= 200) return true; + return false; + } + protected void Exit(IEntity actor) + { + foreach (ERR_HunterExitTask existing : m_Exits) if (existing.Actor == actor) return; + ERR_HunterExitTask task = new ERR_HunterExitTask(); task.Actor = actor; m_Exits.Insert(task); + } + bool Start() + { + m_Gunner = LoadedGunner(m_Unit); + if (!m_Gunner) return false; + ERR_ConvoyMountedCrew.Hold(m_Gunner, m_Unit.Entity); + foreach (IEntity actor : m_Unit.Members) if (actor != m_Gunner) Exit(actor); + return true; + } + protected void ClearFoot() + { + foreach (ERR_HunterFootTask task : m_Foot) + { + if (!task.Actor || !task.Action) continue; + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(task.Actor)); + if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) continue; + array actions = {}; agent.m_UtilityComponent.GetActions(actions); + if (actions.Contains(task.Action)) task.Action.Complete(); + } + m_Foot.Clear(); + } + void Pause() { ClearFoot(); } + bool Move(vector destination, vector via = "0 0 0") + { + // Native group movement waits for its mounted member. Give the original + // foot soldiers their own orders without changing groups or HQ identities. + array replacement = {}; + foreach (IEntity actor : m_Unit.Members) + { + if (Retained(actor) || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != m_Unit.Group || CompartmentAccessComponent.GetVehicleIn(actor)) continue; + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(actor)); + if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) continue; + ERR_HunterFootTask task = new ERR_HunterFootTask(); task.Actor = actor; task.Destination = destination; task.Via = via != vector.Zero; + vector point = destination; if (task.Via) point = via; + task.Action = new SCR_AIMoveIndividuallyBehavior(agent.m_UtilityComponent, null, point, 1000, SCR_AIActionBase.PRIORITY_LEVEL_PLAYER, null, 8); + replacement.Insert(task); + } + if (replacement.IsEmpty()) return false; + ClearFoot(); + foreach (ERR_HunterFootTask replacementTask : replacement) m_Foot.Insert(replacementTask); + foreach (ERR_HunterFootTask pending : m_Foot) + { + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(pending.Actor)); + agent.m_UtilityComponent.AddAction(pending.Action); + } + return true; + } + void Tick(float now) + { + if (m_Gunner && !Cover()) + { + ERR_ConvoyMountedCrew.Release(m_Gunner); Exit(m_Gunner); m_Gunner = null; + ERR_HQ.Event("HUNTER GUN TEAM", m_Unit.Source + ": mounted support unavailable; gunner released to join original infantry"); + } + foreach (ERR_HunterExitTask task : m_Exits) + { + IEntity actor = task.Actor; + if (!ERR_HunterCloseRules.Dismount(m_Unit.Members.Contains(actor), ERR_Actors.CanFight(actor), ERR_Actors.Player(actor), ERR_Actors.Group(actor) == m_Unit.Group, CompartmentAccessComponent.GetVehicleIn(actor) == m_Unit.Entity, Retained(actor))) continue; + Physics physics = m_Unit.Entity.GetPhysics(); + if (!physics || physics.GetVelocity().Length() > 3) continue; + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(actor)); + if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) continue; + array actions = {}; agent.m_UtilityComponent.GetActions(actions); + if (task.Action && actions.Contains(task.Action)) continue; + if (now < task.NextAttempt || task.Attempts >= 3) continue; + ERR_VehicleBehaviorCleanup.Complete(task.Action, actor); + task.Attempts++; task.NextAttempt = now + 10000; + task.Action = new SCR_AIGetOutVehicle(agent.m_UtilityComponent, null, m_Unit.Entity, priority: 1000); + agent.m_UtilityComponent.AddAction(task.Action); + } + foreach (ERR_HunterFootTask foot : m_Foot) + { + if (!foot.Via || !ERR_Actors.CanFight(foot.Actor) || ERR_Actors.Player(foot.Actor) || ERR_Actors.Group(foot.Actor) != m_Unit.Group) continue; + if (vector.DistanceXZ(foot.Actor.GetOrigin(), foot.Action.m_vPosition.m_Value) > 10) continue; + SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(foot.Actor)); + if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) continue; + array actions = {}; agent.m_UtilityComponent.GetActions(actions); + if (actions.Contains(foot.Action)) foot.Action.Complete(); + foot.Via = false; + foot.Action = new SCR_AIMoveIndividuallyBehavior(agent.m_UtilityComponent, null, foot.Destination, 1000, SCR_AIActionBase.PRIORITY_LEVEL_PLAYER, null, 8); + agent.m_UtilityComponent.AddAction(foot.Action); + } + } + void Stop() + { + ClearFoot(); + foreach (ERR_HunterExitTask task : m_Exits) ERR_VehicleBehaviorCleanup.Complete(task.Action, task.Actor); + m_Exits.Clear(); + if (m_Gunner) ERR_ConvoyMountedCrew.Release(m_Gunner); + m_Gunner = null; + } +} --- a/tests/test_hunter_tactics.py +++ b/tests/test_hunter_tactics.py @@ -41,7 +41,7 @@ tick=body(p,'bool Tick(ERR_HQ hq, float now)') 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']: +for n in ['Radio','hq','Unit','Attack','search','coordination','m_GunTeam']: tick=tick.replace(n+'.',n+'->') tick=tick.replace('ERR_HQ.Get()', 'ERR_HQ::Get()').replace('vector reserved', 'Vec reserved').replace('AttackSpace().Claim', 'AttackSpace()->Claim') tick=tick.replace('foreach (IEntity* crew : Unit->Members)','for (IEntity* crew : Unit->Members)') @@ -58,7 +58,9 @@ int holds=0;void Hold(IEntity*,IEntity*){holds++;} struct UnitType{struct GroupType*Group=nullptr;IEntity*Entity=nullptr;ArrayMembers;void*Downlink=nullptr;bool CombatHeld=false;float CombatQuietUntil=0;int clears=0;void ClearOrder(){clears++;}}; 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{ + 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 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;} '''+tick+r''' @@ -79,6 +81,9 @@ {Fixture f;f.radio.result=1;f.t.Stage={1000,0,1000};f.t.Tick(&f.hq,1000);assert(f.t.State==2); } // Road passes within infantry range before exact stage. {Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.attack.alive=false;f.t.Tick(&f.hq,47000);assert(f.t.State==4&&f.t.m_UnsupportedSearch);f.t.Tick(&f.hq,48000);assert(f.t.State==4); } // No repeated cover-loss reset. {Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.aboard=false;f.t.Tick(&f.hq,2000);f.attack.alive=false;f.t.moveWorks=false;f.t.Tick(&f.hq,47000);assert(f.t.State==6); } // Failed foot order still regroups. + {Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);assert(f.t.State==8&&f.t.boards==0);f.t.Tick(&f.hq,20999);assert(f.t.State==8);f.t.Tick(&f.hq,21000);assert(f.t.State==2&&f.t.boards==1);} // Alignment never blocks beyond 20s. + {Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);f.t.facing=true;f.t.Tick(&f.hq,2000);assert(f.t.State==2);} // Stop aligning when facing. + {Fixture f;f.radio.result=1;f.t.align=true;f.t.Tick(&f.hq,1000);f.group.active=true;f.t.Tick(&f.hq,2000);assert(f.t.State==2);} // Combat preempts alignment. {Fixture f;f.radio.result=1;f.t.Tick(&f.hq,1000);f.t.living=false;assert(!f.t.Tick(&f.hq,2000)&&f.t.Stopped);} } ''') @@ -90,13 +95,14 @@ own=own.replace('protected ','').replace('foreach (','for (') for typ in ['AIWaypoint','SCR_BoardingWaypoint','SCR_AIGroupUtilityComponent','SCR_AIVehicleUsageComponent','IEntity','ERR_HQ']: own=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',own) -for name in ['Unit','utility','waypoint','m_Mode','coordination']: +for name in ['Unit','utility','waypoint','m_Mode','coordination','m_GunTeam']: own=own.replace(name+'.',name+'->') own=own.replace('ERR_HQ.Get()', 'ERR_HQ::Get()').replace('AttackSpace().Release', 'AttackSpace()->Release') own=own.replace('Unit->Group.','Unit->Group->').replace('Unit->Entity.FindComponent(SCR_AIVehicleUsageComponent)','Unit->Entity->usage') own=own.replace('SCR_AIVehicleUsageComponent.Cast','UsageCast').replace('SCR_BoardingWaypoint.Cast','BoardCast') for cls in ['ERR_ConvoyRules','ERR_SupplyConvoyTactics','ERR_ConvoyMountedCrew','SCR_EntityHelper']: own=own.replace(cls+'.',cls+'::') +own=own.replace('Unit->Recovery.SuspendBoarding()', 'Unit->Recovery->SuspendBoarding()') own=own.replace('SCR_AIActionBase.PRIORITY_LEVEL_PLAYER','1000').replace('null','nullptr') run(r''' #include @@ -110,14 +116,16 @@ struct SCR_AIGroupUtilityComponent{int adds=0,removes=0;void AddUsableVehicle(SCR_AIVehicleUsageComponent*){adds++;}void RemoveUsableVehicle(SCR_AIVehicleUsageComponent*){removes++;}}; struct Group{SCR_AIGroupUtilityComponent utility;Arraywaypoints;auto GetGroupUtilityComponent(){return &utility;}void RemoveWaypoint(AIWaypoint*w){auto&i=waypoints;i.erase(std::remove(i.begin(),i.end(),w),i.end());}void AddWaypoint(AIWaypoint*w){waypoints.Insert(w);}}; struct IEntity{SCR_AIVehicleUsageComponent usageValue;SCR_AIVehicleUsageComponent*usage=&usageValue;}; -struct UnitType{struct Group*Group=nullptr;IEntity*Entity=nullptr;ArrayMembers;bool CombatHeld=true;float NextOrder=99;}; +struct Recovery{void SuspendBoarding(){}}; +struct UnitType{struct Recovery*Recovery=nullptr;struct Group*Group=nullptr;IEntity*Entity=nullptr;ArrayMembers;bool CombatHeld=true;float NextOrder=99;}; struct Mode{bool active=true;bool Active(){return active;}}; namespace ERR_ConvoyRules{bool captured=false;bool Captured(IEntity*,Group*){return captured;}} namespace ERR_ConvoyMountedCrew{int released=0;void Release(IEntity*){released++;}} namespace SCR_EntityHelper{void DeleteEntityAndChildren(AIWaypoint*w){w->deleted++;}} namespace ERR_SupplyConvoyTactics{AIWaypoint*next=nullptr;bool requested=false;AIWaypoint*Board(IEntity*,bool enter){requested=enter;return next;}} struct Space{void Release(void*){}};struct ERR_HQ{Space space;static ERR_HQ*Get(){return nullptr;}Space*AttackSpace(){return &space;}}; -struct Tactics{UnitType*Unit=nullptr;Mode*m_Mode=nullptr;int Role=0;bool m_VehicleDetached=false,Stopped=false,Delivered=false,living=true;void*Radio=nullptr;ArrayOrders;bool Living(){return living&&!Stopped;} +struct ERR_HunterGunTeam{inline static bool usable=false;int stopped=0;ERR_HunterGunTeam(UnitType*){}bool Start(){return usable;}void Stop(){stopped++;}}; +struct Tactics{ERR_HunterGunTeam*m_GunTeam=nullptr;void Note(const char*){}UnitType*Unit=nullptr;Mode*m_Mode=nullptr;int Role=0;bool m_VehicleDetached=false,Stopped=false,Delivered=false,living=true;void*Radio=nullptr;ArrayOrders;bool Living(){return living&&!Stopped;} '''+own+r''' }; int main(){ @@ -132,17 +140,20 @@ t.VehicleUse(false);t.VehicleUse(false);assert(g.utility.removes==1&&t.m_VehicleDetached); t.Delivered=true;t.Stop();assert(t.Stopped&&g.utility.adds==1&&exit.deleted==1&&unrelated.deleted==0&&g.waypoints.size()==1&&!u.CombatHeld&&u.NextOrder==0); t.Stop();assert(g.utility.adds==1&&exit.deleted==1&&ERR_ConvoyMountedCrew::released==1); + ERR_HunterGunTeam::usable=true;Tactics split;split.Unit=&u;split.m_Mode=&mode;assert(split.Board(false)&&split.m_GunTeam);auto held=split.m_GunTeam; + assert(split.Board(false)&&split.m_GunTeam==held);split.Stop();assert(held->stopped==1&&!split.m_GunTeam);split.Stop();assert(held->stopped==1);ERR_HunterGunTeam::usable=false; + int added=g.utility.adds; for(int scenario=0;scenario<3;scenario++){ Tactics x;x.Unit=&u;x.m_Mode=&mode;x.m_VehicleDetached=true; ERR_ConvoyRules::captured=scenario==0;mode.active=scenario!=1;x.living=scenario!=2; - x.Stop();assert(g.utility.adds==1); // Never reclaim a captured/dead/teardown vehicle. + x.Stop();assert(g.utility.adds==added); // Never reclaim a captured/dead/teardown vehicle. } } ''') print('PASS: production dismount/boarding allocation, all crew allowances, waypoint ownership, one-time vehicle detach/restore and capture/teardown guards') armed=(body(p,'static bool CoverCrew(').replace('ERR_HunterVehicle unit','UnitType* unit').replace('ERR_GameMode mode','int mode')+'\n'+body(p,'bool ArmedCover()')).replace('foreach (','for (').replace('BaseCompartmentSlot seat','BaseCompartmentSlot* seat').replace('bool gunner;','bool gunner=false;') -for name in ['Unit','unit','seat']:armed=armed.replace(name+'.',name+'->') +for name in ['Unit','unit','seat','m_GunTeam']:armed=armed.replace(name+'.',name+'->') armed=armed.replace('ECompartmentType.TURRET','1').replace('ERR_Actors.CanFight','CanFight') armed=armed.replace('ERR_HQMountedAmmoSnapshot ammo','ERR_HQMountedAmmoSnapshot* ammo').replace('ERR_HQMountedAmmoSampler.Inspect','Inspect').replace('ammo.','ammo->') cover=body(p,'bool CoverAlive()').replace('foreach (','for (').replace('ERR_HunterTactics team','ERR_HunterTactics* team').replace('team.','team->') @@ -154,10 +165,11 @@ struct Members{Man*original;bool Contains(Man*m){return m==original;}}; struct ERR_HQMountedAmmoSnapshot{bool Complete=true;int Rounds=99,Chambers=1;}ammo;auto Inspect(int){return &ammo;} struct UnitType{int Entity=0;struct Members Members;std::vectorSeats;bool driver=true;bool CrewValid(int){return driver;}}; -struct ERR_HunterTactics{UnitType*Unit;int Role=0,State=3,m_Mode=0;bool Delivered=true,living=true;bool Living(){return living;} +struct GunTeam{bool covered=true;bool Cover(){return covered;}}; +struct ERR_HunterTactics{GunTeam*m_GunTeam=nullptr;UnitType*Unit;int Role=0,State=3,m_Mode=0;bool Delivered=true,living=true;bool Living(){return living;} '''+armed+r''' };struct Attack{std::vectorTeams; '''+cover+r''' -};int main(){Man gunner,outsider;BaseCompartmentSlot slot;slot.occupant=&gunner;UnitType u;u.Members.original=&gunner;u.Seats={&slot};ERR_HunterTactics t;t.Unit=&u;Attack a;a.Teams={&t};assert(a.CoverAlive());gunner.fit=false;assert(!a.CoverAlive());gunner.fit=true;slot.occupant=&outsider;assert(!a.CoverAlive());slot.occupant=&gunner;u.driver=false;assert(!a.CoverAlive());u.driver=true;t.Delivered=false;assert(!a.CoverAlive());t.Delivered=true;t.living=false;assert(!a.CoverAlive());t.living=true;ammo.Rounds=0;ammo.Chambers=0;assert(!a.CoverAlive());ammo.Chambers=1;assert(a.CoverAlive());ammo.Complete=false;assert(!a.CoverAlive());} +};int main(){Man gunner,outsider;BaseCompartmentSlot slot;slot.occupant=&gunner;UnitType u;u.Members.original=&gunner;u.Seats={&slot};ERR_HunterTactics t;t.Unit=&u;Attack a;a.Teams={&t};assert(a.CoverAlive());gunner.fit=false;assert(!a.CoverAlive());gunner.fit=true;slot.occupant=&outsider;assert(!a.CoverAlive());slot.occupant=&gunner;u.driver=false;assert(!a.CoverAlive());u.driver=true;t.Delivered=false;assert(!a.CoverAlive());t.Delivered=true;t.living=false;assert(!a.CoverAlive());t.living=true;ammo.Rounds=0;ammo.Chambers=0;assert(!a.CoverAlive());ammo.Chambers=1;assert(a.CoverAlive());ammo.Complete=false;assert(!a.CoverAlive());GunTeam split;t.m_GunTeam=&split;t.Role=1;t.State=4;assert(a.CoverAlive());split.covered=false;assert(!a.CoverAlive());} ''') print('PASS: covering support requires the original conscious turret gunner, valid mounted driver, live crew and delivered assignment') --- a/tests/test_hunter_gun_team.py +++ b/tests/test_hunter_gun_team.py @@ -0,0 +1,61 @@ +"""Execute gunner retention eligibility and mixed-crew completion boundaries.""" +import re +from policy_harness import ROOT,body,run +p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterGunTeam.c' +s=body(p,'static IEntity LoadedGunner(').replace('foreach (','for (') +for t in ['IEntity','ERR_HunterVehicle','SCR_VehicleDamageManagerComponent','ERR_HQMountedAmmoSnapshot','BaseCompartmentSlot']: + s=re.sub(r'\b'+t+r' (?=\w)',t+'* ',s) +s=s.replace('static IEntity* LoadedGunner', 'static IEntity* LoadedGunner') +s=s.replace('SCR_VehicleDamageManagerComponent.Cast(unit.Entity.FindComponent(SCR_VehicleDamageManagerComponent))','unit.Entity.damage') +s=s.replace('ERR_HQMountedAmmoSampler.Inspect(unit.Entity)','unit.Entity.ammo') +s=s.replace('unit.Entity.damage','unit->Entity->damage').replace('unit.Entity.ammo','unit->Entity->ammo') +for n in ['unit','damage','ammo','seat']:s=s.replace(n+'.',n+'->') +for cls in ['ERR_Actors','ERR_ConvoyRules','EDamageState','ECompartmentType']:s=s.replace(cls+'.',cls+'::') +s=s.replace('null','nullptr') +run(r''' +#include +#include +struct Group{}; +struct SCR_VehicleDamageManagerComponent{int state=0;float health=1;bool engine=true,gear=true;int GetState(){return state;}float GetHealthScaled(){return health;}bool GetEngineFunctional(){return engine;}bool GetGearboxFunctional(){return gear;}}; +struct ERR_HQMountedAmmoSnapshot{bool Complete=true;int Rounds=1,Chambers=0;}; +struct IEntity{bool fit=true,player=false;Group*group=nullptr;}; +struct Vehicle{SCR_VehicleDamageManagerComponent*damage=nullptr;ERR_HQMountedAmmoSnapshot*ammo=nullptr;}; +struct BaseCompartmentSlot{int type=1;IEntity*occupant=nullptr;int GetType(){return type;}IEntity*GetOccupant(){return occupant;}}; +struct Members{IEntity*original=nullptr;bool Contains(IEntity*a){return a&&a==original;}}; +struct ERR_HunterVehicle{Vehicle*Entity=nullptr;struct Group*Group=nullptr;bool Released=false;struct Members Members;std::vectorSeats;}; +namespace ERR_Actors{bool CanFight(IEntity*a){return a&&a->fit;}bool Player(IEntity*a){return a&&a->player;}struct Group*Group(IEntity*a){return a?a->group:nullptr;}} +namespace ERR_ConvoyRules{bool captured=false;bool Captured(Vehicle*,Group*){return captured;}} +namespace EDamageState{enum{DESTROYED=1};}namespace ECompartmentType{enum{TURRET=1};} +'''+s+r''' +int main(){ + Group group,other;IEntity gunner;gunner.group=&group;SCR_VehicleDamageManagerComponent damage;ERR_HQMountedAmmoSnapshot ammo;Vehicle car;car.damage=&damage;car.ammo=&ammo; + ERR_HunterVehicle unit;unit.Entity=&car;unit.Group=&group;unit.Members.original=&gunner;BaseCompartmentSlot seat;seat.occupant=&gunner;unit.Seats={&seat}; + assert(LoadedGunner(&unit)==&gunner); // No driver-seated prerequisite: driver is assault infantry. + for(int fault=0;fault<13;fault++){ + if(fault==0)unit.Released=true;if(fault==1)ERR_ConvoyRules::captured=true;if(fault==2)damage.state=1; + if(fault==3)damage.health=.25;if(fault==4)damage.engine=false;if(fault==5)damage.gear=false; + if(fault==6)ammo.Complete=false;if(fault==7)ammo.Rounds=0;if(fault==8)gunner.fit=false; + if(fault==9)gunner.player=true;if(fault==10)gunner.group=&other;if(fault==11)unit.Members.original=nullptr;if(fault==12)seat.type=0; + assert(!LoadedGunner(&unit));unit.Released=false;ERR_ConvoyRules::captured=false;damage={};ammo={};gunner.fit=true;gunner.player=false;gunner.group=&group;unit.Members.original=&gunner;seat.type=1; + } + ammo.Rounds=0;ammo.Chambers=1;assert(LoadedGunner(&unit)==&gunner); + unit.Seats.clear();assert(!LoadedGunner(&unit));assert(!LoadedGunner(nullptr)); +} +'''.replace(';if(', ';\n if(')) +q=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c' +s=body(q,'bool Aboard(').replace('foreach (','for (').replace('IEntity member','IEntity* member').replace('Unit.','Unit->').replace('m_GunTeam.','m_GunTeam->') +for cls in ['ERR_Actors','CompartmentAccessComponent']:s=s.replace(cls+'.',cls+'::') +run(r''' +#include +#include +struct IEntity{bool fit=true,player=false;int group=1,vehicle=1;}; +namespace ERR_Actors{bool CanFight(IEntity*a){return a&&a->fit;}bool Player(IEntity*a){return a&&a->player;}int Group(IEntity*a){return a?a->group:0;}} +namespace CompartmentAccessComponent{int GetVehicleIn(IEntity*a){return a?a->vehicle:0;}} +struct UnitType{int Group=1,Entity=1;std::vectorMembers;}; +struct Team{IEntity*gunner;bool Retained(IEntity*a){return a==gunner&&a->vehicle==1;}}; +struct Tactics{UnitType*Unit;Team*m_GunTeam; +'''+s+r''' +}; +int main(){IEntity driver,gunner,passenger;UnitType unit;unit.Members={&driver,&gunner,&passenger};Team team{&gunner};Tactics t{&unit,&team};assert(t.Aboard(true)&&!t.Aboard(false));driver.vehicle=passenger.vehicle=0;assert(t.Aboard(false)&&!t.Aboard(true));t.m_GunTeam=nullptr;assert(!t.Aboard(false));gunner.vehicle=0;assert(t.Aboard(false));} +''') +print('PASS: only original fit loaded healthy uncaptured gunners stay mounted; driver can leave; infantry readiness excludes the retained gunner but regroup requires all survivors')