Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

633 lines
50 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyPlacement.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyPlacement.c
@@ -32,12 +32,16 @@
}
return !m_Obstructed;
}
- static bool Terrain(float origin, float lowest, float highest)
+ static bool Terrain(float origin, float lowest, float highest, float maximumSpan = 0.75)
{
return ERR_ForceRules.Finite(origin) && ERR_ForceRules.Finite(lowest) && ERR_ForceRules.Finite(highest)
- && lowest > 0.3 && highest >= lowest && highest - lowest <= 0.75 && origin >= lowest - 0.3 && origin <= highest + 0.3;
+ && ERR_ForceRules.Finite(maximumSpan) && maximumSpan >= 0.75 && maximumSpan <= 2.5
+ && lowest > 0.3 && highest >= lowest && highest - lowest <= maximumSpan && origin >= lowest - 0.3 && origin <= highest + 0.3;
}
- bool Clear(vector position, float heading)
+ // Spawning retains the flat 0.75 m footprint limit. Already physical vehicles
+ // may stage on up to 2.5 m relief across the 9 m footprint; water, collisions
+ // and native navigation still veto the point. Never use this to relax spawning.
+ bool Clear(vector position, float heading, float maximumSpan = 0.75)
{
m_LastReason = "invalid origin or heading"; m_LastLowest = 1e20; m_LastHighest = -1e20;
m_FirstObstruction = null; m_Visited = 0; m_LastTrace = -1;
@@ -61,7 +65,7 @@
}
}
m_LastLowest = lowest; m_LastHighest = highest;
- if (!Terrain(position[1], lowest, highest))
+ if (!Terrain(position[1], lowest, highest, maximumSpan))
{ m_LastReason = "terrain origin/height/span check"; return false; }
low[1] = lowest + 0.05; high[1] = highest + 3.5;
m_Obstructed = false; m_Visited = 0;
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_ConvoyService.c
@@ -691,6 +691,8 @@
if (unit && unit.InterceptMatches(order))
{ bool delivered = unit.InterceptActive; unit.ClearIntercept(order); if (delivered) unit.LegDeadline = now + 600000; }
}
+ // Tactical ownership is a local service guard, not enemy intelligence.
+ bool CanResumeCombat(ERR_ConvoyTask task) { return true; }
bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
if (!Current() || !task || !order || !m_Tasks.Contains(task) || task.Stopped || !task.Ready || !task.Receipt || !task.Receipt.Consumed) return false;
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c
@@ -1430,7 +1430,7 @@
if (vehicle.Combat.Held && contact.MessageId == vehicle.Combat.ReportId) break;
if (contact.ObservedAt < vehicle.Combat.ObservedAt) break;
}
- else if (vehicle.Combat.ReportId <= 0 || !vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break;
+ else if (!m_Convoys.CanResumeCombat(task) || vehicle.Combat.ReportId <= 0 || !vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) break;
ERR_ConvoyCombatOrder order = new ERR_ConvoyCombatOrder();
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c
@@ -311,7 +311,8 @@
foreach (ERR_HQReport report : reports)
{
ERR_HQBelief belief = hq.Memory().Get(report.Target);
- if (belief && Coordinated(belief.PredictedPosition)) continue;
+ // A coordinated pair does not reserve the whole contact: unused hunters
+ // must still receive delivered sightings and converge as reinforcements.
if (belief && belief.Actionable() && (!best || belief.ObservedAt > best.ObservedAt)) best = belief;
}
bool freshContact;
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c
@@ -41,8 +41,37 @@
}
}
+// 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;
@@ -301,14 +330,22 @@
float currentRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
if (Role == 0 && currentRange >= 140 && currentRange <= 500 && VisualCover())
{ Stage = Unit.Entity.GetOrigin(); return true; }
- vector desired = ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 0);
+ // 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;
- vector seed = desired - Attack.Forward * (attempt * 25);
+ 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, Attack.Forward, occupied) || !utility.IsPositionAllowed(point) || !placement.Clear(point, angles[0])) 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
@@ -316,10 +353,11 @@
for (int offroad = 0; !found && offroad < 8; offroad++)
{
vector point = desired;
- if (offroad > 0) point += Vector(Math.RandomFloat(-40, 40), 0, Math.RandomFloat(-40, 40));
+ 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, Attack.Forward, occupied) || !utility.IsPositionAllowed(point)) continue;
- if (!placement.Clear(point, ERR_ForceRules.Heading(point, Attack.ReportPosition, 0))) continue;
+ 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
@@ -334,8 +372,10 @@
}
if (!found) { Note("no separated safe approach; keeping current orders"); return false; }
if (Role == 0) return true;
- return FootPosition(ERR_HunterAttackRules.Offset(Attack.ReportPosition, Attack.Forward, Role, 1), Approach)
+ 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)
{
@@ -344,7 +384,7 @@
for (int attempt = 0; attempt < 8; attempt++)
{
vector point = desired;
- if (attempt > 0) point += Vector(Math.RandomFloat(-15, 15), 0, Math.RandomFloat(-15, 15));
+ 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;
@@ -392,11 +432,12 @@
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) { Clear(); State = 3; Note("in covering position; native gunner owns target acquisition and fire"); }
+ 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"); }
@@ -496,6 +537,23 @@
}
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--)
@@ -506,12 +564,13 @@
}
if (m_Attacks.Count() >= 5) return;
if (now < m_NextPlan) return;
- m_NextPlan = now + 10000;
+ 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)
@@ -526,7 +585,7 @@
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.ReportPosition = best.PredictedPosition; attack.Observed = best.ObservedAt; attack.Message = best.MessageId; attack.Started = now;
+ 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++)
{
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyService.c
@@ -228,6 +228,11 @@
if (!supply || !supply.Tactics || supply.Tactics.State() != 0) return false;
return super.ReserveIntercept(task, order, now);
}
+ override bool CanResumeCombat(ERR_ConvoyTask task)
+ {
+ ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
+ return supply && supply.Tactics && supply.Tactics.State() == 0;
+ }
override bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_SupplyConvoyTactics.c
@@ -401,28 +401,43 @@
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent();
if (!utility) return false;
+ 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, m_Forward) <= -100 && utility.IsPositionAllowed(road)) { 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 || 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();
for (int attempt = 0; attempt < 8; attempt++)
{
vector candidate = desired;
- if (attempt > 0) candidate += Vector(Math.RandomFloat(-15, 15), 0, Math.RandomFloat(-15, 15));
+ 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, m_Forward) > -100 || !utility.IsPositionAllowed(candidate)) continue;
- if (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0))) { position = candidate; return true; }
+ 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 (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0), 2.5)) { position = candidate; return true; }
}
return false;
}
- protected void Support(int index, bool holdTruck)
+ protected void Support(int index, bool holdTruck, bool retainApproach = false)
{
ERR_SupplyTeam orders = m_Support[index];
if (!Usable(index)) { orders.Clear(); return; }
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
// 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;
+ float range = vector.DistanceXZ(vehicle.Entity.GetOrigin(), m_Contact);
+ // Keep a gun that can already see the reported fight. Do not back it away
+ // 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());
+ 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;
float rear = 180, side;
if (index == 0) side = 150;
if (index == 3) side = -150;
@@ -430,14 +445,23 @@
if (index == 1) { rear = 350; side = 40; }
if (index == 2) { rear = 310; side = -40; }
vector support = vehicle.Entity.GetOrigin();
- vector desired = m_Contact - m_Forward * rear + m_Left * side;
+ vector forward = ERR_MountedCover.Approach(vehicle.Entity.GetOrigin(), m_Contact, m_Forward);
+ vector left = Vector(-forward[2], 0, forward[0]);
+ vector desired = m_Contact - forward * rear + left * side;
vector chosen;
if (SupportPosition(index, desired, chosen)) 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());
+ return;
+ }
if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20)
{
AIWaypoint movement = Move(support);
if (!movement) return;
Command(movement); orders.Clear(); orders.Add(movement);
+ 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();
Claim(vehicle.Group, support, GetGame().GetWorld().GetWorldTime());
@@ -455,7 +479,12 @@
protected void Orders(bool dismount, float now)
{
m_NextOrders = now + 30000;
- if (m_OrdersIssued && vector.DistanceXZ(m_LastOrderContact, m_Contact) < 40) return;
+ if (m_OrdersIssued && vector.DistanceXZ(m_LastOrderContact, m_Contact) < 40)
+ {
+ // Recheck masked firing positions even when the reported target is stationary.
+ for (int support = 0; support < m_Task.Vehicles.Count(); support++) Support(support, !m_TruckWithdrawn, true);
+ return;
+ }
m_LastOrderContact = m_Contact; m_OrdersIssued = true;
for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, !m_TruckWithdrawn);
for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount);
@@ -602,7 +631,7 @@
delayed.Clear(); Flank(retry, true);
}
}
- if (found && now >= m_NextOrders) Orders(!InfantryAboard(false), now);
+ if (now >= m_NextOrders && now - m_LastContact < 180000) Orders(!InfantryAboard(false), now);
// Search only remembered positions. Real new sightings can redirect the
// teams; silence gives them time to reach and sweep both flanks, not a
// one-minute dash followed by forced reboarding.
--- a/docs/aggression-ai.md
+++ b/docs/aggression-ai.md
@@ -30,9 +30,11 @@
## Coordinated hunter encounters (2026-10-04)
-When HQ receives a fresh actionable report and at least two registered hunter crews are within 1800 m, it can assign one UAZ with a loaded mounted gun to cover and up to two original crews to opposite infantry flanks. These are ordinary physical radio downlinks; undelivered assignments do not cancel the crews' current travel or local combat. The target is the delivered report position, not a live player-position lookup.
+Every five seconds, when HQ has a fresh actionable report and at least two registered hunter crews are within 1800 m, it can assign one UAZ with a loaded mounted gun to cover and up to two original crews to opposite infantry flanks. These are ordinary physical radio downlinks; undelivered assignments do not cancel the crews' current travel or local combat. The target is the delivered report position, not a live player-position lookup.
-Approaches reserve separated positions behind the contact. Checked open ground is available where sparse roads would collapse the formation onto one junction. An already engaged covering vehicle can hold its useful position. A flanking crew already in contact, or passing within 140–450 m of the reported sector, can dismount locally instead of following a long road detour to an exact staging point; its driver and passenger receive individual native exit orders while a healthy original gunner with mounted ammunition remains at the weapon. Trucks and vehicles without usable mounted support retain the all-crew dismount path. The group's vehicle is temporarily removed from native usable vehicles so that infantry movement cannot silently turn back into a driving task. A route that would cross too close to the reported enemy gets a wider intermediate point.
+Unused hunters still receive delivered sightings when a coordinated pair already covers that sector; the pair no longer blocks reinforcements. A newer report for the same belief, displaced by at least 250 m, releases a superseded assignment while surviving infantry teams are still on their road approach. An idle covering gun that no longer sees the fight may redirect with them, so it cannot strand a travelling truck at an obsolete sighting. Deployed infantry and guns with visual contact retain local tactical ownership. These changes use delivered HQ knowledge, not live player coordinates.
+
+Approaches reserve separated positions on each arriving vehicle’s own side of the contact, avoiding long drives around the fight just to match the covering UAZ’s direction. Already physical vehicles may stage on moderate terrain relief (up to 2.5 m across the checked footprint), while the original 0.75 m spawning limit stays unchanged. Water and collision checks still apply, and infantry flank probes can search up to 45 m around their planned sector. Candidate gun positions require a clear geometry trace to the reported sector; this does not imply an enemy is currently visible. Checked open ground is available where sparse roads would collapse the formation onto one junction. An already engaged covering vehicle can hold its useful position, including when it acquires visual contact before reaching its planned stage. A flanking crew already in contact, or passing within 140–450 m of the reported sector, can dismount locally instead of following a long road detour to an exact staging point; its driver and passenger receive individual native exit orders while a healthy original gunner with mounted ammunition remains at the weapon. Trucks and vehicles without usable mounted support retain the all-crew dismount path. The group's vehicle is temporarily removed from native usable vehicles so that infantry movement cannot silently turn back into a driving task. A route that would cross too close to the reported enemy gets a wider intermediate point.
A flank starts immediately with visual cover, or after fifteen seconds with an armed covering team in position. Native perception, targeting and weapons still control the fire; support availability does not imply actual suppression. Losing cover pauses a supported flank for up to 45 seconds, then surviving infantry can conduct a bounded investigation along the checked flank route without claiming covering fire. Failed foot-order creation falls back to regrouping. Travel budgets scale with the planned route (90–240 seconds), with progressing road approaches extendable to five minutes; dismount, sector search and regroup also have deadlines. Failed movement or radio allocation preserves prior orders. Capture, loss and mission teardown remove only the assignment's own waypoints and crew holds. No new soldiers or ammunition are created by the coordinator.
@@ -111,3 +113,9 @@
A dead/unconscious/foreign/player gunner, empty or unknown mounted ammunition, severe vehicle damage, or drivetrain failure removes mounted support. Individual hold logic then allows abandonment; no replacement gunner or ammunition is created. Existing close-contact handling uses the same loaded/healthy support gate. The central covering UAZ can still retain its full crew while the flank vehicles send their foot pairs.
Validation and native movement evidence: `docs/evidence/hunter-gun-team-2026-10-05/review.json`. Tests exercise retention boundaries, split readiness, alignment deadline/combat preemption, cleanup and original hunter regressions. Native driving and infantry movement do not guarantee a clear firing line on every terrain.
+
+## Convoy combat support (2026-10-05)
+
+Armed escorts keep their current position when native perception sees the reported fight at 100–600 m. Otherwise, candidate positions are selected from each vehicle’s own approach side and checked for a clear trace to the reported sector. Ammunition trucks retain their withdrawal role. Every 30 seconds during the three-minute contact-memory window, the controller rechecks support without restarting a driver still approaching or resetting infantry routes. A masked arrived vehicle can try another firing position; when no safe clear lane exists it holds and retries. `CONVOY SUPPORT` journal events include the vehicle index, current and ordered positions, reported contact, and failed lane searches. A clear lane is not proof of actual fire or suppression.
+
+HQ no longer queues route-resume commands while the convoy tactics controller owns combat or reboarding. The executor still rejects a resume if combat starts after that command was queued.
--- a/tests/test_convoy_column.py
+++ b/tests/test_convoy_column.py
@@ -186,14 +186,14 @@
struct Items{int count;int Count(){return count;}};
struct Task{Items Vehicles{6},Teams{2};};
struct Controller{Task task;Task* m_Task=&task;float m_NextOrders=0;vector m_LastOrderContact,m_Contact;bool m_OrdersIssued=false,m_TruckWithdrawn=false;int support=0,flank=0;
- void Support(int,bool){support++;}void Flank(int,bool){flank++;}
+ void Support(int,bool,bool=false){support++;}void Flank(int,bool){flank++;}
'''+orders+r'''};
int main(){
Controller c;c.Orders(true,0);assert(c.support==6&&c.flank==2);
- c.Orders(false,30000);assert(c.support==6&&c.flank==2&&c.m_NextOrders==60000);
- c.m_Contact={39};c.Orders(false,60000);assert(c.support==6&&c.flank==2);
- c.m_Contact={40};c.Orders(false,90000);assert(c.support==12&&c.flank==4);
- c.m_OrdersIssued=false;c.Orders(true,100000);assert(c.support==18&&c.flank==6); // new engagement can issue at same location
+ c.Orders(false,30000);assert(c.support==12&&c.flank==2&&c.m_NextOrders==60000);
+ c.m_Contact={39};c.Orders(false,60000);assert(c.support==18&&c.flank==2);
+ c.m_Contact={40};c.Orders(false,90000);assert(c.support==24&&c.flank==4);
+ c.m_OrdersIssued=false;c.Orders(true,100000);assert(c.support==30&&c.flank==6); // new engagement can issue at same location
}
''')
print('PASS: convoy combat keeps native manoeuvres for unchanged contacts and refreshes when contact moves')
@@ -316,3 +316,51 @@
}
''')
print('PASS: restore missing native driving registration only for healthy owned seated crews; preserve existing association and refuse invalid/released vehicles')
+
+# Execute the production support decision, with native perception/navigation
+# boundaries controlled: useful guns hold, masked guns reposition, moving
+# drivers keep their order, and failure never claims working cover.
+support=body(q,'protected void Support(').replace('protected ', '')
+for typ in ('ERR_SupplyTeam','ERR_ConvoyVehicle','AIWaypoint'):
+ support=support.replace(typ+' ',typ+'* ')
+for name in ('orders','vehicle','movement','m_Task'):
+ 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_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;')
+run(r'''
+#include <cassert>
+#include <cmath>
+#include <string>
+#include <vector>
+using string=std::string;
+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);}
+template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;bool IsEmpty(){return this->empty();}};
+template<class...T>string Format(string,T...){return "event";}void Event(string,string){}
+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{Array<AIWaypoint*>Orders;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;Array<ERR_ConvoyVehicle*>Vehicles;};
+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;Array<ERR_SupplyTeam*>m_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++;}
+'''+support+r'''
+};
+struct Case{Tactics t;ERR_ConvoyVehicle car;ERR_SupplyTeam orders;AIWaypoint old;
+ Case(){t.task.Vehicles=Array<ERR_ConvoyVehicle*>(6,&car);t.m_Support=Array<ERR_SupplyTeam*>(6,&orders);old.point={0,0,-500};orders.Orders={&old};ERR_MountedCover::sees=false;}
+};
+int main(){
+ {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
+ {Case c;c.t.position=false;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.moves==0&&c.t.claims==1);} // no unsafe invented position
+ {Case c;c.t.spawn=false;c.t.Support(4,false);assert(c.orders.Orders[0]==&c.old);} // failed allocation preserves old command
+ {Case c;ERR_MountedCover::sees=true;c.t.Support(1,false);assert(c.t.plans==1&&c.t.moves==1);} // ammunition truck still withdraws
+ {Case c;c.t.usable=false;c.t.Support(4,false);assert(c.orders.Orders.empty()&&c.t.plans==0);}
+}
+''')
+print('PASS: actual convoy support preserves visual cover and ongoing approach, retries masked arrived positions, withholds unsafe moves and preserves ammo withdrawal')
--- a/tests/test_convoy_placement.py
+++ b/tests/test_convoy_placement.py
@@ -42,6 +42,11 @@
reset();assert(p.Clear({0,10,0},90));assert(near(game.world.low.x,-4.5)&&near(game.world.high.x,4.5)&&near(game.world.low.z,-2)&&near(game.world.high.z,2));
reset();assert(!p.Clear({NAN,10,0},0)&&game.world.queries==0&&ERR_Actors::grounds==0);assert(!p.Clear({0,10,0},NAN));
reset();ERR_Actors::slope=.2;assert(!p.Clear({0,10,0},0)&&game.world.queries==0); // 0.8m cross-footprint rise.
+ reset();ERR_Actors::slope=.2;assert(p.Clear({0,10,0},0,2.5)); // already physical driver may stage on this gentle slope
+ reset();ERR_Actors::slope=.7;assert(!p.Clear({0,10,0},0,2.5)); // 2.8 m relief still rejected
+ reset();assert(!p.Clear({0,10,0},0,NAN));assert(!p.Clear({0,10,0},0,3));
+ reset();ChimeraWorldUtils::water=true;assert(!p.Clear({0,10,0},0,2.5));
+ reset();game.world.fraction=0;assert(!p.Clear({0,10,0},0,2.5));
reset();ERR_Actors::elevation=.29;assert(!p.Clear({0,.29,0},0)&&game.world.queries==0);
reset();assert(!p.Clear({0,10.31,0},0)&&game.world.queries==0);
reset();ChimeraWorldUtils::water=true;assert(!p.Clear({0,10,0},0)&&game.world.queries==0&&ERR_Actors::grounds==1);
--- a/tests/test_hq_convoy_combat.py
+++ b/tests/test_hq_convoy_combat.py
@@ -46,6 +46,7 @@
struct Receipt {string SourceId="convoy-1";};
struct ERR_ConvoyTask {bool Stopped=false,Ready=true;struct Receipt receipt;struct Receipt* Receipt=&receipt;std::vector<ERR_ConvoyVehicle*> Vehicles;};
struct Service {ERR_ConvoyTask* task=nullptr;int Count(){return task?1:0;}ERR_ConvoyTask* At(int){return task;}ERR_ConvoyTask* Find(string id){return task&&task->Receipt->SourceId==id?task:nullptr;}
+ bool resumeAllowed=true;bool CanResumeCombat(ERR_ConvoyTask*){return resumeAllowed;}
int calls=0;bool valid=true;ERR_ConvoyCombatOrder* last=nullptr;
bool ApplyCombatOrder(ERR_ConvoyTask* task,ERR_ConvoyCombatOrder* o,float){calls++;last=o;if(!task||!valid)return false;
auto s=task->Vehicles.at(o->VehicleIndex)->Combat;s->Held=o->Hold;s->LastCommandId=o->CommandId;s->ReportId=o->ReportId;s->ObservedAt=o->ObservedAt;return true;}};
@@ -79,7 +80,8 @@
ERR_HQReport newer=r;newer.MessageId=2;newer.ObservedAt=12000;h.m_Contacts={&newer};h.PlanConvoy(13000);
assert(u.PendingConvoyOrder->ReportId==2);u.Downlink->result=1;h.TickDownlink(&u,15000);assert(v.Combat->ReportId==2);
h.PlanConvoy(71000);assert(!u.Downlink); // less than60s quiet
- h.PlanConvoy(72000);assert(u.Downlink&&!u.PendingConvoyOrder->Hold);
+ h.m_Convoys.resumeAllowed=false;h.PlanConvoy(72000);assert(!u.Downlink); // active tactics: do not flood radio with refused resumes
+ h.m_Convoys.resumeAllowed=true;h.PlanConvoy(72000);assert(u.Downlink&&!u.PendingConvoyOrder->Hold);
newer.MessageId=3;newer.ObservedAt=73000;u.Downlink->result=1;h.TickDownlink(&u,74000);
assert(v.Combat->Held&&h.m_Convoys.calls==2&&!u.Downlink); // new report rejects in-flight RESUME
h.m_Network.available=false;h.PlanConvoy(75000);assert(!u.Downlink&&v.Combat->Held);
--- a/tests/test_hunter_coordination.py
+++ b/tests/test_hunter_coordination.py
@@ -2,7 +2,7 @@
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterTactics.c'
-code='\n'.join(body(p,x) for x in ['bool Covered(', 'void Tick(ERR_HQ hq, array<ref ERR_HunterVehicle>'])
+code='\n'.join(body(p,x) for x in ['bool Covered(', 'protected void RefreshApproaches(', 'void Tick(ERR_HQ hq, array<ref ERR_HunterVehicle>'])
code=code.replace('protected ','').replace('foreach (','for (').replace('vector position','Vec position').replace('vector desired','Vec desired')
for typ in ['ERR_HunterVehicle','ERR_HunterAttack','ERR_HunterTactics','ERR_HQReport']:
code=code.replace('array<ref '+typ+'>','Array<'+typ+'*>').replace('array<'+typ+'>','Array<'+typ+'*>')
@@ -15,7 +15,7 @@
code=code.replace('Memory().Get','Memory()->Get').replace('Tactics.Stopped','Tactics->Stopped').replace('Entity.GetOrigin','Entity->GetOrigin')
code=code.replace('ERR_HunterTactics.CoverCrew','ERR_HunterTactics::CoverCrew')
code=code.replace('ERR_HQ.Event','Event').replace('string.Format','Format').replace('ERR_HunterAttackRules.Fresh','Fresh').replace('ERR_HunterAttackRules.Offset','Offset').replace('vector.DistanceXZ','Distance')
-code=code.replace('"0 0 1"','Vec(0,0,1)').replace('bool running;','bool running=false;').replace('int nearby;','int nearby=0;').replace('int active;','int active=0;').replace('int nearest;','int nearest=0;').replace('ERR_HQBelief* best;','ERR_HQBelief* best=nullptr;')
+code=code.replace('"0 0 1"','Vec(0,0,1)').replace('bool running;','bool running=false;').replace('int nearby;','int nearby=0;').replace('bool travelling = true;', 'bool travelling = true;').replace('int active;','int active=0;').replace('int nearest;','int nearest=0;').replace('ERR_HQBelief* best;','ERR_HQBelief* best=nullptr;')
run(r'''
#include <cassert>
#include <cmath>
@@ -31,11 +31,11 @@
struct ERR_GameMode{};struct Entity{Vec pos;Vec GetOrigin(){return pos;}};
struct ERR_HunterTactics;
struct ERR_HunterVehicle{bool CloseContactHeld=false,Registered=true,Released=false,valid=true,plan=true,radio=true,armed=true;struct Entity entity;struct Entity*Entity=&entity;ERR_HunterTactics*Tactics=nullptr;bool CrewValid(ERR_GameMode*){return valid;}};
-struct ERR_HQBelief{Vec PredictedPosition;float ObservedAt=0;int MessageId=1;bool actionable=true;bool Actionable(){return actionable;}};
+struct ERR_HQBelief{Vec PredictedPosition;float ObservedAt=0;int BeliefId=1,MessageId=1;bool actionable=true;bool Actionable(){return actionable;}};
struct ERR_HQReport{int Target=0;};struct Memory{Array<ERR_HQBelief> beliefs;ERR_HQBelief*Get(int i){return &beliefs[i];}};
-struct ERR_HQ{struct Memory memory;Array<ERR_HQReport>reports;struct Memory*Memory(){return &memory;}void SupportContacts(Array<ERR_HQReport*>&out,float){for(auto&r:reports)out.Insert(&r);}void Contact(Vec p,float age){ERR_HQBelief b;b.PredictedPosition=p;b.ObservedAt=age;reports.Insert({memory.beliefs.Count()});memory.beliefs.Insert(b);}};
-struct ERR_HunterAttack{Vec ReportPosition,Forward;float Observed=0,Started=0;int Message=0;Array<ERR_HunterTactics*>Teams;};
-struct ERR_HunterTactics{static bool CoverCrew(ERR_HunterVehicle*u,ERR_GameMode*){return u->armed;}ERR_HunterVehicle*Unit;ERR_HunterAttack*Attack;int Role;bool Stopped=false;Vec Stage;ERR_HunterTactics(ERR_GameMode*,ERR_HunterVehicle*u,ERR_HunterAttack*a,int r):Unit(u),Attack(a),Role(r){}bool Plan(Array<Vec>){Stage=Unit->Entity->GetOrigin();return Unit->plan;}bool Queue(ERR_HQ*){return Unit->radio;}bool Living(){return !Unit->Released;}void Stop(){Stopped=true;}};
+struct ERR_HQ{struct Memory memory;Array<ERR_HQReport>reports;struct Memory*Memory(){return &memory;}void SupportContacts(Array<ERR_HQReport*>&out,float){for(auto&r:reports)out.Insert(&r);}void Contact(Vec p,float age){ERR_HQBelief b;b.PredictedPosition=p;b.ObservedAt=age;b.BeliefId=memory.beliefs.Count()+1;reports.Insert({memory.beliefs.Count()});memory.beliefs.Insert(b);}};
+struct ERR_HunterAttack{int BeliefId=0;Vec ReportPosition,Forward;float Observed=0,Started=0;int Message=0;Array<ERR_HunterTactics*>Teams;};
+struct ERR_HunterTactics{static bool CoverCrew(ERR_HunterVehicle*u,ERR_GameMode*){return u->armed;}ERR_HunterVehicle*Unit;ERR_HunterAttack*Attack;int Role,State=0;bool visual=false;bool Covering(){return visual;}bool Stopped=false;Vec Stage;ERR_HunterTactics(ERR_GameMode*,ERR_HunterVehicle*u,ERR_HunterAttack*a,int r):Unit(u),Attack(a),Role(r){}bool Plan(Array<Vec>){Stage=Unit->Entity->GetOrigin();return Unit->plan;}bool Queue(ERR_HQ*){return Unit->radio;}bool Living(){return !Unit->Released;}void Stop(){Stopped=true;}};
struct Coordination{ERR_GameMode mode;ERR_GameMode*m_Mode=&mode;Array<ERR_HunterAttack*>m_Attacks;float m_NextPlan=0;
'''+code+r'''
};
@@ -43,6 +43,17 @@
{ERR_HQ local;local.Contact({0,0,0},0);ERR_HunterVehicle busy,other;busy.CloseContactHeld=true;
Array<ERR_HunterVehicle*> pair={&busy,&other};Coordination c;c.Tick(&local,pair,1000);assert(c.m_Attacks.empty());}
+ // Only newer delivered knowledge for this belief redirects traveling crews.
+ {Coordination c;ERR_HunterVehicle u,v;ERR_HunterAttack a;a.BeliefId=3;a.Observed=10;
+ ERR_HunterTactics t(&c.mode,&u,&a,0),f(&c.mode,&v,&a,1);a.Teams={&t,&f};c.m_Attacks={&a};
+ ERR_HQBelief b;b.BeliefId=4;b.ObservedAt=20;b.PredictedPosition={500,0,0};
+ c.RefreshApproaches(&b,100);assert(!t.Stopped&&!f.Stopped);
+ b.BeliefId=3;f.State=2;c.RefreshApproaches(&b,100);assert(!t.Stopped&&!f.Stopped);
+ f.State=1;b.ObservedAt=9;c.RefreshApproaches(&b,100);assert(!t.Stopped);
+ b.ObservedAt=20;b.PredictedPosition={249,0,0};c.RefreshApproaches(&b,100);assert(!t.Stopped);
+ b.PredictedPosition={250,0,0};t.State=3;t.visual=true;c.RefreshApproaches(&b,100);assert(!t.Stopped&&!f.Stopped); // active gun retains fight
+ t.visual=false;c.RefreshApproaches(&b,100);assert(t.Stopped&&f.Stopped); // blind idle gun cannot trap the travelling truck on old report
+ }
ERR_HQ hq;hq.Contact({0,0,0},0);hq.Contact({6000,0,0},1); // Newer but unreachable report must not starve local fight.
ERR_HunterVehicle a,b,c;a.entity.pos={0,0,-210};b.entity.pos={-155,0,-240};c.entity.pos={155,0,-240};
Array<ERR_HunterVehicle*>units={&a,&b,&c};Coordination co;co.Tick(&hq,units,1000);
@@ -52,7 +63,7 @@
a.Released=true;b.Released=true;assert(!co.Covered({200,0,0}));a.Released=false;b.Released=false;
assert(co.Covered({200,0,0})&&!co.Covered({6000,0,0}));
ERR_HunterVehicle d,e;d.entity.pos={6000,0,-210};e.entity.pos={5845,0,-240};units.Insert(&d);units.Insert(&e);
- co.Tick(&hq,units,5000);assert(!d.Tactics); // Ten-second planning throttle.
+ co.Tick(&hq,units,5000);assert(!d.Tactics); // Five-second planning throttle.
co.Tick(&hq,units,11000);assert(co.m_Attacks.Count()==2&&d.Tactics&&e.Tactics); // Independent front.
co.Tick(&hq,units,21000);assert(co.m_Attacks.Count()==2); // No duplicate crews/encounter.
for(auto*t:co.m_Attacks[0]->Teams)t->Stop();
--- a/tests/test_hunter_opening.py
+++ b/tests/test_hunter_opening.py
@@ -107,12 +107,11 @@
hq.memory.belief.ObservedAt=11;
int requests=hq.requests;service.QueueDestination(&disconnected,&hq,11000);assert(hq.requests==requests);
hq.connected=true;service.QueueDestination(&disconnected,&hq,15000);assert(disconnected.Downlink&&disconnected.LastContact==11);
- // An existing coordinated fight must not attract every unused UAZ as well.
+ // A two-crew encounter must not suppress a spare hunter's delivered response.
ERR_HunterVehicle reserve;service.coordinated=true;hq.memory.belief.ObservedAt=20;
requests=hq.requests;service.QueueDestination(&reserve,&hq,20000);
- assert(hq.requests==requests&&!reserve.Downlink&&reserve.LastContact==-1);
- service.coordinated=false;service.QueueDestination(&reserve,&hq,20000);
- assert(hq.requests==requests+1&&reserve.Downlink);
+ assert(hq.requests==requests+1&&reserve.Downlink&&reserve.LastContact==20);
+
}
''')
print('PASS: real opening road geometry enforces wide azimuths; fresh delivered contact preempts patrol cooldown, respects radio failures and does not starve in-flight interception')
--- a/tests/test_hunter_tactics.py
+++ b/tests/test_hunter_tactics.py
@@ -12,7 +12,7 @@
#include <string>
#include <vector>
using string=std::string;
-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 n){return n==0?x:n==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 "position";}};
+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 n){return n==0?x:n==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};}float Length(){return std::hypot(x,z);}void Normalize(){float length=Length();x/=length;z/=length;}string ToString(){return "position";}};
namespace Geometry{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;}}
template<class T>using Array=std::vector<T>;
'''
@@ -62,13 +62,17 @@
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;}
+ 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;}
'''+tick+r'''
};
struct Fixture{IEntity car,crew;GroupType group;UnitType unit;AttackType attack;RadioType radio;Tactics t;ERR_HQ hq;
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.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;
+ }
{Fixture f;assert(!f.t.Tick(&f.hq,1000));assert(!f.t.Delivered&&!f.unit.CombatHeld&&!f.unit.clears&&f.t.moves==0);f.radio.result=-1;assert(!f.t.Tick(&f.hq,2000)&&f.t.Stopped&&f.unit.clears==0);}
{Fixture f;f.radio.result=1;f.t.moveWorks=false;assert(!f.t.Tick(&f.hq,1000)&&!f.t.Delivered&&f.unit.clears==0);}
{Fixture f;f.radio.result=1;assert(f.t.Tick(&f.hq,1000));assert(f.t.State==2&&f.t.boards==1&&f.unit.clears==1&&f.unit.CombatHeld);f.t.aboard=false;assert(f.t.Tick(&f.hq,2000)&&f.t.State==3);int moves=f.t.moves;f.t.Tick(&f.hq,5000);assert(f.t.moves==moves);f.attack.ready=true;f.t.Tick(&f.hq,6000);assert(f.t.State==4&&f.t.moves==moves+1);f.t.Tick(&f.hq,7000);assert(f.t.moves==moves+1);f.t.position=f.t.Approach;f.t.Tick(&f.hq,8000);assert(f.t.State==5);f.t.Tick(&f.hq,68000);assert(f.t.State==6);f.t.aboard=true;assert(!f.t.Tick(&f.hq,69000)&&f.t.Stopped&&!f.unit.CombatHeld);}
@@ -173,3 +177,20 @@
};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')
+
+# Execute production per-vehicle approach geometry: opposite arrivals stay on
+# their own sides instead of sharing the cover vehicle's staging axis.
+approach=body(p,'static vector Approach(').replace('vector ', 'Vec ')
+run(stubs+approach+r'''
+int main(){
+ Vec contact{100,0,100},fallback{0,0,1};
+ for(Vec origin : {Vec{100,0,-900},Vec{100,0,1100},Vec{-900,0,100},Vec{1100,0,100}}){
+ Vec forward=Approach(origin,contact,fallback);
+ Vec stage=contact-forward*260;
+ assert(Geometry::Dot(stage-contact,origin-contact)>0);
+ assert(Geometry::DistanceXZ(origin,stage)<Geometry::DistanceXZ(origin,contact));
+ assert(std::abs(forward.Length()-1)<0.0001);
+ }
+ assert(Approach(contact,contact,fallback).z==1);
+}
+''')
--- a/tests/test_supply_convoys.py
+++ b/tests/test_supply_convoys.py
@@ -165,7 +165,7 @@
delete out;delete in;ERR_Actors::fail=true;assert(!Board(&truck,true));
}
""")
-delivery=body(D/'ERR_SupplyConvoyService.c','override bool ApplyCombatOrder(')
+delivery=body(D/'ERR_SupplyConvoyService.c','override bool CanResumeCombat(')+'\n'+body(D/'ERR_SupplyConvoyService.c','override bool ApplyCombatOrder(')
delivery=delivery.replace('override ','').replace('ERR_SupplyConvoyTask.Cast(task)','dynamic_cast<ERR_SupplyConvoyTask*>(task)')
for typ in ('ERR_ConvoyTask','ERR_SupplyConvoyTask','ERR_ConvoyCombatOrder'):delivery=re.sub(r'\b'+typ+r' (?=\w)',typ+'* ',delivery)
for name in ('supply','order'):delivery=delivery.replace(name+'.',name+'->')
@@ -182,10 +182,11 @@
};
int main(){
Service service;ERR_SupplyConvoyTask task;ERR_ConvoyCombatOrder order;
- for(int state=0;state<3;state++){task.tactics.state=state;assert(service.ApplyCombatOrder(&task,&order,100));}
+ for(int state=0;state<3;state++){task.tactics.state=state;assert(service.CanResumeCombat(&task)==(state==0));assert(service.ApplyCombatOrder(&task,&order,100));}
assert(task.tactics.reports==3);order.Hold=false;assert(!service.ApplyCombatOrder(&task,&order,100));assert(service.applied==3);
task.tactics.state=0;assert(service.ApplyCombatOrder(&task,&order,100));assert(task.tactics.reports==3);
service.accept=false;order.Hold=true;assert(!service.ApplyCombatOrder(&task,&order,100)&&task.tactics.reports==3);
+ assert(!service.CanResumeCombat(nullptr));
assert(!service.ApplyCombatOrder(nullptr,&order,100));assert(!service.ApplyCombatOrder(&task,nullptr,100));
}
""")