Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
626 lines
29 KiB
C
626 lines
29 KiB
C
// A bounded response by original hunter crews. Enemy positions are immutable
|
|
// radio-delivered snapshots; native perception still owns aiming and firing.
|
|
class ERR_HunterAttackRules
|
|
{
|
|
static float ManeuverBudget(float metres) { return Math.Clamp(30 + metres / 2, 90, 240) * 1000; }
|
|
// Progress can extend a road approach, but never indefinitely.
|
|
static bool ExtendApproach(float started, float progress, float now)
|
|
{
|
|
return now >= started && now - started < 300000 && now >= progress && now - progress <= 15000;
|
|
}
|
|
static bool AdvanceWithoutVisualCover(bool armedCover, float waitingUntil, float now)
|
|
{
|
|
// Give the gunner 15 seconds to acquire a target, then investigate the
|
|
// delivered sector. Support availability is not a claim of suppressing fire.
|
|
return armedCover && now >= waitingUntil - 30000;
|
|
}
|
|
static bool DismountRange(float range) { return range >= 140 && range <= 450; }
|
|
static bool Fresh(float observed, float now) { return observed >= 0 && now >= observed && now - observed <= 60000; }
|
|
static vector Offset(vector contact, vector forward, int role, int phase)
|
|
{
|
|
vector left = Vector(-forward[2], 0, forward[0]);
|
|
if (role == 0) return contact - forward * 210;
|
|
float side = 1; if (role == 2) side = -1;
|
|
if (phase == 0) return contact - forward * 240 + left * (side * 155);
|
|
if (phase == 1) return contact - forward * 85 + left * (side * 170);
|
|
return contact + forward * 35 + left * (side * 105);
|
|
}
|
|
static bool Stage(vector point, vector contact, vector forward, array<vector> occupied)
|
|
{
|
|
float distance = vector.DistanceXZ(point, contact);
|
|
if (distance < 140 || distance > 500 || vector.Dot(point - contact, forward) > -90) return false;
|
|
foreach (vector other : occupied) if (vector.DistanceXZ(point, other) < 85) return false;
|
|
return true;
|
|
}
|
|
static bool Foot(vector point, vector contact, vector forward, int role)
|
|
{
|
|
vector left = Vector(-forward[2], 0, forward[0]);
|
|
float side = vector.Dot(point - contact, left);
|
|
if (role == 2) side = -side;
|
|
return side >= 65 && vector.DistanceXZ(point, contact) >= 85;
|
|
}
|
|
}
|
|
|
|
// Geometry checks only the delivered sector, never a hidden target entity.
|
|
// A clear lane is a candidate firing position, not proof of suppression.
|
|
class ERR_MountedCover
|
|
{
|
|
static bool SeesSector(SCR_AIGroup group, vector contact)
|
|
{
|
|
if (!group || !group.GetGroupUtilityComponent()) return false;
|
|
array<ref ERR_HQVisualContact> visible = {};
|
|
ERR_HQObservation.Gather(group.GetGroupUtilityComponent(), GetGame().GetWorld().GetWorldTime(), visible);
|
|
foreach (ERR_HQVisualContact sighting : visible)
|
|
if (vector.DistanceXZ(sighting.Position, contact) <= 200) return true;
|
|
return false;
|
|
}
|
|
static bool ClearLane(IEntity vehicle, vector position, vector contact)
|
|
{
|
|
TraceParam trace = new TraceParam();
|
|
trace.Start = position + "0 2 0"; trace.End = contact + "0 1 0";
|
|
trace.Exclude = vehicle; trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
|
|
return GetGame().GetWorld().TraceMove(trace, null) >= 1;
|
|
}
|
|
static vector Approach(vector origin, vector contact, vector fallback)
|
|
{
|
|
vector forward = contact - origin; forward[1] = 0;
|
|
if (forward.Length() < 1) return fallback;
|
|
forward.Normalize(); return forward;
|
|
}
|
|
}
|
|
|
|
class ERR_HunterAttack
|
|
{
|
|
int BeliefId;
|
|
vector ReportPosition;
|
|
vector Forward;
|
|
float Observed;
|
|
float Started;
|
|
int Message;
|
|
ref array<ref ERR_HunterTactics> Teams = {};
|
|
bool CoverAlive()
|
|
{
|
|
foreach (ERR_HunterTactics team : Teams)
|
|
if (team.Delivered && team.ArmedCover()) return true;
|
|
return false;
|
|
}
|
|
bool CoverReady()
|
|
{
|
|
foreach (ERR_HunterTactics team : Teams)
|
|
if (team.Covering()) return true;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class ERR_HunterTactics
|
|
{
|
|
ERR_HunterVehicle Unit;
|
|
ERR_HunterAttack Attack;
|
|
int Role; // armed covering vehicle, left infantry, right infantry
|
|
vector Stage;
|
|
vector Approach;
|
|
vector Sector;
|
|
ref ERR_HQRoute Radio;
|
|
ref array<AIWaypoint> Orders = {};
|
|
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;
|
|
bool Delivered;
|
|
bool Stopped;
|
|
protected bool m_VehicleDetached;
|
|
protected float m_TravelDistance;
|
|
protected float m_NextStateLog;
|
|
protected float m_ApproachStarted;
|
|
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)
|
|
{
|
|
m_Mode = mode; Unit = unit; Attack = attack; Role = role;
|
|
}
|
|
string Label()
|
|
{
|
|
if (Role == 0) return "cover";
|
|
if (Role == 1) return "left flank";
|
|
return "right flank";
|
|
}
|
|
bool Living()
|
|
{
|
|
if (Stopped || !Unit || Unit.Released || !Unit.Group || !Unit.Entity || !ERR_Actors.Alive(Unit.Entity) || Unit.Group.GetFaction() != m_Mode.EnemyFaction()) return false;
|
|
foreach (IEntity member : Unit.Members)
|
|
if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Unit.Group) return true;
|
|
return false;
|
|
}
|
|
bool Aboard(bool required)
|
|
{
|
|
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();
|
|
}
|
|
protected bool VisualCover()
|
|
{
|
|
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
|
|
if (!utility) return false;
|
|
// Cover is useful when this crew really sees a threat in the reported area,
|
|
// not merely when a trace to an old point happens to pass between the trees.
|
|
array<ref ERR_HQVisualContact> visible = {};
|
|
ERR_HQObservation.Gather(utility, GetGame().GetWorld().GetWorldTime(), visible);
|
|
foreach (ERR_HQVisualContact sighting : visible)
|
|
if (vector.DistanceXZ(sighting.Position, Attack.ReportPosition) <= 200) return true;
|
|
return false;
|
|
}
|
|
static bool CoverCrew(ERR_HunterVehicle unit, ERR_GameMode mode)
|
|
{
|
|
if (!unit || !unit.CrewValid(mode)) return false;
|
|
bool gunner;
|
|
foreach (BaseCompartmentSlot seat : unit.Seats)
|
|
if (seat && seat.GetType() == ECompartmentType.TURRET && unit.Members.Contains(seat.GetOccupant()) && ERR_Actors.CanFight(seat.GetOccupant())) gunner = true;
|
|
if (!gunner) return false;
|
|
ERR_HQMountedAmmoSnapshot ammo = ERR_HQMountedAmmoSampler.Inspect(unit.Entity);
|
|
return ammo && ammo.Complete && ammo.Rounds + ammo.Chambers > 0;
|
|
}
|
|
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)
|
|
{
|
|
if (waypoint && Unit && Unit.Group) Unit.Group.RemoveWaypoint(waypoint);
|
|
if (waypoint) SCR_EntityHelper.DeleteEntityAndChildren(waypoint);
|
|
}
|
|
Orders.Clear();
|
|
}
|
|
protected void Add(AIWaypoint waypoint) { Orders.Insert(waypoint); Unit.Group.AddWaypoint(waypoint); }
|
|
protected bool Move(vector position, float radius = 20)
|
|
{
|
|
AIWaypoint move = ERR_SupplyConvoyTactics.Move(position, radius);
|
|
if (!move) return false;
|
|
AIWaypoint corner;
|
|
float travel = vector.DistanceXZ(Position(), position);
|
|
if (Role > 0 && State == 3)
|
|
{
|
|
vector origin = Position();
|
|
vector delta = position - origin; delta[1] = 0;
|
|
float lengthSq = vector.Dot(delta, delta);
|
|
float along;
|
|
if (lengthSq > 1) along = Math.Clamp(vector.Dot(Attack.ReportPosition - origin, delta) / lengthSq, 0, 1);
|
|
if (vector.DistanceXZ(origin + delta * along, Attack.ReportPosition) < 130)
|
|
{
|
|
vector left = Vector(-Attack.Forward[2], 0, Attack.Forward[0]);
|
|
float side = 1; if (Role == 2) side = -1;
|
|
vector desired = Attack.ReportPosition + Attack.Forward * vector.Dot(origin - Attack.ReportPosition, Attack.Forward) + left * (side * 230);
|
|
vector detour;
|
|
if (!FootPosition(desired, detour)) { SCR_EntityHelper.DeleteEntityAndChildren(move); return false; }
|
|
travel = vector.DistanceXZ(origin, detour) + vector.DistanceXZ(detour, position);
|
|
corner = ERR_SupplyConvoyTactics.Move(detour, 20);
|
|
if (!corner) { SCR_EntityHelper.DeleteEntityAndChildren(move); return false; }
|
|
}
|
|
}
|
|
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);
|
|
Clear(); if (corner) Add(corner); Add(move); m_TravelDistance = travel; return true;
|
|
}
|
|
protected void VehicleUse(bool enabled)
|
|
{
|
|
if (!Unit || !Unit.Group || !Unit.Entity) return;
|
|
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
|
|
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
|
|
if (!utility || !usage) return;
|
|
if (!enabled && !m_VehicleDetached) { utility.RemoveUsableVehicle(usage); m_VehicleDetached = true; }
|
|
else if (enabled && m_VehicleDetached && Living() && m_Mode.Active() && !ERR_ConvoyRules.Captured(Unit.Entity, Unit.Group))
|
|
{ utility.AddUsableVehicle(usage); m_VehicleDetached = false; }
|
|
}
|
|
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)
|
|
{
|
|
if (created) SCR_EntityHelper.DeleteEntityAndChildren(created);
|
|
return false;
|
|
}
|
|
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)
|
|
{
|
|
ERR_HQ.Event("HUNTER TACTICS", Unit.Source + " " + Label() + ": " + detail, Attack.Message);
|
|
}
|
|
protected void ObserveState(float now)
|
|
{
|
|
if (now < m_NextStateLog) return;
|
|
m_NextStateLog = now + 30000;
|
|
int aboard;
|
|
foreach (IEntity member : Unit.Members)
|
|
if (member && CompartmentAccessComponent.GetVehicleIn(member) == Unit.Entity) aboard++;
|
|
Note(string.Format("state=%1 position=%2 stage distance=%3m crew aboard=%4 armed support=%5 visual cover=%6 remaining=%7s",
|
|
State, Unit.Entity.GetOrigin(), vector.DistanceXZ(Unit.Entity.GetOrigin(), Stage), aboard,
|
|
Attack.CoverAlive(), Attack.CoverReady(), Math.Max(0, Deadline - now) / 1000));
|
|
}
|
|
bool Plan(array<vector> occupied)
|
|
{
|
|
if (!Living()) return false;
|
|
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
|
|
if (!utility) return false;
|
|
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
|
|
float currentRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
|
|
if (Role == 0 && currentRange >= 140 && currentRange <= 500 && VisualCover())
|
|
{ Stage = Unit.Entity.GetOrigin(); return true; }
|
|
// Approach from this crew's side of the contact. A common cover axis used
|
|
// to send the opposite-side truck around the entire fight before dismount.
|
|
vector approachForward = ERR_MountedCover.Approach(Unit.Entity.GetOrigin(), Attack.ReportPosition, Attack.Forward);
|
|
vector desired = Attack.ReportPosition - approachForward * 260;
|
|
if (Role == 0) desired = Attack.ReportPosition - approachForward * 210;
|
|
vector approachLeft = Vector(-approachForward[2], 0, approachForward[0]);
|
|
bool found;
|
|
for (int attempt = 0; attempt < 8; attempt++)
|
|
{
|
|
vector point, angles;
|
|
float lateral = Math.Ceil(attempt * 0.5) * 45;
|
|
if (attempt % 2 == 0) lateral = -lateral;
|
|
vector seed = desired - approachForward * (attempt * 15) + approachLeft * lateral;
|
|
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, point, angles, 100)) continue;
|
|
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point) || !placement.Clear(point, angles[0], 2.5)) continue;
|
|
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
|
|
Stage = point; found = true; break;
|
|
}
|
|
// Sparse roads must not collapse every crew onto the same junction. UAZs
|
|
// may use a checked open-ground staging area; native navigation still owns travel.
|
|
for (int offroad = 0; !found && offroad < 8; offroad++)
|
|
{
|
|
vector point = desired;
|
|
if (offroad > 0) point += Vector(Math.RandomFloat(-90, 90), 0, Math.RandomFloat(-90, 90));
|
|
point = ERR_Actors.Ground(point);
|
|
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point)) continue;
|
|
if (!placement.Clear(point, ERR_ForceRules.Heading(point, Attack.ReportPosition, 0), 2.5)) continue;
|
|
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
|
|
Stage = point; found = true;
|
|
}
|
|
// A crew already at a safe stand-off can dismount where it is instead of
|
|
// driving across the enemy's reported position merely to fit the formation.
|
|
if (!found && Role > 0)
|
|
{
|
|
vector parked = Unit.Entity.GetOrigin();
|
|
float range = vector.DistanceXZ(parked, Attack.ReportPosition);
|
|
bool separate = true;
|
|
foreach (vector reserved : occupied) if (vector.DistanceXZ(parked, reserved) < 85) separate = false;
|
|
if (range >= 140 && range <= 500 && separate) { Stage = parked; found = true; }
|
|
}
|
|
if (!found) { Note("no separated safe approach; keeping current orders"); return false; }
|
|
if (Role == 0) return true;
|
|
bool foot = FootPosition(ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 1), Approach)
|
|
&& FootPosition(ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 2), Sector);
|
|
if (!foot) Note("no safe infantry flank route from approach=" + Stage.ToString() + "; keeping current orders");
|
|
return foot;
|
|
}
|
|
protected bool FootPosition(vector desired, out vector chosen)
|
|
{
|
|
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
|
|
if (!utility) return false;
|
|
for (int attempt = 0; attempt < 8; attempt++)
|
|
{
|
|
vector point = desired;
|
|
if (attempt > 0) point += Vector(Math.RandomFloat(-45, 45), 0, Math.RandomFloat(-45, 45));
|
|
point = ERR_Actors.Ground(point);
|
|
ERR_HQ coordination = ERR_HQ.Get();
|
|
if (coordination && !coordination.AttackSpace().Free(Unit.Group, point, GetGame().GetWorld().GetWorldTime())) continue;
|
|
if (!ERR_HunterAttackRules.Foot(point, Attack.ReportPosition, Attack.Forward, Role)) continue;
|
|
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !utility.IsPositionAllowed(point)) continue;
|
|
if (SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { chosen = point; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
bool Queue(ERR_HQ hq)
|
|
{
|
|
Radio = hq.Downlink(Unit.Group, "HUNTER TEAM", Unit.Source + " " + Label() + " stage=" + Stage.ToString() + " sector=" + Sector.ToString(), Attack.Message);
|
|
return Radio != null;
|
|
}
|
|
bool Tick(ERR_HQ hq, float now)
|
|
{
|
|
if (!Living()) { Stop(); return false; }
|
|
if (!Delivered)
|
|
{
|
|
if (!Radio) { Stop(); return false; }
|
|
int result = Radio.Advance(now, hq.Journal());
|
|
if (result == 0) return false; // Normal local reactions retain ownership until delivery.
|
|
Radio = null;
|
|
if (result < 0 || !Move(Stage, 35)) { Stop(); return false; }
|
|
Delivered = true; Unit.ClearOrder(); Unit.Downlink = null;
|
|
State = 1; Deadline = now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance);
|
|
m_ApproachStarted = now; m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin();
|
|
Note("radio delivered; separate approach=" + Stage.ToString());
|
|
if (Role == 0) foreach (IEntity crew : Unit.Members) ERR_ConvoyMountedCrew.Hold(crew, Unit.Entity);
|
|
}
|
|
ObserveState(now);
|
|
ERR_HQ coordination = ERR_HQ.Get();
|
|
if (coordination)
|
|
{
|
|
vector reserved = Stage;
|
|
if (State == 4) reserved = Approach;
|
|
if (State == 5) reserved = Sector;
|
|
coordination.AttackSpace().Claim(Unit.Group, reserved, now);
|
|
}
|
|
// Keep the original crew's driving recovery from fighting the dismount or
|
|
// returning to the UAZ fifteen seconds after the last visible target.
|
|
Unit.CombatHeld = true; Unit.CombatQuietUntil = now + 15000;
|
|
if (State == 1)
|
|
{
|
|
if (vector.DistanceXZ(Unit.Entity.GetOrigin(), m_ApproachPosition) >= 15)
|
|
{ m_ApproachProgress = now; m_ApproachPosition = Unit.Entity.GetOrigin(); }
|
|
bool near = vector.DistanceXZ(Unit.Entity.GetOrigin(), Stage) <= 55;
|
|
float currentCoverRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
|
|
bool local = ERR_HunterCombat.Active(Unit.Group) && vector.DistanceXZ(Position(), Attack.ReportPosition) <= 400;
|
|
// A road can pass close to the reported sector and then loop kilometres
|
|
// 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 || (currentCoverRange >= 100 && currentCoverRange <= 600 && VisualCover()))) { Clear(); State = 3; Note("in covering position; native gunner owns target acquisition and fire"); }
|
|
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))
|
|
{ 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; }
|
|
}
|
|
}
|
|
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"); }
|
|
else if (now >= Deadline) { Note("dismount incomplete; return surviving crew"); State = 6; Deadline = now + 90000; Board(true); }
|
|
else if (now >= NextRetry && Retries < 2) { NextRetry = now + 15000; Retries++; Board(false); }
|
|
}
|
|
if (State == 3 && Role > 0)
|
|
{
|
|
bool visualCover = Attack.CoverReady();
|
|
bool investigate = ERR_HunterAttackRules.AdvanceWithoutVisualCover(Attack.CoverAlive(), Deadline, now);
|
|
bool unsupported = !Attack.CoverAlive() && now >= Deadline;
|
|
if ((visualCover || investigate || unsupported) && Move(Approach, 20))
|
|
{
|
|
m_UnsupportedSearch = unsupported;
|
|
State = 4; Deadline = now + ERR_HunterAttackRules.ManeuverBudget(m_TravelDistance);
|
|
if (visualCover) Note("cover established; moving around flank via " + Approach.ToString());
|
|
else if (unsupported) Note("cover lost; bounded infantry investigation of delivered sector via " + Approach.ToString());
|
|
else Note("armed support in position without visual contact; investigating reported flank via " + Approach.ToString());
|
|
}
|
|
else if (now >= Deadline)
|
|
{
|
|
Note("infantry approach could not be assigned; regrouping original survivors");
|
|
State = 6; Deadline = now + 90000; Board(true);
|
|
}
|
|
}
|
|
if (State == 4 && !m_UnsupportedSearch && !Attack.CoverAlive())
|
|
{ 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)
|
|
{
|
|
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"); }
|
|
if (State == 3 && Role == 0 && now - Attack.Started >= 300000)
|
|
{ Stop(); return false; }
|
|
if (State == 6 && (Aboard(true) || now >= Deadline)) { Note("regroup ended; ordinary HQ travel may resume"); Stop(); return false; }
|
|
return Delivered && !Stopped;
|
|
}
|
|
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();
|
|
if (coordination && Unit) coordination.AttackSpace().Release(Unit.Group);
|
|
if (!Unit) return;
|
|
if (Delivered && Role == 0)
|
|
foreach (IEntity crew : Unit.Members) ERR_ConvoyMountedCrew.Release(crew);
|
|
if (Delivered) { Unit.CombatHeld = false; Unit.NextOrder = 0; }
|
|
}
|
|
}
|
|
|
|
class ERR_HunterCoordination
|
|
{
|
|
protected ERR_GameMode m_Mode;
|
|
protected ref array<ref ERR_HunterAttack> m_Attacks = {};
|
|
protected float m_NextPlan;
|
|
void ERR_HunterCoordination(ERR_GameMode mode) { m_Mode = mode; }
|
|
bool Covered(vector position)
|
|
{
|
|
foreach (ERR_HunterAttack attack : m_Attacks)
|
|
{
|
|
if (vector.DistanceXZ(position, attack.ReportPosition) >= 300) continue;
|
|
int active;
|
|
foreach (ERR_HunterTactics team : attack.Teams) if (!team.Stopped && team.Living()) active++;
|
|
// A failed/finished partner must not reserve the entire encounter and
|
|
// prevent an available hunter receiving a fresh HQ interception order.
|
|
if (active >= 2) return true;
|
|
}
|
|
return false;
|
|
}
|
|
// Redirect an obsolete road approach only. Infantry already deployed or a
|
|
// gun with visual contact retains its local tactical ownership. An idle gun
|
|
// at the old sighting must not strand its still-travelling partner there.
|
|
protected void RefreshApproaches(ERR_HQBelief belief, float now)
|
|
{
|
|
if (!belief || !belief.Actionable() || !ERR_HunterAttackRules.Fresh(belief.ObservedAt, now)) return;
|
|
foreach (ERR_HunterAttack attack : m_Attacks)
|
|
{
|
|
if (attack.BeliefId != belief.BeliefId || belief.ObservedAt <= attack.Observed || vector.DistanceXZ(attack.ReportPosition, belief.PredictedPosition) < 250) continue;
|
|
bool travelling = true;
|
|
foreach (ERR_HunterTactics team : attack.Teams)
|
|
if (!team.Stopped && team.Living() && team.State > 1 && !(team.Role == 0 && team.State == 3 && !team.Covering())) travelling = false;
|
|
if (!travelling) continue;
|
|
foreach (ERR_HunterTactics old : attack.Teams) if (!old.Stopped) old.Stop();
|
|
ERR_HQ.Event("HUNTER COORDINATION", "new delivered sighting supersedes old road approach; original crews available for retask", belief.MessageId);
|
|
}
|
|
}
|
|
void Tick(ERR_HQ hq, array<ref ERR_HunterVehicle> hunters, float now)
|
|
{
|
|
for (int i = m_Attacks.Count() - 1; i >= 0; i--)
|
|
{
|
|
bool running;
|
|
foreach (ERR_HunterTactics old : m_Attacks[i].Teams) if (!old.Stopped) running = true;
|
|
if (!running) m_Attacks.Remove(i);
|
|
}
|
|
if (m_Attacks.Count() >= 5) return;
|
|
if (now < m_NextPlan) return;
|
|
m_NextPlan = now + 5000;
|
|
array<ref ERR_HQReport> reports = {}; hq.SupportContacts(reports, now);
|
|
ERR_HQBelief best;
|
|
foreach (ERR_HQReport report : reports)
|
|
{
|
|
ERR_HQBelief belief = hq.Memory().Get(report.Target);
|
|
RefreshApproaches(belief, now);
|
|
if (!belief || !belief.Actionable() || !ERR_HunterAttackRules.Fresh(belief.ObservedAt, now) || Covered(belief.PredictedPosition)) continue;
|
|
int nearby;
|
|
foreach (ERR_HunterVehicle candidate : hunters)
|
|
if (candidate.Registered && !candidate.Released && !candidate.CloseContactHeld && candidate.CrewValid(m_Mode) && (!candidate.Tactics || candidate.Tactics.Stopped) && vector.DistanceXZ(candidate.Entity.GetOrigin(), belief.PredictedPosition) <= 1800) nearby++;
|
|
if (nearby >= 2 && (!best || belief.ObservedAt > best.ObservedAt)) best = belief;
|
|
}
|
|
if (!best) return;
|
|
array<ERR_HunterVehicle> available = {};
|
|
foreach (ERR_HunterVehicle unit : hunters)
|
|
if (unit.Registered && !unit.Released && !unit.CloseContactHeld && unit.CrewValid(m_Mode) && (!unit.Tactics || unit.Tactics.Stopped) && vector.DistanceXZ(unit.Entity.GetOrigin(), best.PredictedPosition) <= 1800) available.Insert(unit);
|
|
if (available.Count() < 2) return;
|
|
ERR_HQ.Event("HUNTER COORDINATION", string.Format("%1 nearby crews available for delivered contact=%2", available.Count(), best.PredictedPosition), best.MessageId);
|
|
ERR_HunterAttack attack = new ERR_HunterAttack();
|
|
m_Attacks.Insert(attack);
|
|
attack.BeliefId = best.BeliefId; attack.ReportPosition = best.PredictedPosition; attack.Observed = best.ObservedAt; attack.Message = best.MessageId; attack.Started = now;
|
|
array<vector> occupied = {};
|
|
for (int role = 0; role < 3 && !available.IsEmpty(); role++)
|
|
{
|
|
int nearest = -1; float distance = 1e20;
|
|
vector desired = attack.ReportPosition;
|
|
if (role > 0) desired = ERR_HunterAttackRules.Offset(attack.ReportPosition, attack.Forward, role, 0);
|
|
for (int index = 0; index < available.Count(); index++)
|
|
{
|
|
if (role == 0 && !ERR_HunterTactics.CoverCrew(available[index], m_Mode)) continue;
|
|
float candidate = vector.DistanceXZ(available[index].Entity.GetOrigin(), desired);
|
|
if (candidate < distance) { distance = candidate; nearest = index; }
|
|
}
|
|
if (nearest < 0) break;
|
|
ERR_HunterVehicle selected = available[nearest]; available.Remove(nearest);
|
|
if (role == 0)
|
|
{
|
|
attack.Forward = attack.ReportPosition - selected.Entity.GetOrigin(); attack.Forward[1] = 0;
|
|
if (attack.Forward.Length() < 1) attack.Forward = "0 0 1";
|
|
attack.Forward.Normalize();
|
|
}
|
|
ERR_HunterTactics team = new ERR_HunterTactics(m_Mode, selected, attack, role);
|
|
if (!team.Plan(occupied) || !team.Queue(hq)) { team.Stop(); if (role == 0) break; continue; }
|
|
occupied.Insert(team.Stage); attack.Teams.Insert(team); selected.Tactics = team;
|
|
}
|
|
if (attack.Teams.Count() < 2)
|
|
{
|
|
foreach (ERR_HunterTactics incomplete : attack.Teams) incomplete.Stop();
|
|
m_Attacks.RemoveItem(attack);
|
|
}
|
|
}
|
|
void Stop()
|
|
{
|
|
foreach (ERR_HunterAttack attack : m_Attacks)
|
|
foreach (ERR_HunterTactics team : attack.Teams) team.Stop();
|
|
m_Attacks.Clear();
|
|
}
|
|
}
|