Integrate delivered HQ memory, finite physical convoy and RHS WCS dependencies

This commit is contained in:
2026-09-20 02:55:30 +02:00
parent e7b4141f69
commit a2aab17171
230 changed files with 37976 additions and 139 deletions
@@ -0,0 +1,83 @@
// One mission-owned allocator. Reservations include every future native crew body.
class ERR_ConvoyReceipt
{
int Id;
string SourceId;
bool Consumed;
int OutstandingBodies = 19;
int OutstandingGroups = 6;
ref array<int> Records = {-1, -1, -1, -1, -1, -1};
}
class ERR_ConvoyPool
{
protected ERR_GameMode m_Mode;
protected int m_Limit;
protected ref array<ref ERR_ConvoyReceipt> m_Receipts = {};
void ERR_ConvoyPool(ERR_GameMode mode, int maxConvoys)
{
m_Mode = mode; m_Limit = Math.Clamp(maxConvoys, 0, 8);
}
static int CrewCount(int vehicle)
{
if (vehicle == 1) return 1;
if (vehicle == 2) return 9;
if (vehicle == 4) return 3;
if (vehicle == 0 || vehicle == 3 || vehicle == 5) return 2;
return 0;
}
protected bool Current()
{
return Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Mode.Forces();
}
bool Owns(ERR_ConvoyReceipt receipt)
{
return receipt && m_Receipts.Contains(receipt);
}
int CommittedBodies()
{
if (!m_Mode || !m_Mode.Forces()) return 60;
int total;
ERR_ForceLedger ledger = m_Mode.Forces();
for (int i = 0; i < ledger.Count(); i++)
{
ERR_ForceRecord record = ledger.At(i);
if (record && record.Physical && !record.Eliminated) total += record.Strength;
}
foreach (ERR_ConvoyReceipt receipt : m_Receipts) total += receipt.OutstandingBodies;
return total;
}
protected int CommittedGroups()
{
int total = m_Mode.Forces().Count();
foreach (ERR_ConvoyReceipt receipt : m_Receipts) total += receipt.OutstandingGroups;
return total;
}
ERR_ConvoyReceipt Reserve(string sourceId)
{
if (!Current() || sourceId.IsEmpty() || sourceId.Length() > 64) return null;
foreach (ERR_ConvoyReceipt known : m_Receipts) if (known.SourceId == sourceId) return known;
if (m_Receipts.Count() >= m_Limit || CommittedBodies() + 19 > 60 || CommittedGroups() + 6 > 128) return null;
ERR_ConvoyReceipt receipt = new ERR_ConvoyReceipt(); receipt.Id = m_Receipts.Count() + 1; receipt.SourceId = sourceId;
m_Receipts.Insert(receipt);
return receipt;
}
bool Consume(ERR_ConvoyReceipt receipt)
{
if (!Current() || !Owns(receipt) || receipt.Consumed || CommittedBodies() > 60 || CommittedGroups() > 128) return false;
receipt.Consumed = true;
return true;
}
bool BindRecord(ERR_ConvoyReceipt receipt, int vehicle, ERR_ForceRecord record)
{
if (!Current() || !Owns(receipt) || !receipt.Consumed || vehicle < 0 || vehicle >= 6 || !record) return false;
if (receipt.Records[vehicle] >= 0) return receipt.Records[vehicle] == record.Id;
if (m_Mode.Forces().Find(record.Id) != record || record.Kind != ERR_ForceKind.CONVOY || !record.Group || !record.Physical || record.Eliminated) return false;
if (record.SourceId != receipt.SourceId + "-vehicle-" + vehicle.ToString() || record.InitialStrength != CrewCount(vehicle) || record.Strength != record.InitialStrength) return false;
receipt.Records[vehicle] = record.Id;
receipt.OutstandingBodies -= CrewCount(vehicle); receipt.OutstandingGroups--;
return true;
}
// There is intentionally no refund/reset method. A consumed startup attempt
// can retain partial native entities even when a later seat or ledger step fails.
}
@@ -0,0 +1,89 @@
// Conservative native volume check; no scenery removal or alternate placement.
class ERR_ConvoyPlacement
{
protected bool m_Obstructed;
protected int m_Visited;
protected string m_LastReason;
protected float m_LastLowest;
protected float m_LastHighest;
protected IEntity m_FirstObstruction;
protected float m_LastTrace = -1;
float LastTraceFraction() { return m_LastTrace; }
string Diagnostic()
{
string prefab = "none";
vector position;
if (m_FirstObstruction)
{
prefab = "no prefab";
if (m_FirstObstruction.GetPrefabData()) prefab = m_FirstObstruction.GetPrefabData().GetPrefabName();
position = m_FirstObstruction.GetOrigin();
}
return string.Format("%1; terrainY=%2..%3 span=%4; visited=%5; first=%6 at %7; trace=%8",
m_LastReason, m_LastLowest, m_LastHighest, m_LastHighest - m_LastLowest, m_Visited, prefab, position, m_LastTrace);
}
protected bool Occupancy(IEntity entity)
{
m_Visited++;
if (m_Visited > 128) { m_Obstructed = true; m_LastReason = "native query visit cap"; }
else if (entity && entity.GetVObject())
{
m_Obstructed = true; m_LastReason = "native VObject volume overlap"; m_FirstObstruction = entity;
}
return !m_Obstructed;
}
static bool Terrain(float origin, float lowest, float highest)
{
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;
}
bool Clear(vector position, float heading)
{
m_LastReason = "invalid origin or heading"; m_LastLowest = 1e20; m_LastHighest = -1e20;
m_FirstObstruction = null; m_Visited = 0; m_LastTrace = -1;
if (!ERR_ForceRules.FiniteVector(position) || !ERR_ForceRules.Finite(heading)) return false;
float radians = heading * Math.DEG2RAD;
vector forward = Vector(Math.Sin(radians), 0, Math.Cos(radians));
vector right = Vector(forward[2], 0, -forward[0]);
float lowest = 1e20, highest = -1e20;
vector low = position, high = position;
for (int x = -1; x <= 1; x++)
{
for (int z = -1; z <= 1; z++)
{
vector sample = ERR_Actors.Ground(position + right * (x * 2.0) + forward * (z * 4.5));
if (!ERR_ForceRules.FiniteVector(sample)) { m_LastReason = "nonfinite terrain sample"; return false; }
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), sample))
{ m_LastReason = "water at footprint sample"; return false; }
lowest = Math.Min(lowest, sample[1]); highest = Math.Max(highest, sample[1]);
low[0] = Math.Min(low[0], sample[0]); low[2] = Math.Min(low[2], sample[2]);
high[0] = Math.Max(high[0], sample[0]); high[2] = Math.Max(high[2], sample[2]);
}
}
m_LastLowest = lowest; m_LastHighest = highest;
if (!Terrain(position[1], lowest, highest))
{ 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;
GetGame().GetWorld().QueryEntitiesByAABB(low, high, Occupancy);
// Entity bounding boxes can include distant branches or overhead wires.
// Their overlap is diagnostic only; the filled native volume decides collision.
vector center = (low + high) * 0.5;
TraceBox volume = new TraceBox();
volume.Start = center; volume.End = center + "0 0.01 0";
volume.Mins = low - center; volume.Maxs = high - center;
// Nine terrain/water samples already qualify the supporting ground below us.
volume.Flags = TraceFlags.ENTS | TraceFlags.OCEAN;
m_LastTrace = GetGame().GetWorld().TraceMove(volume, null);
m_Obstructed = !ERR_ForceRules.Finite(m_LastTrace) || m_LastTrace < 1;
m_FirstObstruction = null;
if (m_Obstructed)
{
m_FirstObstruction = volume.TraceEnt;
m_LastReason = "native filled volume collision";
if (!ERR_ForceRules.Finite(m_LastTrace)) m_LastReason = "invalid native trace fraction";
}
else m_LastReason = "clear";
return !m_Obstructed;
}
}
@@ -0,0 +1,163 @@
// Deterministic projection/sampling adapted from the owner's road-polyline helper.
// One explicit seed selects the nearest valid native road within 500m; no random or offroad fallback.
class ERR_ConvoyRoadPlan
{
static bool Sample(array<vector> points, float distance, out vector position, out vector direction)
{
if (distance < 0 || !ERR_ForceRules.Finite(distance)) return false;
for (int i = 1; i < points.Count(); i++)
{
float length = vector.DistanceXZ(points[i - 1], points[i]);
if (length < 0.01) continue;
if (distance <= length)
{
position = vector.Lerp(points[i - 1], points[i], distance / length);
direction = points[i] - points[i - 1]; direction[1] = 0; direction.Normalize();
return true;
}
distance -= length;
}
return false;
}
static bool CandidateStart(float length, float projected, int candidateIndex, out float start)
{
if (!ERR_ForceRules.Finite(length) || !ERR_ForceRules.Finite(projected) || length < 390 || projected < 0 || projected > length || candidateIndex < 0 || candidateIndex >= 33) return false;
float preferred = Math.Clamp(projected - 87.5, 15, length - 375);
float offset;
if (candidateIndex > 0)
{
int step = (candidateIndex + 1) / 2;
offset = step * 35;
if (candidateIndex % 2 == 0) offset = -offset;
}
start = preferred + offset;
// Refuse outside offsets; clamping would repeatedly test the same formation.
return start >= 15 && start <= length - 375;
}
static bool FromPolyline(array<vector> source, vector seed, out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route, out float proximity, int candidateIndex = 0)
{
positions = {}; angles = {}; route = null; proximity = 1e20;
if (!source || source.Count() < 2 || source.Count() > 1024 || !ERR_ForceRules.FiniteVector(seed)) return false;
array<vector> points = {};
float length, projected, nearest = 1e20;
foreach (vector point : source)
{
if (!ERR_ForceRules.FiniteVector(point)) return false;
if (points.IsEmpty() || vector.DistanceXZ(points[points.Count() - 1], point) >= 0.01) points.Insert(point);
}
if (points.Count() < 2) return false;
for (int i = 1; i < points.Count(); i++)
{
vector delta = points[i] - points[i - 1]; delta[1] = 0;
vector offset = seed - points[i - 1]; offset[1] = 0;
float segment = delta.Length();
float fraction = Math.Clamp(vector.Dot(offset, delta) / (segment * segment), 0, 1);
vector closest = vector.Lerp(points[i - 1], points[i], fraction);
float distance = vector.DistanceXZ(seed, closest);
if (distance < nearest) { nearest = distance; projected = length + fraction * segment; }
length += segment;
}
// 205m is the minimum formation road. A 200m forward tail also allows the
// lead vehicle to travel beyond the native 35m waypoint completion radius.
if (!ERR_ForceRules.Finite(length) || length < 390 || nearest > 500) return false;
// Orient along the same authored road toward its longer remaining end.
if (projected > length * 0.5)
{
array<vector> reversed = {};
for (int i = points.Count() - 1; i >= 0; i--) reversed.Insert(points[i]);
points = reversed; projected = length - projected;
}
float start;
if (!CandidateStart(length, projected, candidateIndex, start)) return false;
float leadDistance = start + 175;
for (int vehicle = 0; vehicle < 6; vehicle++)
{
vector position, direction;
if (!Sample(points, leadDistance - vehicle * 35, position, direction)) return false;
if (vector.DistanceXZ(position, seed) > 500) return false;
foreach (vector previous : positions) if (vector.DistanceXZ(previous, position) < 18) return false;
positions.Insert(position);
float heading = ERR_ForceRules.Heading(position, position + direction, 0);
angles.Insert(Vector(heading, 0, 0));
}
ERR_ForceRoute candidate = new ERR_ForceRoute();
if (!candidate.Add(positions[0])) return false;
float traversed;
for (int i = 1; i < points.Count(); i++)
{
traversed += vector.DistanceXZ(points[i - 1], points[i]);
if (traversed <= leadDistance) continue;
// Retain every intermediate bend. Never connect omitted vertices with an
// offroad chord to fit the 64-point route cap.
if (!candidate.Add(points[i])) return false;
if (traversed >= leadDistance + 200) break;
}
if (!candidate.Seal() || candidate.Length() < 200) return false;
vector center, centerDirection;
if (!Sample(points, start + 87.5, center, centerDirection)) return false;
proximity = vector.DistanceXZ(center, seed);
if (proximity > 500) return false;
route = candidate;
return true;
}
static bool Prefer(float distance, array<vector> positions, ERR_ForceRoute route, float bestDistance, array<vector> bestPositions, ERR_ForceRoute bestRoute)
{
if (!bestRoute || distance < bestDistance) return true;
if (distance > bestDistance) return false;
for (int i = 0; i < positions.Count(); i++)
{
for (int axis = 0; axis < 3; axis++)
{
if (positions[i][axis] < bestPositions[i][axis]) return true;
if (positions[i][axis] > bestPositions[i][axis]) return false;
}
}
int count = Math.Min(route.Count(), bestRoute.Count());
for (int i = 0; i < count; i++)
{
vector point = route.At(i), bestPoint = bestRoute.At(i);
for (int axis = 0; axis < 3; axis++)
{
if (point[axis] < bestPoint[axis]) return true;
if (point[axis] > bestPoint[axis]) return false;
}
}
return route.Count() < bestRoute.Count();
}
static bool Build(vector seed, out array<vector> positions, out array<vector> angles, out ERR_ForceRoute route)
{
positions = {}; angles = {}; route = null;
if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(seed)) return false;
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager()) return false;
array<BaseRoad> roads = {};
world.GetRoadNetworkManager().GetRoadsInAABB(seed + "-500 -1000 -500", seed + "500 1000 500", roads);
if (roads.Count() > 128) return false;
float bestDistance = 1e20;
foreach (BaseRoad road : roads)
{
if (!road || road.GetWidth() < 4) continue;
array<vector> points = {}; road.GetPoints(points);
for (int candidateIndex = 0; candidateIndex < 33; candidateIndex++)
{
array<vector> candidatePositions, candidateAngles;
ERR_ForceRoute polyline;
float distance;
if (!FromPolyline(points, seed, candidatePositions, candidateAngles, polyline, distance, candidateIndex)) continue;
for (int i = 0; i < candidatePositions.Count(); i++) candidatePositions[i] = ERR_Actors.Ground(candidatePositions[i]);
ERR_ForceRoute grounded = new ERR_ForceRoute();
bool valid = true;
for (int i = 0; i < polyline.Count(); i++)
{
vector point = ERR_Actors.Ground(polyline.At(i));
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !grounded.Add(point)) { valid = false; break; }
}
if (!valid || !grounded.Seal() || !ERR_ConvoyRules.Plan(candidatePositions, candidateAngles, grounded)) continue;
if (!Prefer(distance, candidatePositions, grounded, bestDistance, positions, route)) continue;
if (!ERR_ConvoyRules.GroundClear(candidatePositions, candidateAngles)) continue;
positions = candidatePositions; angles = candidateAngles; route = grounded; bestDistance = distance;
}
}
return route != null;
}
}
@@ -0,0 +1,419 @@
// Independent physical adaptation of the owner's supply-convoy extension.
// HQ must dispatch an authored route using a finite canonical pool receipt.
class ERR_ConvoyResources
{
static ResourceName VehiclePrefab(int index)
{
if (index == 1) return "{3336BE330C4F355B}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_ammo.et";
if (index == 2) return "{16C1F16C9B053801}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_transport.et";
if (index == 4) return "{1DC01AAACFFA8B63}Prefabs/Vehicles/Tracked/BMP1/BMP1_Base.et";
if (index == 0 || index == 3 || index == 5) return "{0B4DEA8078B78A9B}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469_PKM.et";
return "";
}
static ResourceName GroupPrefab() { return "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et"; }
static ResourceName Soldier() { return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; }
static ResourceName Rifle() { return "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et"; }
static ResourceName Magazine() { return "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et"; }
static bool Available()
{
array<ResourceName> required = {GroupPrefab(), Soldier(), Rifle(), Magazine(), ERR_Resources.MoveWaypoint()};
for (int i = 0; i < 6; i++) required.Insert(VehiclePrefab(i));
foreach (ResourceName prefab : required)
{
Resource resource = Resource.Load(prefab);
if (!resource || !resource.IsValid()) return false;
}
return true;
}
}
class ERR_ConvoyRules
{
static bool EmptyCrewTemplate(SCR_AIGroup group)
{
// The vanilla faction base authors six queued soldiers. Only our memberless
// template may receive finite seat occupants; never retire another spawn job.
return group && !group.GetSpawnImmediately() && group.GetAgentsCount() == 0
&& (!group.m_aUnitPrefabSlots || group.m_aUnitPrefabSlots.IsEmpty());
}
static bool Plan(array<vector> positions, array<vector> angles, ERR_ForceRoute route)
{
if (!positions || !angles || positions.Count() != 6 || angles.Count() != 6 || !route || route.Looped() || route.Count() < 2 || !route.Seal()) return false;
for (int i = 0; i < 6; i++)
{
if (!ERR_ForceRules.FiniteVector(positions[i]) || !ERR_ForceRules.FiniteVector(angles[i])) return false;
for (int j = 0; j < i; j++) if (vector.DistanceXZ(positions[i], positions[j]) < 18) return false;
}
return true;
}
static bool GroundClear(array<vector> positions, array<vector> angles)
{
if (!positions || !angles || positions.Count() != 6 || angles.Count() != 6) return false;
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int i = 0; i < positions.Count(); i++) if (!placement.Clear(positions[i], angles[i][0])) return false;
return true;
}
static bool PartOf(IEntity entity, IEntity root)
{
int depth;
while (entity && depth++ < 32)
{
if (entity == root) return true;
entity = entity.GetParent();
}
return false;
}
static bool Captured(Vehicle vehicle, SCR_AIGroup group)
{
if (group && group.GetPlayerCount(true) > 0) return true;
if (!vehicle) return false;
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
foreach (int id : ids)
{
IEntity player = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
if (player && PartOf(CompartmentAccessComponent.GetVehicleIn(player), vehicle)) return true;
}
return false;
}
static bool CollectSeats(IEntity root, array<BaseCompartmentSlot> seats)
{
array<IEntity> pending = {root};
int cursor;
while (cursor < pending.Count() && cursor < 128)
{
IEntity entity = pending[cursor++];
if (!entity) return false;
SCR_BaseCompartmentManagerComponent manager = SCR_BaseCompartmentManagerComponent.Cast(entity.FindComponent(SCR_BaseCompartmentManagerComponent));
if (manager)
{
array<BaseCompartmentSlot> local = {}; manager.GetCompartments(local);
foreach (BaseCompartmentSlot seat : local) if (seat && !seats.Contains(seat)) seats.Insert(seat);
}
IEntity child = entity.GetChildren();
while (child)
{
if (pending.Count() >= 128 || pending.Contains(child)) return false;
pending.Insert(child); child = child.GetSibling();
}
}
return cursor == pending.Count();
}
}
class ERR_ConvoyVehicle
{
int Index;
Vehicle Entity;
SCR_AIGroup Group;
IEntity Driver;
ref array<IEntity> Members = {};
ref array<BaseCompartmentSlot> Seats = {};
AIWaypoint Waypoint;
int RouteIndex;
bool Released;
bool Registered;
bool Arrived;
float LegDeadline;
float NextReadinessNote;
void ClearOrder()
{
if (Waypoint && Group) Group.RemoveWaypoint(Waypoint);
if (Waypoint) SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
Waypoint = null;
}
void Release(ERR_GameMode mode)
{
if (Released) return;
Released = true;
ClearOrder();
if (mode && mode.Forces())
{
ERR_ForceRecord record = mode.Forces().FindGroup(Group);
if (record) record.Pinned = false;
}
if (Group)
{
if (mode && mode.Command()) mode.Command().UnregisterGroup(Group);
Group.SetPermanentLOD(-1);
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
}
// Vehicle, cargo, casualties and surviving crew remain physical and lootable.
}
bool CrewValid(ERR_GameMode mode)
{
if (!Group || !Entity || !ERR_Actors.Alive(Entity) || Group.GetFaction() != mode.EnemyFaction() || !ERR_Actors.CanFight(Driver)) return false;
if (ERR_Actors.Player(Driver) || !ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(Driver), Entity)) return false;
foreach (IEntity member : Members)
if (!ERR_Actors.Alive(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group) return false;
return true;
}
bool Order(ERR_ForceRoute route, float now)
{
if (Released || Arrived || !Group || !Entity || !route || RouteIndex >= route.Count()) return false;
if (Waypoint) return true;
Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), route.At(RouteIndex)));
if (!Waypoint) return false;
Waypoint.SetCompletionRadius(35);
Group.AddWaypoint(Waypoint);
LegDeadline = now + 600000;
return true;
}
void Tick(ERR_GameMode mode, ERR_ForceRoute route, float now)
{
if (Released || Arrived) return;
if (!CrewValid(mode)) { Release(mode); return; }
// Native completion measures the seated group, not the vehicle origin.
// Keep the same predicate after native removal of the completed waypoint.
if (Waypoint && Waypoint.IsWithinCompletionRadius(Group))
{
ClearOrder(); RouteIndex++;
if (RouteIndex >= route.Count()) { Arrived = true; return; }
}
if (Waypoint && now >= LegDeadline) { Release(mode); return; }
if (!Order(route, now)) Release(mode);
}
}
class ERR_ConvoyCargoCallback : ScriptedInventoryOperationCallback
{
ResourceName Prefab;
bool Finished;
bool Succeeded;
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
protected override void OnComplete() { Finish(true); }
protected override void OnFailed() { Finish(false); }
protected override void OnAllComplete() { Finish(true); }
protected override void OnAllFailed() { Finish(false); }
}
class ERR_ConvoyTask
{
ref ERR_ConvoyReceipt Receipt;
ref ERR_ForceRoute Route;
ref array<ref ERR_ConvoyVehicle> Vehicles = {};
ref array<ref ERR_ConvoyCargoCallback> Cargo = {};
bool Stopped;
bool Ready;
bool CargoIssued;
float Deadline;
float NextCheck;
string Reason;
void Stop(ERR_GameMode mode, string reason)
{
if (Stopped) return;
Stopped = true; Reason = reason;
foreach (ERR_ConvoyVehicle vehicle : Vehicles) vehicle.Release(mode);
ERR_HQ.Event("CONVOY STOP", reason + "; surviving entities and cargo preserved");
}
}
class ERR_ConvoyService
{
protected ERR_GameMode m_Mode;
protected ERR_ConvoyPool m_Pool;
protected ref array<ref ERR_ConvoyTask> m_Tasks = {};
protected bool m_Stopped;
void ERR_ConvoyService(ERR_GameMode mode, ERR_ConvoyPool pool) { m_Mode = mode; m_Pool = pool; }
protected bool Current()
{
return !m_Stopped && Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Pool;
}
protected bool FillCrew(ERR_ConvoyVehicle unit)
{
array<BaseCompartmentSlot> all = {};
if (!ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false;
array<BaseCompartmentSlot> drivers = {}, turrets = {}, cargo = {};
foreach (BaseCompartmentSlot slot : all)
{
if (!slot.IsCompartmentAccessible() || slot.IsOccupied()) continue;
if (slot.GetType() == ECompartmentType.PILOT) drivers.Insert(slot);
else if (slot.GetType() == ECompartmentType.TURRET) turrets.Insert(slot);
else if (slot.GetType() == ECompartmentType.CARGO) cargo.Insert(slot);
}
int requiredTurrets = 1;
if (unit.Index == 1 || unit.Index == 2) requiredTurrets = 0;
if (unit.Index == 4) requiredTurrets = 2;
if (drivers.Count() != 1 || turrets.Count() < requiredTurrets || (unit.Index == 2 && cargo.Count() < 8)) return false;
unit.Seats.Insert(drivers[0]);
for (int i = 0; i < requiredTurrets; i++) unit.Seats.Insert(turrets[i]);
if (unit.Index == 2) for (int i = 0; i < 8; i++) unit.Seats.Insert(cargo[i]);
// Exact planned population is known before any character is created.
if (unit.Seats.Count() != ERR_ConvoyPool.CrewCount(unit.Index)) return false;
AIGroup nativeGroup = unit.Group;
foreach (BaseCompartmentSlot seat : unit.Seats)
{
IEntity actor = seat.SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab());
if (actor) { unit.Members.Insert(actor); m_Mode.Own(actor); }
if (!actor || nativeGroup != unit.Group) return false;
}
unit.Driver = unit.Members[0];
return true;
}
ERR_ConvoyTask Dispatch(ERR_ConvoyReceipt receipt, array<vector> positions, array<vector> angles, ERR_ForceRoute route, float now)
{
if (!Current() || !ERR_ForceRules.Finite(now) || now < 0 || !ERR_ConvoyRules.Plan(positions, angles, route) || !ERR_ConvoyResources.Available() || !ERR_ConvoyRules.GroundClear(positions, angles)) return null;
// The selected stock crew and group are USSR. Other factions require a new
// reviewed catalog; never silently rewrite affiliations on incompatible kit.
if (!m_Mode.EnemyFaction() || m_Mode.EnemyFaction().GetFactionKey() != "USSR" || !m_Pool.Consume(receipt)) return null;
ERR_ConvoyTask task = new ERR_ConvoyTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000;
m_Tasks.Insert(task);
for (int index = 0; index < 6; index++)
{
ERR_ConvoyVehicle unit = new ERR_ConvoyVehicle(); unit.Index = index; task.Vehicles.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), positions[index]));
if (unit.Group) m_Mode.Own(unit.Group);
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(index), positions[index], angles[index]));
if (unit.Entity) m_Mode.Own(unit.Entity);
if (!unit.Group || !unit.Entity || unit.Group.GetFaction() != m_Mode.EnemyFaction() || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{ task.Stop(m_Mode, "finite convoy startup failed; receipt consumed without retry"); return task; }
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(35);
}
ERR_HQ.Event("CONVOY CREATED", "six physical vehicles and 19 finite crew; native readiness and ledger admission pending");
return task;
}
protected string ReadinessReason(ERR_ConvoyVehicle unit)
{
if (unit.Registered) return "already admitted";
if (!unit.Group) return "missing group";
if (!unit.Entity) return "missing vehicle";
if (!ERR_Actors.Alive(unit.Entity)) return "vehicle destroyed or lacks damage state";
if (unit.Group.GetFaction() != m_Mode.EnemyFaction()) return "group faction mismatch";
if (!ERR_Actors.CanFight(unit.Driver)) return "driver absent/dead/unconscious or controller unavailable";
if (ERR_Actors.Player(unit.Driver)) return "driver controlled by player";
if (!ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(unit.Driver), unit.Entity)) return "driver outside intended vehicle";
foreach (IEntity member : unit.Members)
{
if (!ERR_Actors.Alive(member)) return "member absent/dead or damage state unavailable";
if (ERR_Actors.Player(member)) return "member controlled by player";
if (ERR_Actors.Group(member) != unit.Group) return "member native agent/parent group mismatch";
}
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return "native agent count differs from original crew";
foreach (AIAgent agent : agents)
{
if (!agent) return "null native agent";
if (!unit.Members.Contains(agent.GetControlledEntity())) return "native agent controls foreign entity";
if (!ERR_Actors.CanFight(agent.GetControlledEntity())) return "native agent actor cannot fight";
}
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
if (!utility) return "group utility unavailable";
if (!utility.m_VehicleMgr) return "group vehicle manager unavailable";
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!usage) return "vehicle usage component unavailable";
for (int i = 0; i < unit.Seats.Count(); i++)
if (i >= unit.Members.Count() || !unit.Seats[i] || unit.Seats[i].GetOccupant() != unit.Members[i]) return "intended seat occupant mismatch";
return "readiness guards satisfied; awaiting admission";
}
protected void InspectReadiness(ERR_ConvoyVehicle unit, float now)
{
if (!unit || now < unit.NextReadinessNote) return;
unit.NextReadinessNote = now + 5000;
int agents, activeAgents;
bool activeGroup;
if (unit.Group)
{
array<AIAgent> current = {}; unit.Group.GetAgents(current);
agents = current.Count(); activeGroup = unit.Group.IsAIActivated();
foreach (AIAgent agent : current) if (agent && agent.IsAIActivated()) activeAgents++;
}
ERR_HQ.Event("CONVOY READINESS", string.Format("vehicle=%1 reason=%2 members=%3 agents=%4 activeAgents=%5 activeGroup=%6 seats=%7 registered=%8", unit.Index, ReadinessReason(unit), unit.Members.Count(), agents, activeAgents, activeGroup, unit.Seats.Count(), unit.Registered));
}
protected bool Admit(ERR_ConvoyTask task, float now)
{
foreach (ERR_ConvoyVehicle observed : task.Vehicles) InspectReadiness(observed, now);
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
{
if (unit.Registered) continue;
if (!unit.CrewValid(m_Mode)) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return false;
foreach (AIAgent agent : agents)
if (!agent || !unit.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage)) return false;
ERR_ForceRecord record = m_Mode.Forces().Register(task.Receipt.SourceId + "-vehicle-" + unit.Index.ToString(), ERR_ForceKind.CONVOY, unit.Entity.GetOrigin(), unit.Members.Count(), null, unit.Group, now);
if (!record || !m_Pool.BindRecord(task.Receipt, unit.Index, record)) { task.Stop(m_Mode, "convoy ledger admission rejected"); return false; }
record.Pinned = true;
unit.Registered = true;
m_Mode.RegisterGroup(unit.Group);
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
if (hq) hq.ReserveGroup(unit.Group);
unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); }
}
return true;
}
protected void IssueCargo(ERR_ConvoyTask task)
{
if (task.CargoIssued || task.Stopped || task.Vehicles.Count() != 6) return;
task.CargoIssued = true;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(task.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent));
if (!inventory) { ERR_HQ.Event("CONVOY CARGO", "ammo truck inventory unavailable; no retry or free replacement"); return; }
for (int i = 0; i < 56; i++)
{
ResourceName prefab = ERR_ConvoyResources.Magazine();
if (i < 8) prefab = ERR_ConvoyResources.Rifle();
ERR_ConvoyCargoCallback operation = new ERR_ConvoyCargoCallback(); operation.Prefab = prefab; task.Cargo.Insert(operation);
if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, operation)) operation.Finished = true;
}
ERR_HQ.Event("CONVOY CARGO", "finite target submitted once: eight AK74 and 48 magazines; native capacity/completion may reduce actual stock");
}
void Tick(float now)
{
if (!Current() || !ERR_ForceRules.Finite(now)) return;
foreach (ERR_ConvoyTask task : m_Tasks)
{
if (task.Stopped || now < task.NextCheck) continue;
task.NextCheck = now + 1000;
bool captured;
foreach (ERR_ConvoyVehicle unit : task.Vehicles) if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) captured = true;
if (captured) { task.Stop(m_Mode, "player captured convoy vehicle or joined crew"); continue; }
if (!task.Ready)
{
if (now >= task.Deadline) { task.Stop(m_Mode, "native crew readiness expired; no replacement"); continue; }
if (!Admit(task, now)) continue;
task.Ready = true; IssueCargo(task);
}
bool pending;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
vehicle.Tick(m_Mode, task.Route, now);
if (!vehicle.Released && !vehicle.Arrived) pending = true;
}
if (!pending) task.Stop(m_Mode, "finite authored road route ended or all vehicle orders released");
}
}
int Count() { return m_Tasks.Count(); }
string Status()
{
int ready, stopped, vehicles, completed, failed, pending;
foreach (ERR_ConvoyTask task : m_Tasks)
{
if (task.Ready) ready++;
if (task.Stopped) stopped++;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles) if (vehicle && vehicle.Entity) vehicles++;
foreach (ERR_ConvoyCargoCallback operation : task.Cargo)
{
if (!operation.Finished) pending++;
else if (operation.Succeeded) completed++;
else failed++;
}
}
string reason;
if (m_Tasks.Count() == 1 && m_Tasks[0].Stopped) reason = m_Tasks[0].Reason;
if (reason.Length() > 160) reason = reason.Substring(0, 160);
return string.Format("Convoys: %1 tasks, %2 reached readiness, %3 stopped, %4 retained physical vehicles; cargo native insertion successes=%5 failed=%6 pending=%7; current inventory may differ after use/looting", m_Tasks.Count(), ready, stopped, vehicles, completed, failed, pending) + "; reason=" + reason;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
foreach (ERR_ConvoyTask task : m_Tasks) task.Stop(m_Mode, "HQ ended convoy task service");
}
}
@@ -0,0 +1,205 @@
// Owned AI startup-equipment adaptation; source hashes in docs/ai-equipment-migration.md.
// HQ supplies the physical groups. No independent spawn/order timers exist here.
class ERR_EnemyEquipmentRules
{
static bool Eligible(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !mode || mode != ERR_GameMode.Get() || !mode.Active()) return false;
if (!group || !actor || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group) return false;
SCR_AIGroupUtilityComponent utility = group.GetGroupUtilityComponent();
return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && group.GetFaction() == mode.EnemyFaction();
}
static EAISkill Floor(EAISkill skill)
{
if (skill < EAISkill.EXPERT) return EAISkill.EXPERT;
return skill;
}
}
// The binding is explicit and mission-local; initialization of ordinary AI is untouched.
modded class SCR_AICombatComponent
{
protected ERR_GameMode m_ERRSkillMode;
protected SCR_AIGroup m_ERRSkillGroup;
protected EAISkill m_ERRPreviousSkill;
void ERR_BindEnemySkill(ERR_GameMode mode, SCR_AIGroup group)
{
if (!ERR_EnemyEquipmentRules.Eligible(mode, group, GetOwner())) return;
if (!m_ERRSkillMode) m_ERRPreviousSkill = GetAISkill();
m_ERRSkillMode = mode; m_ERRSkillGroup = group;
SetAISkill(GetAISkill());
}
void ERR_ReleaseEnemySkill(ERR_GameMode mode)
{
if (!m_ERRSkillMode || m_ERRSkillMode != mode) return;
m_ERRSkillMode = null; m_ERRSkillGroup = null;
super.SetAISkill(m_ERRPreviousSkill);
}
override void SetAISkill(EAISkill skill)
{
if (m_ERRSkillMode && ERR_EnemyEquipmentRules.Eligible(m_ERRSkillMode, m_ERRSkillGroup, GetOwner())) skill = ERR_EnemyEquipmentRules.Floor(skill);
super.SetAISkill(skill);
}
override void ResetAISkill()
{
super.ResetAISkill();
if (m_ERRSkillMode) SetAISkill(GetAISkill());
}
}
class ERR_EnemyEquipmentCallback : ScriptedInventoryOperationCallback
{
bool Finished;
bool Succeeded;
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
protected override void OnComplete() { Finish(true); }
protected override void OnFailed() { Finish(false); }
protected override void OnAllComplete() { Finish(true); }
protected override void OnAllFailed() { Finish(false); }
}
class ERR_EnemyEquipmentEntry
{
static const ResourceName LAUNCHER = "{3DF204EEE0534EF2}Prefabs/Weapons/Launchers/FIM92_Stinger/Launcher_FIM92_Stinger.et";
static const int MAX_CHECKS = 5;
IEntity Actor;
SCR_AIGroup Group;
SCR_AICombatComponent Combat;
int Checks;
int SkillChecks;
bool Rolled;
bool Selected;
bool Submitted;
bool Complete;
bool Released;
ref ERR_EnemyEquipmentCallback Operation;
protected bool Finish(string result)
{
Complete = true;
if (Selected) ERR_HQ.Event("STINGER", result);
return true;
}
bool Check(ERR_GameMode mode)
{
if (Complete) return true;
if (!ERR_EnemyEquipmentRules.Eligible(mode, Group, Actor)) return Finish("startup equipment cancelled: actor no longer eligible");
Checks++;
if (!Rolled)
{
int oneIn = mode.Settings().Get("stingerChanceOneIn");
Rolled = true;
if (oneIn > 0) Selected = Math.RandomInt(0, oneIn) == 0;
}
if (!Selected) return Finish("not selected");
BaseWeaponManagerComponent weapons = BaseWeaponManagerComponent.Cast(Actor.FindComponent(BaseWeaponManagerComponent));
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(Actor.FindComponent(InventoryStorageManagerComponent));
if (!weapons || !inventory)
{
if (Checks >= MAX_CHECKS) return Finish("inventory unavailable after bounded startup checks");
return false;
}
array<IEntity> equipped = {};
weapons.GetWeaponsList(equipped);
foreach (IEntity item : equipped)
{
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == LAUNCHER)
return Finish("physical FIM-92 observed in weapon slot; no replacement will be issued");
}
// A successful callback may precede a changed slot; dropping/looting that item
// before observation must not cause a second grant. Failure permits a bounded retry.
if (Submitted && Operation && Operation.Finished && Operation.Succeeded) return Finish("native insertion completed; equipment will not be replenished");
if (Checks >= MAX_CHECKS) return Finish("startup deadline reached; unresolved insertion quarantined for this actor");
if (Submitted && Operation && !Operation.Finished) return false;
Operation = new ERR_EnemyEquipmentCallback();
Submitted = true;
if (!inventory.TrySpawnPrefabToStorage(LAUNCHER, null, -1, EStoragePurpose.PURPOSE_WEAPON_PROXY, Operation)) Submitted = false;
return false;
}
void Stop(ERR_GameMode mode)
{
Complete = true;
Released = true;
if (Combat) Combat.ERR_ReleaseEnemySkill(mode);
Combat = null;
// Do not interpret Stop as cancellation of an accepted native insertion.
// The service is terminal and retains this exact actor's completed entry.
}
}
class ERR_EnemyEquipmentGroup
{
SCR_AIGroup Group;
int Scans;
float ReadyAt;
}
class ERR_EnemyEquipment
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_EnemyEquipmentGroup> m_Groups = {};
protected ref array<ref ERR_EnemyEquipmentEntry> m_Entries = {};
protected float m_NextCheck;
protected bool m_Stopped;
void ERR_EnemyEquipment(ERR_GameMode mode) { m_Mode = mode; }
void RegisterGroup(SCR_AIGroup group)
{
if (m_Stopped || !Replication.IsServer() || !group || m_Groups.Count() >= 128) return;
foreach (ERR_EnemyEquipmentGroup existing : m_Groups) if (existing.Group == group) return;
ERR_EnemyEquipmentGroup state = new ERR_EnemyEquipmentGroup(); state.Group = group;
state.ReadyAt = GetGame().GetWorld().GetWorldTime() + 2000;
m_Groups.Insert(state);
}
void UnregisterGroup(SCR_AIGroup group)
{
foreach (ERR_EnemyEquipmentGroup state : m_Groups) if (state.Group == group) state.Scans = 5;
foreach (ERR_EnemyEquipmentEntry entry : m_Entries) if (entry.Group == group) entry.Stop(m_Mode);
}
protected bool Known(IEntity actor)
{
foreach (ERR_EnemyEquipmentEntry entry : m_Entries) if (entry.Actor == actor) return true;
return false;
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || now < m_NextCheck) return;
m_NextCheck = now + 3000;
foreach (ERR_EnemyEquipmentGroup state : m_Groups)
{
if (state.Scans >= 5 || now < state.ReadyAt) continue;
state.Scans++;
if (!state.Group) continue;
array<AIAgent> agents = {}; state.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
if (!agent || m_Entries.Count() >= 2048) continue;
IEntity actor = agent.GetControlledEntity();
if (Known(actor) || !ERR_EnemyEquipmentRules.Eligible(m_Mode, state.Group, actor)) continue;
ERR_EnemyEquipmentEntry entry = new ERR_EnemyEquipmentEntry(); entry.Actor = actor; entry.Group = state.Group;
entry.Combat = SCR_AICombatComponent.Cast(actor.FindComponent(SCR_AICombatComponent));
if (entry.Combat) entry.Combat.ERR_BindEnemySkill(m_Mode, state.Group);
m_Entries.Insert(entry);
}
}
foreach (ERR_EnemyEquipmentEntry entry : m_Entries)
{
if (entry.Released) continue;
if (!ERR_EnemyEquipmentRules.Eligible(m_Mode, entry.Group, entry.Actor)) { entry.Stop(m_Mode); continue; }
// Combat may arrive after the group's initial membership notification.
if (!entry.Combat && entry.SkillChecks < 5)
{
entry.SkillChecks++;
entry.Combat = SCR_AICombatComponent.Cast(entry.Actor.FindComponent(SCR_AICombatComponent));
if (entry.Combat) entry.Combat.ERR_BindEnemySkill(m_Mode, entry.Group);
}
entry.Check(m_Mode);
}
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
foreach (ERR_EnemyEquipmentEntry entry : m_Entries) entry.Stop(m_Mode);
m_Groups.Clear();
}
}
+613 -79
View File
@@ -12,6 +12,19 @@ class ERR_HQUnit
ref ERR_HQReport PendingOrder;
ref ERR_HQReport Assignment;
AIWaypoint Waypoint;
ref ERR_HQTask Task;
ref ERR_HQTask PendingTask;
ref ERR_HQSearchReport PendingSweep;
ref ERR_HQStatusReport PendingStatus;
ref ERR_HQStatusSampler StatusSampler = new ERR_HQStatusSampler();
string ForceSourceId;
float NextStatus;
bool PendingCheckin;
bool HomeGuard;
string HomeId;
bool PlanEligible;
bool PlanFighting;
vector PlanPosition;
bool ReservedForSupport;
int LogisticsReservation;
ref ERR_MortarSupplyStatus PendingSupplyStatus;
@@ -31,34 +44,70 @@ class ERR_HQUnit
return survivor;
}
void Release()
{
if (Waypoint)
{
if (Group) Group.RemoveWaypoint(Waypoint);
SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
}
Waypoint = null;
Assignment = null;
PendingOrder = null;
Downlink = null;
}
void CancelPending()
{
PendingOrder = null;
PendingCheckin = false;
PendingTask = null;
Downlink = null;
}
ERR_HQTask PlannedTask()
{
if (PendingTask) return PendingTask;
return Task;
}
void ReleaseAssignment()
{
ERR_GameMode mode = ERR_GameMode.Get();
ERR_ForceRecord record;
if (mode && Group) record = mode.Forces().FindGroup(Group);
if (record && !record.Eliminated)
{
record.Duty = record.HomeDuty;
record.DutyTarget = record.HomePosition;
}
if (Waypoint)
{
if (Group) Group.RemoveWaypoint(Waypoint);
SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
}
Waypoint = null;
Assignment = null;
Task = null;
}
void Release() { ReleaseAssignment(); CancelPending(); }
}
class ERR_HQ : ERR_CommandService
{
protected ERR_GameMode m_Mode;
protected ref ERR_MortarSystem m_Mortars;
protected ref ERR_EnemyEquipment m_Equipment;
protected ref ERR_ConvoyPool m_ConvoyPool;
protected ref ERR_ConvoyService m_Convoys;
protected ref ERR_ConvoyTask m_ConvoyTask;
protected ref array<vector> m_ConvoyPositions = {};
protected ref array<vector> m_ConvoyAngles = {};
protected ref ERR_ForceRoute m_ConvoyRoute;
protected string m_ConvoyFailure;
protected ref ERR_HQBodyObserver m_Bodies = new ERR_HQBodyObserver();
protected ref ERR_HQWatch m_Watch = new ERR_HQWatch();
protected ref ERR_MortarLogistics m_Logistics;
protected ref array<ref ERR_HQUnit> m_Units = {};
protected ref array<ref ERR_HQReport> m_Contacts = {};
protected ref ERR_HQMemory m_Memory = new ERR_HQMemory();
protected ref ERR_HQCoverage m_Coverage = new ERR_HQCoverage();
protected ref array<ref ERR_HQHuntWindow> m_Hunts = {};
protected ref array<ref ERR_HQBelief> m_PlanBeliefs = {};
protected ref map<string, int> m_HomeAvailable = new map<string, int>();
protected ref ERR_HQNetwork m_Network = new ERR_HQNetwork();
protected ref ERR_HQJournal m_Journal = new ERR_HQJournal();
protected int m_NextId;
protected float m_NextNetwork;
protected float m_NextAssignment;
void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); }
void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Equipment = new ERR_EnemyEquipment(mode); m_ConvoyPool = new ERR_ConvoyPool(mode, 1); m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool); m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); }
static ERR_HQ Get()
{
ERR_GameMode mode = ERR_GameMode.Get();
@@ -75,6 +124,63 @@ class ERR_HQ : ERR_CommandService
ERR_HQ hq = Get();
if (hq) hq.Journal().Write(kind, message, messageId);
}
ERR_ConvoyPool ConvoyPool() { return m_ConvoyPool; }
ERR_ConvoyService Convoys() { return m_Convoys; }
ERR_ConvoyTask ConvoyTask() { return m_ConvoyTask; }
bool PrepareConvoy(vector seed)
{
if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask) return false;
m_ConvoyPositions.Clear(); m_ConvoyAngles.Clear(); m_ConvoyRoute = null;
if (!ERR_ConvoyRoadPlan.Build(seed, m_ConvoyPositions, m_ConvoyAngles, m_ConvoyRoute))
{
m_ConvoyPositions.Clear(); m_ConvoyAngles.Clear(); m_ConvoyRoute = null;
m_ConvoyFailure = "No sufficiently long, wide and clear road near the deployment site.";
return false;
}
m_ConvoyFailure = "";
return true;
}
bool StartConvoy()
{
if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask || !m_ConvoyRoute) return false;
// Every existing formation must be admitted before the additional 19 bodies
// are budgeted. In particular, asynchronous mortar startup cannot be skipped.
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group && !m_Mode.Forces().FindGroup(unit.Group))
{
m_ConvoyFailure = "Existing force admission is still pending; convoy deployment withheld.";
return false;
}
}
ERR_ConvoyReceipt receipt = m_ConvoyPool.Reserve("everon-supply-convoy-1");
if (!receipt || receipt.Consumed)
{
m_ConvoyFailure = "The finite convoy allowance or physical force budget is unavailable.";
return false;
}
m_ConvoyTask = m_Convoys.Dispatch(receipt, m_ConvoyPositions, m_ConvoyAngles, m_ConvoyRoute, GetGame().GetWorld().GetWorldTime());
if (!m_ConvoyTask)
{
m_ConvoyFailure = "Deployment preflight failed; no convoy created.";
return false;
}
m_ConvoyFailure = "";
return !m_ConvoyTask.Stopped;
}
bool StartScenarioConvoy()
{
if (!m_Mode || !m_Mode.Sites()) return false;
ERR_SiteRuntime site = m_Mode.Sites().Find("everon-supply-1");
if (!site || !site.Definition) return false;
return PrepareConvoy(site.Definition.Position) && StartConvoy();
}
string ConvoyStatus()
{
string detail = m_Convoys.Status();
if (!m_ConvoyFailure.IsEmpty()) detail += "; " + m_ConvoyFailure;
return detail;
}
ERR_MortarSystem Mortars() { return m_Mortars; }
bool PrepareSupport() { return m_Mortars.Prepare() && m_Logistics.Prepare(); }
ERR_MortarLogistics Logistics() { return m_Logistics; }
@@ -83,7 +189,7 @@ class ERR_HQ : ERR_CommandService
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
unit.Release();
ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.ReservedForSupport = true;
}
}
@@ -175,39 +281,75 @@ class ERR_HQ : ERR_CommandService
if (ERR_MortarPrediction.Fresh(contact, now)) contacts.Insert(contact.Snapshot());
}
ERR_HQMemory Memory() { return m_Memory; }
ERR_HQCoverage Coverage() { return m_Coverage; }
ERR_HQJournal Journal() { return m_Journal; }
string Status()
{
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + "; " + m_Mortars.Status() + "; " + m_Logistics.Status();
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells", m_Memory.Count(), m_Coverage.Count()) + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status();
}
protected void BindPhysical(ERR_HQUnit unit)
{
if (!unit || !unit.Group) return;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
if (!record || !record.Physical || record.Eliminated) return;
unit.ForceSourceId = record.SourceId;
if (!unit.StatusSampler.Bound()) unit.StatusSampler.Bind(unit.Group, record.SourceId, record.InitialStrength, m_Mode.EnemyFaction());
m_Bodies.RegisterGroup(unit.Group);
}
override void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !group || m_Units.Count() >= 128) return;
foreach (ERR_HQUnit known : m_Units) if (known.Group == group) return;
if (!Replication.IsServer() || !group) return;
foreach (ERR_HQUnit known : m_Units) if (known.Group == group) { BindPhysical(known); return; }
if (m_Units.Count() >= 128) return;
SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent));
if (!utility || group.GetFaction() != m_Mode.EnemyFaction()) return;
ERR_HQUnit unit = new ERR_HQUnit();
unit.Id = ++m_NextId;
unit.Group = group;
unit.Utility = utility;
ERR_SiteService sites = m_Mode.Sites();
ERR_SiteRuntime home;
if (sites) home = sites.FindGroup(group);
if (home)
{
unit.HomeId = home.Definition.Id;
unit.HomeGuard = !home.Definition.Patrol || home.Definition.Patrol.IsEmpty();
}
int phase = unit.Id % 5;
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
m_Units.Insert(unit);
m_Equipment.RegisterGroup(group);
BindPhysical(unit);
unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000;
m_Journal.Write("REGISTER", string.Format("group %1", unit.Id));
}
override void UnregisterGroup(SCR_AIGroup group)
{
m_Equipment.UnregisterGroup(group);
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
unit.Release();
ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.Group = null;
unit.Utility = null;
}
}
protected int HuntingAssigned()
{
int count;
foreach (ERR_HQUnit unit : m_Units)
{
if (!unit.Assignment && !unit.PendingOrder) continue;
ERR_HQTask planned = unit.PlannedTask();
if (planned && planned.ReleaseOnly) planned = planned.ReplacesTask;
if (planned && !planned.Investigation()) count++;
}
return count;
}
protected int Assigned()
{
int total;
@@ -264,9 +406,147 @@ class ERR_HQ : ERR_CommandService
m_Journal.Write("REPORT", route.Payload, report.MessageId);
}
protected void ObserveBody(ERR_HQUnit unit, float now)
{
if (unit.Uplink || !unit.Radio || !unit.Group) return;
ERR_HQReport report;
if (!m_Bodies.Observe(unit.Group, now, report)) return;
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!route) return;
report.MessageId = m_Journal.Message(); report.Source = unit.Group;
route.MessageId = report.MessageId;
route.Payload = "visible casualty evidence; cause unknown; " + report.Description(now);
unit.PendingReport = report.Snapshot(); unit.Uplink = route;
m_Journal.Write("BODY REPORT", route.Payload, route.MessageId);
}
protected void StatusReport(ERR_HQUnit unit, float now)
{
unit.StatusSampler.ObserveDeaths();
if (unit.Uplink || !unit.Radio || now < unit.NextStatus) return;
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!route) return;
ERR_HQStatusReport report = unit.StatusSampler.Sample(now, m_Journal.Message(), unit.Radio);
if (!report) return;
route.MessageId = report.MessageId;
route.Payload = string.Format("status %1: %2 living, %3 fit, %4 confirmed losses; sampled=%5", report.SourceId, report.Count, report.Fit, report.ConfirmedLosses, report.ObservedAt);
unit.PendingStatus = report; unit.Uplink = route; unit.NextStatus = now + 180000;
m_Journal.Write("STATUS REPORT", route.Payload, route.MessageId);
}
// Due routine status and requested check-ins get the next free uplink.
// Positive observations precede a negative search report.
protected void QueueReports(ERR_HQUnit unit, float now)
{
StatusReport(unit, now);
Observe(unit, now);
ObserveBody(unit, now);
Sweep(unit, now);
}
protected bool WatchTaskValid(ERR_HQTask task)
{
if (!task || !task.Investigation()) return false;
ERR_HQWatchEntry entry = m_Watch.Find(task.WatchSourceId);
return entry && !entry.Closed && entry.Investigating && entry.MessageId == task.WatchMessageId && entry.RequestId == task.WatchRequestId;
}
protected void FinishWatch(ERR_HQTask task, float now)
{
if (task && task.Investigation()) m_Watch.FinishInvestigation(task.WatchSourceId, task.WatchMessageId, task.WatchRequestId, now);
}
protected void ReleaseUnit(ERR_HQUnit unit, float now)
{
FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now);
unit.Release();
}
protected void CancelResolvedInvestigation(ERR_HQUnit unit, float now)
{
if (unit.PendingTask && unit.PendingTask.Investigation() && !WatchTaskValid(unit.PendingTask)) LostOrder(unit, now);
if (!unit.Task || !unit.Task.Investigation() || WatchTaskValid(unit.Task) || unit.Downlink || !unit.Radio) return;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) return;
ERR_HQTask cancel = new ERR_HQTask();
cancel.ReleaseOnly = true; cancel.ReplacesTask = unit.Task; cancel.QueuedAt = now; cancel.ExpiresAt = now + 30000;
cancel.ApproachBearing = unit.Task.ApproachBearing; cancel.Destination = unit.Task.Destination;
unit.PendingTask = cancel; unit.PendingOrder = unit.Assignment.Snapshot(); unit.Downlink = route;
route.MessageId = m_Journal.Message(); route.Payload = "cancel resolved missing-patrol investigation; group " + unit.Id.ToString();
m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId);
}
protected void RequestCheckins(float now)
{
for (int i = 0; i < m_Watch.Count(); i++)
{
ERR_HQWatchEntry entry = m_Watch.At(i);
if (!m_Watch.MarkRequested(entry.SourceId, entry.MessageId, now)) continue;
m_Journal.Write("CHECK-IN OVERDUE", string.Format("%1 missed two check-ins; last reported position=%2, status M%3; cause unknown", entry.SourceId, entry.Position, entry.MessageId));
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.ForceSourceId != entry.SourceId || unit.Downlink || !unit.Radio) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) break;
route.MessageId = m_Journal.Message();
route.Payload = "HQ requests current status from " + entry.SourceId;
unit.PendingCheckin = true; unit.Downlink = route;
m_Journal.Write("STATUS REQUEST", route.Payload, route.MessageId);
break;
}
}
}
protected void Investigate(float now, int limit)
{
if (Assigned() >= limit) return;
// One missing-patrol investigation at a time shares the same finite units.
foreach (ERR_HQUnit unit : m_Units)
if (unit.PlannedTask() && unit.PlannedTask().Investigation()) return;
for (int i = 0; i < m_Watch.Count(); i++)
{
ERR_HQWatchEntry entry = m_Watch.At(i);
if (!m_Watch.InvestigationDue(entry.SourceId, now)) continue;
ERR_HQUnit best;
ERR_HQRoute bestRoute;
float distance = m_Mode.Settings().Get("responseRadiusMetres") + 1;
int first = -1;
int second = -1;
foreach (ERR_HQUnit existing : m_Units)
{
ERR_HQTask planned = existing.PlannedTask();
if (!planned) continue;
if (first < 0) first = planned.ApproachBearing;
else if (planned.ApproachBearing != first) second = planned.ApproachBearing;
}
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.ForceSourceId == entry.SourceId || !unit.Radio || !unit.PlanEligible || unit.PlanFighting || unit.Assignment || unit.PendingOrder || unit.Downlink) continue;
if (unit.HomeGuard && HomeAvailable(unit) <= 1) continue;
if (!ERR_HQPursuitRules.BearingAllowed(ERR_HQPursuitRules.Bearing(unit.PlanPosition, entry.Position), first, second)) continue;
float candidate = vector.DistanceXZ(unit.PlanPosition, entry.Position);
if (candidate >= distance) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) continue;
best = unit; bestRoute = route; distance = candidate;
}
if (!best || !m_Watch.MarkInvestigating(entry.SourceId, entry.MessageId, entry.RequestId, now)) continue;
ERR_HQTask task = new ERR_HQTask();
task.WatchSourceId = entry.SourceId; task.WatchMessageId = entry.MessageId; task.WatchRequestId = entry.RequestId;
task.Role = ERR_HQPursuitRole.SEARCH; task.Destination = entry.Position; task.QueuedAt = now;
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(task.Role, distance) * 1000;
task.ApproachBearing = ERR_HQPursuitRules.Bearing(best.PlanPosition, entry.Position);
ERR_HQReport lineage = new ERR_HQReport();
lineage.MessageId = entry.MessageId; lineage.ObservedAt = entry.ObservedAt; lineage.Position = entry.Position; lineage.Distress = true;
best.PendingTask = task; best.PendingOrder = lineage; best.Downlink = bestRoute;
bestRoute.MessageId = m_Journal.Message();
bestRoute.Payload = string.Format("group %1 investigate %2 at last delivered status position %3; source M%4", best.Id, entry.SourceId, entry.Position, entry.MessageId);
m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId);
if (best.HomeGuard) m_HomeAvailable.Set(best.HomeId, HomeAvailable(best) - 1);
return;
}
}
protected void Accept(ERR_HQReport report, float now)
{
if (!report || !ERR_MissionRules.ContactFresh(report.ObservedAt, now)) return;
if (!m_Memory.Ingest(report, now)) return;
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position);
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY) m_Bodies.ConfirmDelivered(report.Target);
m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId);
// A reported corpse or distress call can justify investigation, never fire.
if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return;
for (int i = 0; i < m_Contacts.Count(); i++)
{
ERR_HQReport previous = m_Contacts[i];
@@ -285,65 +565,309 @@ class ERR_HQ : ERR_CommandService
m_Contacts.Insert(report.Snapshot());
}
protected void DeliverOrder(ERR_HQUnit unit, float now)
{
ERR_HQReport order = unit.PendingOrder;
if (!order || !ERR_MissionRules.ContactFresh(order.ObservedAt, now) || !unit.Eligible(m_Mode)) { unit.Release(); return; }
SCR_AIGroupPerception perception = unit.Utility.m_Perception;
if (perception && !perception.m_aTargets.IsEmpty()) { unit.Release(); return; }
int deliveryId;
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
unit.Release();
vector position = order.Position;
if (order.HasVelocity) position = ERR_HQReport.Predict(position, order.Velocity, (now - order.ObservedAt) / 1000);
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(position)));
if (!waypoint) { m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
unit.Waypoint = waypoint;
unit.Assignment = order;
unit.Group.AddWaypointAt(waypoint, 0);
m_Journal.Write("ORDER DELIVERED", string.Format("group %1 from report M%2: %3", unit.Id, order.MessageId, order.Description(now)), deliveryId);
}
protected void Assign(float now)
{
int limit = m_Mode.Settings().Get("supportSquadLimit");
int radius = m_Mode.Settings().Get("responseRadiusMetres");
int assigned = Assigned();
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Assignment && (!ERR_MissionRules.ContactFresh(unit.Assignment.ObservedAt, now) || !unit.Eligible(m_Mode) || assigned > limit))
{
unit.Release(); assigned--;
}
if (unit.PendingOrder && (!ERR_MissionRules.ContactFresh(unit.PendingOrder.ObservedAt, now) || !unit.Eligible(m_Mode) || assigned > limit))
{
unit.Release(); assigned--;
}
}
foreach (ERR_HQReport report : m_Contacts)
{
if (!ERR_MissionRules.ContactFresh(report.ObservedAt, now)) continue;
foreach (ERR_HQUnit unit : m_Units)
{
if (assigned >= limit) return;
if (unit.Assignment || unit.PendingOrder || !unit.Eligible(m_Mode) || !unit.Radio) continue;
if (vector.DistanceXZ(unit.Group.GetOrigin(), report.Position) > radius) continue;
SCR_AIGroupPerception perception = unit.Utility.m_Perception;
if (perception && !perception.m_aTargets.IsEmpty()) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) continue;
unit.PendingOrder = report.Snapshot();
unit.Downlink = route;
route.MessageId = m_Journal.Message();
route.Payload = "order from report M" + report.MessageId.ToString();
assigned++;
}
}
}
// Current combat suspends a sweep without deleting its native queued orders.
protected bool Fighting(ERR_HQUnit unit)
{
return unit.Utility && unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty();
}
protected int HomeAvailable(ERR_HQUnit candidate) { return m_HomeAvailable.Get(candidate.HomeId); }
protected ERR_HQHuntWindow Hunt(int beliefId)
{
foreach (ERR_HQHuntWindow known : m_Hunts) if (known.BeliefId == beliefId) return known;
if (m_Hunts.Count() >= 64) return null;
ERR_HQHuntWindow created = new ERR_HQHuntWindow();
created.BeliefId = beliefId;
m_Hunts.Insert(created);
return created;
}
protected void LostOrder(ERR_HQUnit unit, float now)
{
ERR_HQTask task = unit.PendingTask;
unit.CancelPending();
if (!task || task.ReleaseOnly) return;
if (task.Investigation()) { FinishWatch(task, now); return; }
foreach (ERR_HQUnit other : m_Units)
{
ERR_HQTask planned = other.PlannedTask();
if (planned && planned.BeliefId == task.BeliefId) return;
}
// A call that never arrived did not begin a hunt. Retry through the network
// after a short backoff, without bypassing another unit's active hunt window.
ERR_HQHuntWindow hunt = Hunt(task.BeliefId);
if (hunt) hunt.NextHunt = Math.Min(hunt.NextHunt, now + 15000);
}
protected void DeliverOrder(ERR_HQUnit unit, float now)
{
ERR_HQReport order = unit.PendingOrder;
ERR_HQTask task = unit.PendingTask;
if (!order || !task || now >= task.ExpiresAt || !unit.Eligible(m_Mode)) { LostOrder(unit, now); return; }
if (task.ReleaseOnly)
{
if (unit.Task == task.ReplacesTask) { ReleaseUnit(unit, now); m_Journal.Write("ORDER DELIVERED", "resolved investigation released through radio"); }
else unit.CancelPending();
return;
}
if (task.Investigation())
{
if (!WatchTaskValid(task)) { LostOrder(unit, now); return; }
}
else
{
ERR_HQBelief belief = m_Memory.GetById(task.BeliefId);
if (!belief || belief.BeliefRevision != task.BeliefRevision
|| !ERR_HQPursuitRules.Actionable(belief.Confidence, (now - belief.ObservedAt) / 1000, belief.Radius))
{ LostOrder(unit, now); return; }
}
if (Fighting(unit)) { LostOrder(unit, now); return; }
int deliveryId;
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(task.Destination)));
if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
unit.Release();
unit.Task = task;
unit.Waypoint = waypoint;
unit.Assignment = order;
unit.Group.AddWaypointAt(waypoint, 0);
ERR_ForceRecord record = m_Mode.Forces().FindGroup(unit.Group);
if (record)
{
record.Duty = ERR_ForceDuty.SEARCH;
record.DutyTarget = task.Destination;
}
m_Journal.Write("ORDER DELIVERED", string.Format("group %1 %2 from report M%3; belief=%4 destination=%5", unit.Id, task.Name(), order.MessageId, task.BeliefId, task.Destination), deliveryId);
}
protected void Sweep(ERR_HQUnit unit, float now)
{
ERR_HQTask task = unit.Task;
if (!task || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return;
if (Fighting(unit) || vector.DistanceXZ(unit.Group.GetOrigin(), task.Destination) > 40)
{ task.ArrivedAt = -1; return; }
// Quiet combat state alone does not establish a negative search. Use this
// physical group's native perception, also while its uplink is occupied.
array<ref ERR_HQVisualContact> samples = {};
ERR_HQObservation.Gather(unit.Utility, now, samples);
if (!samples.IsEmpty()) { task.ArrivedAt = -1; return; }
if (task.ArrivedAt < 0) task.ArrivedAt = now;
if (now - task.ArrivedAt < 30000 || unit.Uplink || !unit.Radio) return;
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!route) return;
ERR_HQSearchReport report = new ERR_HQSearchReport();
report.SourceId = unit.Id;
report.MessageId = m_Journal.Message();
report.WatchSourceId = task.WatchSourceId; report.WatchMessageId = task.WatchMessageId; report.WatchRequestId = task.WatchRequestId;
report.BeliefId = task.BeliefId;
report.BeliefRevision = task.BeliefRevision;
report.Position = task.Destination;
report.ObservedAt = now;
report.SweepCompleted = true;
report.ContactFound = false;
route.MessageId = report.MessageId;
route.Payload = string.Format("group %1 searched belief %2 at %3; no contact", unit.Id, task.BeliefId, task.Destination);
unit.PendingSweep = report;
unit.Uplink = route;
task.SweepQueued = true;
m_Journal.Write("SEARCH REPORT", route.Payload, report.MessageId);
}
protected void AcceptSweep(ERR_HQSearchReport report, float now)
{
if (!report) return;
if (!report.WatchSourceId.IsEmpty())
{
if (!ERR_HQMemoryRules.DeliveryFresh(report.ObservedAt, now) || !report.SweepCompleted || report.ContactFound) return;
if (m_Watch.FinishInvestigation(report.WatchSourceId, report.WatchMessageId, report.WatchRequestId, now))
m_Journal.Write("INVESTIGATION", "physical search reported no contact; missing patrol remains unconfirmed: " + report.WatchSourceId, report.MessageId);
return;
}
ERR_HQBelief belief = m_Memory.GetById(report.BeliefId);
if (!belief || belief.BeliefRevision != report.BeliefRevision) return;
if (!m_Coverage.Ingest(report, now)) return;
m_Memory.NegativeSearch(report.BeliefId, report.BeliefRevision, now);
m_Journal.Write("AREA SWEPT", string.Format("group %1 reported no contact at %2; belief=%3", report.SourceId, report.Position, report.BeliefId), report.MessageId);
}
protected vector Containment(ERR_HQBelief belief, vector fallback)
{
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
float best = -1;
vector result = fallback;
foreach (ERR_LocationComponent location : locations)
{
if (!location || (location.Kind() != ERR_LocationKind.RADIO && location.Kind() != ERR_LocationKind.EXTRACTION)) continue;
vector objective = location.Position();
if (!ERR_HQDoctrine.Forward(objective, belief.PredictedPosition, belief.Velocity, belief.HasVelocity)) continue;
float threat = ERR_HQDoctrine.Threat(objective, belief.Position, belief.Velocity, belief.HasVelocity, belief.Confidence, belief.Radius);
if (threat <= best) continue;
best = threat;
result = ERR_HQDoctrine.Approach(objective, belief.PredictedPosition);
}
return result;
}
void MemoryStatus(array<string> replies)
{
replies.Insert(string.Format("HQ memory: %1 tracks; %2 searched cells. Predictions use delivered reports only.", m_Memory.Count(), m_Coverage.Count()));
for (int i = 0; i < m_Memory.Count() && i < 6; i++)
{
ERR_HQBelief belief = m_Memory.At(i);
replies.Insert(string.Format("Track %1: confidence %2%%, uncertainty %3m, age %4s, samples %5; %6", belief.BeliefId, Math.Round(belief.Confidence * 100), Math.Round(belief.Radius), Math.Round((belief.EvaluatedAt - belief.ObservedAt) / 1000), belief.SampleCount, belief.PredictionSource));
}
}
protected void Retask(float now)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (!unit.Assignment || !unit.Task || unit.Task.Investigation() || unit.PendingOrder || !unit.Radio || unit.PlanFighting || !unit.PlanEligible) continue;
ERR_HQBelief belief = m_Memory.GetById(unit.Task.BeliefId);
if (!belief || belief.BeliefRevision == unit.Task.BeliefRevision || now - belief.ObservedAt > 45000) continue;
// Nearby updates refine memory without churning native movement orders.
if (vector.DistanceXZ(unit.Assignment.Position, belief.Position) < Math.Max(100, belief.Radius)) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) continue;
array<vector> reserved = {};
int first = -1;
int second = -1;
foreach (ERR_HQUnit other : m_Units)
{
ERR_HQTask planned = other.PlannedTask();
if (other == unit || !planned) continue;
reserved.Insert(planned.Destination);
if (first < 0) first = planned.ApproachBearing;
else if (planned.ApproachBearing != first) second = planned.ApproachBearing;
}
int bearing = ERR_HQPursuitRules.Bearing(unit.Group.GetOrigin(), belief.PredictedPosition);
if (!ERR_HQPursuitRules.BearingAllowed(bearing, first, second)) continue;
vector destination;
if (!m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue;
ERR_HQTask task = new ERR_HQTask();
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = ERR_HQPursuitRole.SEARCH;
task.Destination = destination; task.QueuedAt = now;
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(task.Role, vector.DistanceXZ(unit.PlanPosition, destination)) * 1000;
task.ApproachBearing = bearing;
unit.PendingTask = task; unit.PendingOrder = belief.Report.Snapshot(); unit.Downlink = route;
route.MessageId = m_Journal.Message();
route.Payload = string.Format("group %1 retask from report M%2; belief=%3 destination=%4", unit.Id, belief.MessageId, belief.BeliefId, destination);
m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId);
ERR_HQHuntWindow hunt = Hunt(belief.BeliefId);
if (hunt) hunt.NextHunt = Math.Max(hunt.NextHunt, task.ExpiresAt + 180000);
}
}
protected void Assign(float now)
{
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Get("supportSquadLimit"));
int limit = ERR_HQPursuitRules.Limit(globalLimit);
int radius = m_Mode.Settings().Get("responseRadiusMetres");
int assigned = Assigned();
// Snapshot bounded own-force eligibility once per five-second planning pass.
m_PlanBeliefs.Clear();
for (int index = 0; index < m_Memory.Count(); index++) m_PlanBeliefs.Insert(m_Memory.At(index));
foreach (ERR_HQUnit unit : m_Units)
{
unit.PlanEligible = unit.Eligible(m_Mode);
unit.PlanFighting = Fighting(unit);
if (unit.Group) unit.PlanPosition = unit.Group.GetOrigin();
}
for (int h = m_Hunts.Count() - 1; h >= 0; h--)
if (!m_Memory.GetById(m_Hunts[h].BeliefId)) m_Hunts.Remove(h);
foreach (ERR_HQUnit unit : m_Units)
{
if (!unit.Assignment && !unit.PendingOrder) continue;
if (!unit.PlanEligible || assigned > globalLimit) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); assigned--; continue; }
if (unit.Task && now >= unit.Task.ExpiresAt) { FinishWatch(unit.Task, now); unit.ReleaseAssignment(); }
if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) { FinishWatch(unit.PendingTask, now); unit.CancelPending(); }
if (unit.Task && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment();
if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending();
}
assigned = Assigned();
m_HomeAvailable.Clear();
foreach (ERR_HQUnit unit : m_Units)
if (unit.HomeGuard && unit.PlanEligible && !unit.Assignment && !unit.PendingOrder)
m_HomeAvailable.Set(unit.HomeId, m_HomeAvailable.Get(unit.HomeId) + 1);
foreach (ERR_HQUnit unit : m_Units) CancelResolvedInvestigation(unit, now);
RequestCheckins(now);
Retask(now);
Investigate(now, globalLimit);
assigned = HuntingAssigned();
array<vector> reserved = {};
int first = -1;
int second = -1;
foreach (ERR_HQUnit unit : m_Units)
{
ERR_HQTask planned = unit.PlannedTask();
if (!planned || (!unit.Assignment && !unit.PendingOrder)) continue;
reserved.Insert(planned.Destination);
int bearing = planned.ApproachBearing;
if (first < 0) first = bearing;
else if (bearing != first && second < 0) second = bearing;
}
// Strongest delivered track first; finite scans (64 beliefs,128 own units).
array<int> considered = {};
for (int pass = 0; pass < m_PlanBeliefs.Count() && assigned < limit && Assigned() < globalLimit; pass++)
{
ERR_HQBelief belief;
for (int i = 0; i < m_PlanBeliefs.Count(); i++)
{
ERR_HQBelief candidate = m_PlanBeliefs[i];
if (!candidate || considered.Contains(candidate.BeliefId)) continue;
float age = (now - candidate.ObservedAt) / 1000;
if (!ERR_HQPursuitRules.Actionable(candidate.Confidence, age, candidate.Radius)) continue;
if (!belief || candidate.Confidence > belief.Confidence) belief = candidate;
}
if (!belief) break;
considered.Insert(belief.BeliefId);
ERR_HQHuntWindow hunt = Hunt(belief.BeliefId);
if (!hunt || now < hunt.NextHunt) continue;
for (int slot = 0; slot < limit && assigned < limit && Assigned() < globalLimit; slot++)
{
float age = (now - belief.ObservedAt) / 1000;
ERR_HQPursuitRole role = ERR_HQPursuitRules.Role(belief.Confidence, age, belief.Radius, belief.HasVelocity, slot);
vector destination = ERR_HQPursuitRules.Objective(belief.PredictedPosition, belief.Velocity, belief.HasVelocity, belief.Confidence, age, belief.Radius, role, slot);
if (role == ERR_HQPursuitRole.CONTAIN) destination = Containment(belief, destination);
if (role == ERR_HQPursuitRole.SEARCH && !m_Coverage.Select(belief.PredictedPosition, belief.Radius, belief.Position, now, destination, reserved)) continue;
ERR_HQUnit best;
ERR_HQRoute bestRoute;
float bestScore = -1;
int bestId;
int bestBearing = -1;
foreach (ERR_HQUnit unit : m_Units)
{
bool busy = unit.Assignment || unit.PendingOrder || unit.Downlink || unit.PlanFighting;
if (!unit.Radio || !ERR_HQPursuitRules.CanCommit(unit.Group != null, unit.PlanEligible, busy, unit.HomeGuard, HomeAvailable(unit), assigned, limit)) continue;
vector position = unit.PlanPosition;
if (vector.DistanceXZ(position, belief.Position) > radius) continue;
int bearing = ERR_HQPursuitRules.Bearing(position, belief.PredictedPosition);
if (!ERR_HQPursuitRules.BearingAllowed(bearing, first, second)) continue;
float score = ERR_HQPursuitRules.Score(role, belief.PredictedPosition, belief.Velocity, position, destination);
if (!ERR_HQPursuitRules.Better(score, unit.Id, bestScore, bestId)) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) continue;
best = unit; bestRoute = route; bestScore = score; bestId = unit.Id; bestBearing = bearing;
}
if (!best) continue;
ERR_HQTask task = new ERR_HQTask();
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision; task.Role = role;
task.Destination = destination; task.QueuedAt = now;
task.ExpiresAt = now + ERR_HQPursuitRules.TaskLifetimeSeconds(role, vector.DistanceXZ(best.PlanPosition, destination)) * 1000;
task.ApproachBearing = bestBearing;
best.PendingTask = task; best.PendingOrder = belief.Report.Snapshot(); best.Downlink = bestRoute;
bestRoute.MessageId = m_Journal.Message();
bestRoute.Payload = string.Format("group %1 %2; belief=%3 from report M%4; destination=%5", best.Id, task.Name(), task.BeliefId, belief.MessageId, destination);
m_Journal.Write("ORDER QUEUED", bestRoute.Payload, bestRoute.MessageId);
reserved.Insert(destination);
if (first < 0) first = bestBearing;
else if (bestBearing != first && second < 0) second = bestBearing;
if (best.HomeGuard) m_HomeAvailable.Set(best.HomeId, HomeAvailable(best) - 1);
hunt.NextHunt = Math.Max(hunt.NextHunt, task.ExpiresAt + 180000);
assigned++;
}
}
}
override void Tick(float now)
{
if (!Replication.IsServer()) return;
m_Memory.Tick(now);
m_Equipment.Tick(now);
m_Convoys.Tick(now);
m_Bodies.TickBudget(now);
if (now >= m_NextNetwork) { IndexRadios(); m_NextNetwork = now + 5000; }
for (int i = m_Contacts.Count() - 1; i >= 0; i--)
if (!ERR_MissionRules.ContactFresh(m_Contacts[i].ObservedAt, now)) m_Contacts.Remove(i);
@@ -355,19 +879,26 @@ class ERR_HQ : ERR_CommandService
int delivered = unit.Uplink.Advance(now, m_Journal);
if (delivered == 1)
{
if (unit.PendingSupplyStatus) m_Logistics.Receive(unit.PendingSupplyStatus, now);
if (unit.PendingStatus) m_Watch.Ingest(unit.PendingStatus, now);
else if (unit.PendingSweep) AcceptSweep(unit.PendingSweep, now);
else if (unit.PendingSupplyStatus) m_Logistics.Receive(unit.PendingSupplyStatus, now);
else Accept(unit.PendingReport, now);
}
if (delivered != 0) { unit.Uplink = null; unit.PendingReport = null; unit.PendingSupplyStatus = null; }
if (delivered < 0 && unit.PendingSweep && unit.Task && unit.PendingSweep.BeliefId == unit.Task.BeliefId && unit.PendingSweep.BeliefRevision == unit.Task.BeliefRevision && unit.PendingSweep.WatchSourceId == unit.Task.WatchSourceId && unit.PendingSweep.WatchMessageId == unit.Task.WatchMessageId && unit.PendingSweep.WatchRequestId == unit.Task.WatchRequestId) unit.Task.SweepQueued = false;
if (delivered != 0) { unit.Uplink = null; unit.PendingReport = null; unit.PendingSupplyStatus = null; unit.PendingSweep = null; unit.PendingStatus = null; }
}
if (unit.Downlink)
{
int delivered = unit.Downlink.Advance(now, m_Journal);
if (delivered == 1) DeliverOrder(unit, now);
else if (delivered < 0) unit.Release();
if (delivered == 1)
{
if (unit.PendingCheckin) { unit.NextStatus = now; unit.CancelPending(); }
else DeliverOrder(unit, now);
}
else if (delivered < 0) LostOrder(unit, now);
}
if (!unit.Group && !unit.Uplink) { unit.Release(); m_Units.Remove(i); continue; }
Observe(unit, now);
if (!unit.Group && !unit.Uplink) { ReleaseUnit(unit, now); m_Units.Remove(i); continue; }
QueueReports(unit, now);
}
if (now >= m_NextAssignment) { Assign(now); m_NextAssignment = now + 5000; }
m_Logistics.Tick(now);
@@ -377,11 +908,14 @@ class ERR_HQ : ERR_CommandService
override void Stop()
{
m_Convoys.Stop();
m_Equipment.Stop();
m_Logistics.Stop();
m_Mortars.Stop();
foreach (ERR_HQUnit unit : m_Units) unit.Release();
m_Units.Clear();
m_Contacts.Clear();
m_Memory.Clear(); m_Coverage.Clear(); m_Hunts.Clear(); m_Watch.Clear(); m_Bodies.Stop(); m_PlanBeliefs.Clear(); m_HomeAvailable.Clear();
m_Network.Clear();
m_Journal.Write("STOP", "HQ stopped; tactical orders released");
m_Journal.Flush();
@@ -11,6 +11,7 @@ class ERR_HQAdminProvider : ERR_AdminProvider
if (option == "interval") return "reportIntervalSeconds";
if (option == "squads") return "supportSquadLimit";
if (option == "radius") return "responseRadiusMetres";
if (option == "stinger") return "stingerChanceOneIn";
return "";
}
override int JournalSequence()
@@ -26,8 +27,16 @@ class ERR_HQAdminProvider : ERR_AdminProvider
}
override bool Execute(ERR_GameMode mode, string arguments, array<string> replies)
{
if (arguments != "status" && arguments != "hq show" && arguments != "hq log") return false;
if (arguments != "status" && arguments != "hq show" && arguments != "hq log" && arguments != "hq memory" && arguments != "hq convoy show" && arguments != "hq convoy start") return false;
if (!m_HQ) { replies.Insert("HQ unavailable."); return true; }
if (arguments == "hq convoy start")
{
m_HQ.StartScenarioConvoy();
replies.Insert(m_HQ.ConvoyStatus());
return true;
}
if (arguments == "hq convoy show") { replies.Insert(m_HQ.ConvoyStatus()); return true; }
if (arguments == "hq memory") { m_HQ.MemoryStatus(replies); return true; }
if (arguments == "hq show" || arguments == "status") replies.Insert(m_HQ.Status());
else
{
@@ -40,9 +49,10 @@ class ERR_HQAdminProvider : ERR_AdminProvider
override bool Help(string topic, array<string> replies)
{
if (topic != "hq") return false;
replies.Insert("/esc hq show | hq log show|next|set on|off");
replies.Insert("/esc hq interval|squads|radius show|set N");
replies.Insert("HQ assigns available physical squads through delivered reports. These commands do not create replacement forces.");
replies.Insert("/esc hq show | hq memory | hq log show|next|set on|off | hq convoy show|start");
replies.Insert("/esc hq interval|squads|radius|stinger show|set N");
replies.Insert("Stinger: 0 off, 1 every eligible soldier, 2..10000 one in N; applies only before each actor rolls.");
replies.Insert("HQ assigns available physical squads through delivered reports. Convoy start uses the single finite initial deployment; losses are not replaced.");
return true;
}
}
@@ -0,0 +1,203 @@
// Adapted from the user-owned AI body's visual discovery rules only.
// Candidate casualty identities are local perception work, never delivered HQ knowledge.
class ERR_HQBodyCandidate
{
IEntity Corpse;
bool Confirmed;
float ReportAfter;
}
class ERR_HQBodyScan
{
SCR_AIGroup Group;
float NextScan;
float LastAsked = -1;
int Cursor;
}
class ERR_HQBodyRules
{
static bool InView(float distance, float facing)
{
return ERR_HQMemoryRules.Finite(distance) && ERR_HQMemoryRules.Finite(facing)
&& distance >= 0 && distance <= 60 && (distance <= 12 || facing >= 0.5);
}
static bool Clear(float fraction, bool hitBody)
{
return ERR_HQMemoryRules.Finite(fraction) && fraction >= 0 && (fraction >= 1 || hitBody);
}
}
class ERR_HQBodyObserver
{
protected ref array<ref ERR_HQBodyCandidate> m_Roster = {};
protected ref array<ref ERR_HQBodyCandidate> m_Bodies = {};
protected ref array<ref ERR_HQBodyScan> m_Groups = {};
protected float m_NextBudget;
protected float m_Now = -1;
protected int m_Traces;
protected int m_RosterCursor;
protected bool m_CapLogged;
protected bool OwnedPhysical(SCR_AIGroup group)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !group || group.GetFaction() != mode.EnemyFaction()) return false;
ERR_ForceRecord force = mode.Forces().FindGroup(group);
return force && force.Physical && !force.Eliminated;
}
protected bool Dead(IEntity actor)
{
ChimeraCharacter character = ChimeraCharacter.Cast(actor);
return character && character.GetCharacterController()
&& character.GetCharacterController().GetLifeState() == ECharacterLifeState.DEAD;
}
protected void Cap()
{
if (m_CapLogged) return;
m_CapLogged = true;
Print("Rereintroduced [body observer]: finite local registry limit reached; no discovery work added.");
}
protected void Prune()
{
for (int i = m_Bodies.Count() - 1; i >= 0; i--)
if (!m_Bodies[i].Corpse || m_Bodies[i].Confirmed || !Dead(m_Bodies[i].Corpse)) m_Bodies.Remove(i);
for (int i = m_Roster.Count() - 1; i >= 0; i--)
if (!m_Roster[i].Corpse) m_Roster.Remove(i);
for (int i = m_Groups.Count() - 1; i >= 0; i--)
if (!m_Groups[i].Group) m_Groups.Remove(i);
}
void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !OwnedPhysical(group)) return;
Prune();
bool known;
foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) known = true;
if (!known)
{
if (m_Groups.Count() >= 128) { Cap(); return; }
ERR_HQBodyScan created = new ERR_HQBodyScan(); created.Group = group;
m_Groups.Insert(created);
}
array<AIAgent> agents = {}; group.GetAgents(agents);
for (int member = 0; member < agents.Count() && member < 32; member++)
{
AIAgent agent = agents[member];
if (!agent || !agent.GetControlledEntity()) continue;
IEntity actor = agent.GetControlledEntity();
bool found;
foreach (ERR_HQBodyCandidate entry : m_Roster) if (entry.Corpse == actor) found = true;
if (found) continue;
if (m_Roster.Count() >= 128) { Cap(); return; }
ERR_HQBodyCandidate candidate = new ERR_HQBodyCandidate(); candidate.Corpse = actor;
m_Roster.Insert(candidate);
}
}
void TickBudget(float now)
{
if (!Replication.IsServer() || !ERR_HQMemoryRules.Finite(now) || now < 0 || now < m_Now) return;
m_Now = now;
if (now < m_NextBudget) return;
m_NextBudget = now + 5000;
m_Traces = 12;
Prune();
// No position reads, messages or callbacks here: death only makes a local LOS candidate.
// Rotate the finite candidate window so sixteen remote corpses cannot hide later losses.
m_Bodies.Clear();
int total = m_Roster.Count();
for (int visited = 0; visited < total; visited++)
{
if (m_Bodies.Count() >= 16) { Cap(); break; }
if (m_RosterCursor >= total) m_RosterCursor = 0;
ERR_HQBodyCandidate candidate = m_Roster[m_RosterCursor++];
if (!candidate.Corpse || candidate.Confirmed || m_Bodies.Contains(candidate) || !Dead(candidate.Corpse)) continue;
m_Bodies.Insert(candidate);
}
}
protected bool Visible(IEntity observer, IEntity body, out vector observedPosition)
{
if (m_Traces <= 0 || !observer || !body || !ERR_Actors.CanFight(observer)
|| CompartmentAccessComponent.GetVehicleIn(observer)) return false;
vector bodyPosition = body.GetOrigin();
vector observerPosition = observer.GetOrigin();
vector delta = bodyPosition - observerPosition;
float distance = delta.Length();
delta[1] = 0;
vector transform[4]; observer.GetWorldTransform(transform);
vector forward = transform[2]; forward[1] = 0;
float facing;
if (delta.Length() > 0.01 && forward.Length() > 0.01)
facing = vector.Dot(forward.Normalized(), delta.Normalized());
if (!ERR_HQBodyRules.InView(distance, facing)) return false;
TraceParam trace = new TraceParam();
trace.Start = observerPosition + "0 1.5 0";
trace.End = bodyPosition + "0 0.3 0";
trace.Exclude = observer;
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
m_Traces--;
float fraction = GetGame().GetWorld().TraceMove(trace, null);
if (!ERR_HQBodyRules.Clear(fraction, trace.TraceEnt == body)) return false;
observedPosition = bodyPosition;
return true;
}
protected ERR_HQBodyScan OldestDue(float now)
{
ERR_HQBodyScan selected;
foreach (ERR_HQBodyScan scan : m_Groups)
{
// Recently requesting groups share turns; inactive callers cannot reserve work forever.
if (!scan.Group || now < scan.NextScan || scan.LastAsked < 0 || now - scan.LastAsked > 5000) continue;
if (!OwnedPhysical(scan.Group) || scan.Group.GetPlayerCount(true) > 0) continue;
if (!selected || scan.NextScan < selected.NextScan) selected = scan;
}
return selected;
}
bool Observe(SCR_AIGroup group, float now, out ERR_HQReport report)
{
report = null;
if (!Replication.IsServer() || !ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return false;
TickBudget(now);
if (!OwnedPhysical(group) || group.GetPlayerCount(true) > 0 || m_Bodies.IsEmpty()) return false;
ERR_HQBodyScan state;
foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) state = scan;
if (!state) return false;
state.LastAsked = now;
if (now < state.NextScan || m_Traces <= 0 || OldestDue(now) != state) return false;
state.NextScan = now + 15000;
array<AIAgent> agents = {}; group.GetAgents(agents);
int count = m_Bodies.Count();
for (int visited = 0; visited < count; visited++)
{
if (state.Cursor >= count) state.Cursor = 0;
ERR_HQBodyCandidate candidate = m_Bodies[state.Cursor++];
if (!candidate.Corpse || candidate.Confirmed || now < candidate.ReportAfter || !Dead(candidate.Corpse)) continue;
for (int member = 0; member < agents.Count() && member < 32; member++)
{
if (m_Traces <= 0) return false;
AIAgent agent = agents[member];
if (!agent) continue;
vector position;
if (!Visible(agent.GetControlledEntity(), candidate.Corpse, position)) continue;
report = new ERR_HQReport();
report.Target = candidate.Corpse; report.Source = group;
report.Position = position; report.ObservedAt = now; report.TravelMode = "body";
// Local throttling only. A lost radio call can be retried after fresh LOS.
candidate.ReportAfter = now + 15000;
return true;
}
}
return false;
}
// Call only after a radio-delivered body snapshot was accepted by HQ memory.
void ConfirmDelivered(IEntity body)
{
if (!Replication.IsServer() || !body) return;
foreach (ERR_HQBodyCandidate candidate : m_Roster)
if (candidate.Corpse == body) candidate.Confirmed = true;
}
void Stop()
{
m_Roster.Clear(); m_Bodies.Clear(); m_Groups.Clear();
m_Traces = 0; m_Now = -1; m_NextBudget = 0; m_RosterCursor = 0; m_CapLogged = false;
}
}
+145
View File
@@ -0,0 +1,145 @@
// Negative sightings have the same ownership as visual reports: HQ may ingest
// this snapshot only after its physical source's uplink completes successfully.
class ERR_HQSearchReport
{
string WatchSourceId;
int WatchMessageId;
int WatchRequestId;
int SourceId;
int MessageId;
int BeliefId;
int BeliefRevision;
vector Position;
float ObservedAt = -1;
bool SweepCompleted;
bool ContactFound;
}
class ERR_HQCoverageRules
{
static bool PositionValid(vector position)
{
return ERR_HQPursuitRules.PositionValid(position)
&& Math.AbsFloat(position[0]) < 1000000 && Math.AbsFloat(position[2]) < 1000000;
}
static int Cell(float coordinate) { return Math.Floor(coordinate / 500); }
// Bounded coordinates make this integer grid key collision-free, including
// negative positions. No per-candidate string or collection allocation.
static int Key(vector position)
{
return (Cell(position[0]) + 2000) * 4096 + Cell(position[2]) + 2000;
}
static bool Recent(float searchedAt, float now)
{
return ERR_HQPursuitRules.Finite(searchedAt) && ERR_HQPursuitRules.Finite(now)
&& searchedAt >= 0 && now >= searchedAt && now - searchedAt <= 900000;
}
static bool ValidReport(int sourceId, int messageId, int beliefId, int revision,
bool completed, bool found, float observedAt, float now)
{
return sourceId > 0 && messageId > 0 && beliefId > 0 && revision > 0 && completed && !found
&& Recent(observedAt, now) && now - observedAt <= 120000;
}
static bool Point(int x, int z, vector center, float radius, out vector point)
{
point = Vector(x * 500 + 250, center[1], z * 500 + 250);
if (vector.DistanceXZ(point, center) > radius)
{
// Small uncertainty circles need not include a grid center. Choose a point
// in their intersecting cell; inset edges keep its key in the selected cell.
point[0] = Math.Clamp(center[0], x * 500, x * 500 + 499.99);
point[2] = Math.Clamp(center[2], z * 500, z * 500 + 499.99);
}
return PositionValid(point) && vector.DistanceXZ(point, center) <= radius;
}
}
// A cell records a reported sweep visit, not proof that every building or hiding
// place in its 500m square was visible. New positive sightings invalidate that
// coarse exclusion; replay watermarks survive so old negatives cannot restore it.
class ERR_HQCoverage
{
protected ref map<int, float> m_Cells = new map<int, float>();
protected ref map<int, int> m_Messages = new map<int, int>();
int Count() { return m_Cells.Count(); }
void Forget(vector position)
{
if (ERR_HQCoverageRules.PositionValid(position)) m_Cells.Remove(ERR_HQCoverageRules.Key(position));
}
bool Recent(vector position, float now)
{
if (!ERR_HQCoverageRules.PositionValid(position)) return false;
int key = ERR_HQCoverageRules.Key(position);
return m_Cells.Contains(key) && ERR_HQCoverageRules.Recent(m_Cells.Get(key), now);
}
bool Ingest(ERR_HQSearchReport report, float now)
{
if (!report || !ERR_HQCoverageRules.PositionValid(report.Position)) return false;
if (!ERR_HQCoverageRules.ValidReport(report.SourceId, report.MessageId, report.BeliefId,
report.BeliefRevision, report.SweepCompleted, report.ContactFound, report.ObservedAt, now)) return false;
if (m_Messages.Contains(report.SourceId) && report.MessageId <= m_Messages.Get(report.SourceId)) return false;
// One uplink per source gives monotonic message order. Never evict replay
// watermarks; the mission already caps its total physical formations at 128.
if (!m_Messages.Contains(report.SourceId) && m_Messages.Count() >= 128) return false;
m_Messages.Set(report.SourceId, report.MessageId);
int key = ERR_HQCoverageRules.Key(report.Position);
if (m_Cells.Contains(key) && m_Cells.Get(key) >= report.ObservedAt) return false;
if (!m_Cells.Contains(key) && m_Cells.Count() >= 128)
{
int oldestKey;
float oldest = 1e20;
foreach (int known, float searched : m_Cells)
{
if (searched >= oldest) continue;
oldest = searched;
oldestKey = known;
}
if (oldest >= report.ObservedAt) return false;
m_Cells.Remove(oldestKey);
}
// Store the observation time, not delivery time: radio delay never makes a
// searched area newer than the physical sweep that justified it.
m_Cells.Set(key, report.ObservedAt);
return true;
}
protected bool Reserved(vector point, array<vector> reserved)
{
if (!reserved) return false;
int key = ERR_HQCoverageRules.Key(point);
foreach (vector position : reserved)
if (ERR_HQCoverageRules.PositionValid(position) && ERR_HQCoverageRules.Key(position) == key) return true;
return false;
}
bool Select(vector center, float radius, vector fromPosition, float now,
out vector destination, array<vector> reserved = null)
{
destination = center;
if (!ERR_HQCoverageRules.PositionValid(center) || !ERR_HQCoverageRules.PositionValid(fromPosition))
return false;
if (!ERR_HQPursuitRules.Finite(now) || now < 0 || !ERR_HQPursuitRules.Finite(radius)
|| radius < 0 || radius > 2000 || (reserved && reserved.Count() > 8)) return false;
int lowX = ERR_HQCoverageRules.Cell(center[0] - radius);
int highX = ERR_HQCoverageRules.Cell(center[0] + radius);
int lowZ = ERR_HQCoverageRules.Cell(center[2] - radius);
int highZ = ERR_HQCoverageRules.Cell(center[2] + radius);
float best = 1e20;
bool found;
vector point;
// Radius is bounded to 2 km: at most nine by nine candidate grid cells.
for (int x = lowX; x <= highX; x++)
{
for (int z = lowZ; z <= highZ; z++)
{
if (!ERR_HQCoverageRules.Point(x, z, center, radius, point)) continue;
if (Recent(point, now) || Reserved(point, reserved)) continue;
float distance = vector.DistanceXZ(point, fromPosition);
if (distance >= best) continue;
best = distance;
destination = point;
found = true;
}
}
return found;
}
void Clear() { m_Cells.Clear(); m_Messages.Clear(); }
}
@@ -0,0 +1,36 @@
// Own objective locations are doctrine, not enemy observations. A straight-line
// heading suggests a destination; it does not assert a road route or player goal.
class ERR_HQDoctrine
{
static float Threat(vector objective, vector lastSeen, vector velocity, bool heading,
float confidence, float radius)
{
if (!ERR_HQPursuitRules.PositionValid(objective) || !ERR_HQPursuitRules.PositionValid(lastSeen)) return 0;
float standing = 0.2;
if (!ERR_HQPursuitRules.Finite(confidence) || confidence < 0 || confidence > 1
|| !ERR_HQPursuitRules.Finite(radius) || radius < 0 || radius > 2000) return standing;
vector route = objective - lastSeen;
route[1] = 0;
float distance = route.Length();
float proximity = 1 / (1 + distance / Math.Max(100, radius));
float alignment;
if (distance > 1 && ERR_HQPursuitRules.Heading(velocity, heading))
alignment = Math.Max(0, vector.Dot(route.Normalized(), ERR_HQPursuitRules.Direction(velocity, true)));
return standing + confidence * (0.4 * proximity + 0.6 * alignment / (1 + distance / 3000));
}
static bool Forward(vector objective, vector position, vector velocity, bool heading)
{
if (!ERR_HQPursuitRules.PositionValid(objective) || !ERR_HQPursuitRules.PositionValid(position)
|| !ERR_HQPursuitRules.Heading(velocity, heading)) return false;
vector route = objective - position;
route[1] = 0;
return route.Length() >= 150 && vector.Dot(route.Normalized(), ERR_HQPursuitRules.Direction(velocity, true)) >= 0.5;
}
static vector Approach(vector objective, vector position)
{
vector route = objective - position;
route[1] = 0;
if (route.Length() < 1) return position;
return position + route.Normalized() * Math.Clamp(route.Length() * 0.25, 100, 200);
}
}
+340
View File
@@ -0,0 +1,340 @@
// Memory accepts radio-delivered reports at one boundary. Entity references are
// opaque identity keys: this module never reads a target's current world state.
enum ERR_HQMemoryKind
{
SIGHTING,
DISTRESS,
BODY
}
class ERR_HQMemoryRules
{
static bool Finite(float value) { return value == value && Math.AbsFloat(value) <= 1000000000000.0; }
static bool PositionValid(vector value)
{
for (int i = 0; i < 3; i++)
if (!Finite(value[i]) || Math.AbsFloat(value[i]) > 1000000) return false;
return true;
}
static bool DeliveryFresh(float observedAt, float now)
{
return Finite(observedAt) && Finite(now) && observedAt >= 0 && now >= observedAt && now - observedAt <= 120000;
}
static int Kind(ERR_HQReport report)
{
if (report.Distress) return ERR_HQMemoryKind.DISTRESS;
if (report.TravelMode == "body" || report.TravelMode == "dead body") return ERR_HQMemoryKind.BODY;
return ERR_HQMemoryKind.SIGHTING;
}
static float Confidence(float ageSeconds, float halfLife = 600)
{
if (!Finite(ageSeconds) || !Finite(halfLife) || ageSeconds < 0 || halfLife <= 0) return 0;
return Math.Pow(0.5, ageSeconds / halfLife);
}
static float BaseConfidence(int kind)
{
if (kind == ERR_HQMemoryKind.DISTRESS) return 0.3;
if (kind == ERR_HQMemoryKind.BODY) return 0.2;
return 1;
}
static float HalfLife(int kind)
{
if (kind == ERR_HQMemoryKind.DISTRESS) return 180;
if (kind == ERR_HQMemoryKind.BODY) return 300;
return 600;
}
static float RadiusFloor(int kind, string travel)
{
if (kind == ERR_HQMemoryKind.DISTRESS) return 150;
if (kind == ERR_HQMemoryKind.BODY) return 75;
if (travel == "aircraft") return 120;
if (travel == "vehicle" || travel == "in vehicle") return 60;
return 25;
}
static float RadiusGrowth(int kind, string travel)
{
if (kind == ERR_HQMemoryKind.DISTRESS) return 2;
if (kind == ERR_HQMemoryKind.BODY) return 1.2;
if (travel == "aircraft") return 8;
if (travel == "vehicle" || travel == "in vehicle") return 4;
return 1.2;
}
static float Radius(float ageSeconds, float growth, float floor)
{
if (!Finite(ageSeconds) || !Finite(growth) || !Finite(floor)) return 2001;
if (ageSeconds < 0 || growth < 0 || floor < 0) return 2001;
return floor + ageSeconds * growth;
}
static float SpeedLimit(string travel)
{
if (travel == "on foot") return 12;
if (travel == "vehicle" || travel == "in vehicle") return 55;
if (travel == "aircraft") return 120;
return 0;
}
static bool MotionCredible(float seconds, float distance, string previousMode, string currentMode)
{
if (!Finite(seconds) || !Finite(distance) || seconds < 2 || seconds > 120 || distance < 0) return false;
float limit = SpeedLimit(currentMode);
return previousMode == currentMode && limit > 0 && distance / seconds <= limit;
}
static vector Predict(vector position, vector velocity, float ageSeconds)
{
if (!PositionValid(position) || !PositionValid(velocity) || !Finite(ageSeconds) || ageSeconds < 0)
return position;
vector offset = velocity * Math.Clamp(ageSeconds, 0, 30);
offset[1] = 0;
if (offset.Length() > 300) offset = offset.Normalized() * 300;
return position + offset;
}
static bool Actionable(float confidence, float radius)
{
return Finite(confidence) && Finite(radius) && confidence >= 0.1 && confidence <= 1 && radius <= 2000 && radius >= 0;
}
static bool NewMessage(int id, bool alreadySeen)
{
if (id <= 0 || alreadySeen) return false;
return true;
}
static float Value(float confidence, float radius) { return confidence / (1 + radius / 500); }
}
// Detached read model. Mutating it (or Report) cannot alter the stored history.
class ERR_HQBelief
{
int BeliefId;
int BeliefRevision;
float EvaluatedAt;
IEntity Target;
vector Position;
vector PredictedPosition;
vector Velocity;
bool HasVelocity;
float Speed;
float HeadingDegrees;
float Confidence;
float Radius;
float ObservedAt;
float LastDeliveredAt;
float PredictionSeconds;
int MessageId;
int SampleCount;
int Kind;
string PredictionSource;
string TravelMode;
ref ERR_HQReport Report;
bool Actionable() { return Kind == ERR_HQMemoryKind.SIGHTING && ERR_HQMemoryRules.Actionable(Confidence, Radius); }
}
class ERR_HQMemoryTrack
{
int BeliefId;
int BeliefRevision;
int NegativeRevision = -1;
float SearchConfidence = 1;
IEntity Key;
int Kind;
float DeliveredAt;
protected ref array<ref ERR_HQReport> m_Samples = {};
ERR_HQReport Latest() { return m_Samples[m_Samples.Count() - 1]; }
int Count() { return m_Samples.Count(); }
void Append(ERR_HQReport report, float now)
{
if (m_Samples.Count() >= 8) m_Samples.Remove(0);
m_Samples.Insert(report.Snapshot());
DeliveredAt = now;
BeliefRevision++;
SearchConfidence = 1;
}
float ConfidenceAt(float now)
{
float seconds = Math.Max(0, (now - Latest().ObservedAt) / 1000);
return SearchConfidence * ERR_HQMemoryRules.BaseConfidence(Kind)
* ERR_HQMemoryRules.Confidence(seconds, ERR_HQMemoryRules.HalfLife(Kind));
}
float RadiusAt(float now)
{
ERR_HQReport last = Latest();
return ERR_HQMemoryRules.Radius(Math.Max(0, (now - last.ObservedAt) / 1000),
ERR_HQMemoryRules.RadiusGrowth(Kind, last.TravelMode), ERR_HQMemoryRules.RadiusFloor(Kind, last.TravelMode));
}
ERR_HQBelief Evaluate(float now)
{
ERR_HQReport last = Latest();
ERR_HQBelief belief = new ERR_HQBelief();
belief.BeliefId = BeliefId;
belief.BeliefRevision = BeliefRevision;
belief.EvaluatedAt = now;
belief.Report = last.Snapshot();
belief.Target = last.Target;
belief.Position = last.Position;
belief.PredictedPosition = last.Position;
belief.ObservedAt = last.ObservedAt;
belief.LastDeliveredAt = DeliveredAt;
belief.MessageId = last.MessageId;
belief.SampleCount = Count();
belief.Kind = Kind;
belief.TravelMode = last.TravelMode;
belief.Confidence = ConfidenceAt(now);
belief.Radius = RadiusAt(now);
belief.PredictionSource = "delivered sighting; motion unknown";
if (Kind == ERR_HQMemoryKind.DISTRESS)
{
belief.Target = null;
belief.PredictionSource = "delivered distress; enemy position and motion unknown";
return belief;
}
if (Kind == ERR_HQMemoryKind.BODY)
{
belief.PredictionSource = "reported body location; no moving-target inference";
return belief;
}
if (Count() < 2) return belief;
ERR_HQReport previous = m_Samples[Count() - 2];
float seconds = (last.ObservedAt - previous.ObservedAt) / 1000;
float distance = vector.DistanceXZ(previous.Position, last.Position);
if (!ERR_HQMemoryRules.MotionCredible(seconds, distance, previous.TravelMode, last.TravelMode))
{
belief.PredictionSource = "delivered sighting; motion history not credible";
return belief;
}
belief.Velocity = (last.Position - previous.Position) / seconds;
belief.Velocity[1] = 0;
belief.Speed = belief.Velocity.Length();
belief.HasVelocity = true;
float age = Math.Max(0, (now - last.ObservedAt) / 1000);
belief.PredictionSeconds = Math.Clamp(age, 0, 30);
belief.PredictedPosition = ERR_HQMemoryRules.Predict(last.Position, belief.Velocity, age);
belief.HeadingDegrees = Math.Atan2(belief.Velocity[0], belief.Velocity[2]) * Math.RAD2DEG;
if (belief.HeadingDegrees < 0) belief.HeadingDegrees += 360;
belief.PredictionSource = "successive delivered sightings; 30s/300m prediction limit";
if (belief.Speed < 0.5) belief.PredictionSource = "successive delivered sightings; approximately stationary";
return belief;
}
}
class ERR_HQMemory
{
protected ref array<ref ERR_HQMemoryTrack> m_Tracks = {};
protected ref array<int> m_Seen = {};
protected ref array<float> m_SeenAt = {};
protected int m_NextBeliefId;
protected float m_Now = -1;
protected string m_LastRefusal;
string LastRefusal() { return m_LastRefusal; }
int Count() { return m_Tracks.Count(); }
protected bool Refuse(string reason) { m_LastRefusal = reason; return false; }
protected int Find(IEntity key, int kind)
{
for (int i = 0; i < m_Tracks.Count(); i++)
if (m_Tracks[i].Key == key && m_Tracks[i].Kind == kind) return i;
return -1;
}
void Clear()
{
m_Tracks.Clear(); m_Seen.Clear(); m_SeenAt.Clear();
m_Now = -1; m_LastRefusal = "";
// Do not recycle belief IDs: an order retained across Clear cannot bind to a new track.
}
// A slow HQ tick advances only the observation clock and bounded retirement.
// Confidence/radius are calculated on read; there is no per-frame decay work.
void Tick(float now)
{
if (!ERR_HQMemoryRules.Finite(now) || now < 0 || now < m_Now) return;
m_Now = now;
for (int message = m_Seen.Count() - 1; message >= 0; message--)
{
if (now - m_SeenAt[message] <= 120000) continue;
m_Seen.Remove(message); m_SeenAt.Remove(message);
}
for (int i = m_Tracks.Count() - 1; i >= 0; i--)
if (m_Tracks[i].RadiusAt(now) > 2000) m_Tracks.Remove(i);
}
// Call exclusively after the radio network accepts delivery. No other method
// can inject a report, and no stored report object is exposed to a caller.
bool Ingest(ERR_HQReport report, float now)
{
if (!ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return Refuse("invalid memory clock");
Tick(now);
if (!report || !ERR_HQMemoryRules.DeliveryFresh(report.ObservedAt, now)) return Refuse("stale or future report");
if (!ERR_HQMemoryRules.PositionValid(report.Position)) return Refuse("invalid observed position");
if (!ERR_HQMemoryRules.NewMessage(report.MessageId, m_Seen.Contains(report.MessageId)))
return Refuse("replayed or expired message");
if (m_Seen.Count() >= 4096) return Refuse("delivered-report replay guard full");
int kind = ERR_HQMemoryRules.Kind(report);
IEntity key = report.Target;
if (kind == ERR_HQMemoryKind.DISTRESS) key = report.Source;
if (!key) return Refuse("report has no observation identity");
int index = Find(key, kind);
if (index >= 0 && report.ObservedAt <= m_Tracks[index].Latest().ObservedAt)
return Refuse("observation does not advance trajectory");
if (index < 0 && m_NextBeliefId >= 2147483646) return Refuse("belief identity space exhausted");
if (index < 0 && m_Tracks.Count() >= 64)
{
int weakest = -1;
float least = 2;
for (int i = 0; i < m_Tracks.Count(); i++)
{
float value = ERR_HQMemoryRules.Value(m_Tracks[i].ConfidenceAt(now), m_Tracks[i].RadiusAt(now));
if (value < least) { least = value; weakest = i; }
}
float age = (now - report.ObservedAt) / 1000;
float incoming = ERR_HQMemoryRules.BaseConfidence(kind);
incoming *= ERR_HQMemoryRules.Confidence(age, ERR_HQMemoryRules.HalfLife(kind));
float radius = ERR_HQMemoryRules.Radius(age, ERR_HQMemoryRules.RadiusGrowth(kind, report.TravelMode),
ERR_HQMemoryRules.RadiusFloor(kind, report.TravelMode));
if (weakest < 0 || ERR_HQMemoryRules.Value(incoming, radius) <= least)
return Refuse("memory full; stronger observations retained");
m_Tracks.Remove(weakest);
}
ERR_HQMemoryTrack track;
if (index >= 0) track = m_Tracks[index];
else
{
track = new ERR_HQMemoryTrack();
track.BeliefId = ++m_NextBeliefId; track.Key = key; track.Kind = kind;
m_Tracks.Insert(track);
}
track.Append(report, now);
m_Seen.Insert(report.MessageId); m_SeenAt.Insert(report.ObservedAt);
m_LastRefusal = "";
return true;
}
ERR_HQBelief Get(IEntity target)
{
if (!target) return null;
int index = Find(target, ERR_HQMemoryKind.SIGHTING);
if (index < 0) return null;
return m_Tracks[index].Evaluate(m_Now);
}
ERR_HQBelief GetById(int beliefId)
{
for (int i = 0; i < m_Tracks.Count(); i++)
if (m_Tracks[i].BeliefId == beliefId) return m_Tracks[i].Evaluate(m_Now);
return null;
}
// This is negative evidence from an actually delivered sweep report, not a
// synthetic sighting. A duplicate cannot compound the same observation's loss.
bool NegativeSearch(int beliefId, int revision, float now)
{
if (!ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return false;
Tick(now);
for (int i = 0; i < m_Tracks.Count(); i++)
{
ERR_HQMemoryTrack track = m_Tracks[i];
if (track.BeliefId != beliefId || track.Kind != ERR_HQMemoryKind.SIGHTING) continue;
if (track.BeliefRevision != revision || track.NegativeRevision == revision) return false;
track.SearchConfidence = 0.65;
track.NegativeRevision = revision;
return true;
}
return false;
}
ERR_HQBelief At(int index)
{
if (index < 0 || index >= m_Tracks.Count()) return null;
return m_Tracks[index].Evaluate(m_Now);
}
}
@@ -64,6 +64,10 @@ class ERR_HQRoute
{
if (now < Due)
return 0;
// A transmission takes two seconds. The sender must keep its physical
// radio throughout that interval, including the final manpack-to-HQ leg.
if (Cursor >= 0 && !Nodes[Cursor].Valid(!Downlink && Cursor == Nodes.Count() - 1))
return Fail(journal, "transmitting carrier lost radio during transmission");
if (target < Nodes.Count())
{
if (!Nodes[target].Valid(Downlink && target == 0))
+164
View File
@@ -0,0 +1,164 @@
enum ERR_HQPursuitRole
{
SEARCH,
INTERCEPT,
CONTAIN
}
// Pure decisions over delivered belief snapshots and HQ's own physical forces.
// No player/entity reads, spawning, timers, waypoints or independent assignments.
class ERR_HQPursuitRules
{
static bool Finite(float value)
{
return value == value && value > -1e20 && value < 1e20;
}
static bool PositionValid(vector value)
{
return Finite(value[0]) && Finite(value[1]) && Finite(value[2]);
}
static int Limit(int configured) { return Math.Clamp(configured, 0, 3); }
static bool Actionable(float confidence, float ageSeconds, float uncertainty)
{
return Finite(confidence) && Finite(ageSeconds) && Finite(uncertainty)
&& confidence >= 0.15 && confidence <= 1 && ageSeconds >= 0
&& uncertainty >= 0 && uncertainty <= 2000;
}
static bool Heading(vector velocity, bool reported)
{
if (!reported || !PositionValid(velocity)) return false;
velocity[1] = 0;
float speed = velocity.Length();
return speed >= 0.5 && speed <= 70;
}
static ERR_HQPursuitRole Role(float confidence, float ageSeconds, float uncertainty,
bool hasHeading, int slot)
{
// A stale/broad/weak track authorizes a search, never a precision interception.
if (!Actionable(confidence, ageSeconds, uncertainty) || !hasHeading || slot < 0 || slot > 2)
return ERR_HQPursuitRole.SEARCH;
if (confidence < 0.65 || ageSeconds > 45 || uncertainty > 150) return ERR_HQPursuitRole.SEARCH;
if (slot == 0) return ERR_HQPursuitRole.INTERCEPT;
if (slot == 2 && confidence >= 0.8 && uncertainty <= 100) return ERR_HQPursuitRole.CONTAIN;
return ERR_HQPursuitRole.SEARCH;
}
static float LifetimeSeconds(ERR_HQPursuitRole role)
{
if (role == ERR_HQPursuitRole.INTERCEPT) return 60;
if (role == ERR_HQPursuitRole.CONTAIN) return 90;
return 120;
}
static float TaskLifetimeSeconds(ERR_HQPursuitRole role, float distanceMetres)
{
if (!Finite(distanceMetres) || distanceMetres < 0) return 0;
if (role != ERR_HQPursuitRole.SEARCH) return LifetimeSeconds(role);
// Existing infantry must have time to travel and search. This is an absolute
// order lifetime, not permission to refresh an old task indefinitely.
return Math.Clamp(distanceMetres / 3 + 45, 120, 900);
}
static bool CanCommit(bool physical, bool eligible, bool busy, bool homeGuard,
int homeAvailable, int assigned, int configured)
{
if (!physical || !eligible || busy || assigned < 0 || assigned >= Limit(configured)) return false;
// Count only uncommitted physical home groups; pending radio orders spend one
// immediately. Mobile patrols may leave; at least one home group must remain.
return !homeGuard || homeAvailable > 1;
}
static int Bearing(vector forcePosition, vector beliefPosition)
{
if (!PositionValid(forcePosition) || !PositionValid(beliefPosition)) return -1;
vector offset = forcePosition - beliefPosition;
if (Math.AbsFloat(offset[0]) > Math.AbsFloat(offset[2]))
{
if (offset[0] >= 0) return 1;
return 3;
}
if (offset[2] >= 0) return 0;
return 2;
}
static bool BearingAllowed(int bearing, int first, int second)
{
if (bearing < 0 || bearing > 3 || first < -1 || first > 3 || second < -1 || second > 3)
return false;
if (first == -1 && second != -1) return false;
// At most two occupied cardinal approach sectors; never close a full ring.
return first == -1 || second == -1 || second == first || bearing == first || bearing == second;
}
static vector Direction(vector velocity, bool hasHeading)
{
if (!Heading(velocity, hasHeading)) return "0 0 1";
velocity[1] = 0;
return velocity.Normalized();
}
static vector Objective(vector position, vector velocity, bool hasHeading, float confidence,
float ageSeconds, float uncertainty, ERR_HQPursuitRole role, int slot)
{
if (!PositionValid(position)) return "0 0 0";
bool directionKnown = Heading(velocity, hasHeading);
// Callers pass the memory's already predicted position. Do not extrapolate
// report age a second time; the small future lead is independently bounded.
ERR_HQPursuitRole permitted = Role(confidence, ageSeconds, uncertainty, directionKnown, slot);
if (role != permitted) role = ERR_HQPursuitRole.SEARCH;
vector forward = Direction(velocity, directionKnown);
vector right = Vector(forward[2], 0, -forward[0]);
float radius = 40;
if (Finite(uncertainty)) radius = Math.Clamp(uncertainty, 40, 250);
vector offset;
if (role == ERR_HQPursuitRole.INTERCEPT)
{
velocity[1] = 0;
float horizon = Math.Clamp(15 - ageSeconds * 0.2, 3, 15);
float lead = Math.Clamp(velocity.Length() * horizon, 60, 200);
offset = forward * lead;
}
else if (role == ERR_HQPursuitRole.CONTAIN)
offset = forward * 80 + right * Math.Clamp(radius, 80, 150);
else
{
// Distinct search sectors within the bounded uncertainty area. HQ/memory
// owns sweep history; slot is a supplied sector, not an independent clock.
int sector = slot % 4;
if (sector < 0) sector += 4;
if (sector == 0) offset = forward * radius;
else if (sector == 1) offset = right * radius;
else if (sector == 2) offset = right * -radius;
else offset = forward * -radius;
}
if (offset.Length() > 300) offset = offset.Normalized() * 300;
return position + offset;
}
// Lower scores win. A negative score rejects malformed geometry. Travel cost
// dominates; direction penalties avoid ordering trailing units to overtake the
// contact or sending a far-side guard across it to contain the other flank.
static float Score(ERR_HQPursuitRole role, vector beliefPosition, vector velocity,
vector forcePosition, vector objective)
{
if (!PositionValid(beliefPosition) || !PositionValid(forcePosition) || !PositionValid(objective))
return -1;
vector forward = Direction(velocity, true);
vector offset = forcePosition - beliefPosition;
offset[1] = 0;
float cost = vector.DistanceXZ(forcePosition, objective);
if (role != ERR_HQPursuitRole.SEARCH && cost > 3 * (LifetimeSeconds(role) - 10)) return -1;
if (role == ERR_HQPursuitRole.INTERCEPT)
{
float ahead = vector.Dot(offset, forward);
if (ahead < 0) cost += Math.Min(-ahead, 1000);
}
else if (role == ERR_HQPursuitRole.CONTAIN)
{
vector right = Vector(forward[2], 0, -forward[0]);
float side = vector.Dot(offset, right);
float intendedSide = vector.Dot(objective - beliefPosition, right);
if (side * intendedSide < 0) cost += Math.Min(Math.AbsFloat(side), 1000);
}
return cost;
}
static bool Better(float score, int id, float bestScore, int bestId)
{
if (!Finite(score) || score < 0 || id <= 0) return false;
if (bestId <= 0 || bestScore < 0) return true;
if (score != bestScore) return score < bestScore;
return id < bestId;
}
}
@@ -0,0 +1,100 @@
// Local evidence only. HQ must receive the resulting immutable status through
// the normal radio route; sampling never writes the delivered watch roster.
class ERR_HQStatusSampler
{
protected SCR_AIGroup m_Group;
protected Faction m_Faction;
protected string m_SourceId;
protected int m_Strength;
protected int m_Deaths;
protected bool m_Bound;
protected float m_LastSampleAt = -1;
protected ref array<IEntity> m_Members = {};
protected ref array<bool> m_Dead = {};
protected ref array<bool> m_Released = {};
protected ref array<AIAgent> m_Agents = {};
bool Bound() { return m_Bound; }
protected bool Eligible()
{
return Replication.IsServer() && m_Bound && m_Group && m_Faction
&& m_Group.GetPlayerCount(true) == 0 && !m_Group.IsSlave() && m_Group.GetFaction() == m_Faction;
}
bool Bind(SCR_AIGroup group, string sourceId, int initialStrength, Faction enemyFaction)
{
if (!Replication.IsServer()) return false;
if (m_Bound) return Eligible() && group == m_Group && sourceId == m_SourceId
&& initialStrength == m_Strength && enemyFaction == m_Faction;
if (!Replication.IsServer() || !group || !enemyFaction || sourceId.IsEmpty() || sourceId.Length() > 96
|| initialStrength < 1 || initialStrength > 32 || group.GetPlayerCount(true) > 0
|| group.IsSlave() || group.GetFaction() != enemyFaction) return false;
m_Agents.Clear(); m_Members.Clear();
group.GetAgents(m_Agents);
if (m_Agents.Count() != initialStrength) { m_Agents.Clear(); return false; }
foreach (AIAgent agent : m_Agents)
{
if (!agent) { m_Members.Clear(); return false; }
IEntity actor = agent.GetControlledEntity();
if (!ERR_Actors.Alive(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group
|| SCR_FactionManager.SGetFaction(actor) != enemyFaction || m_Members.Contains(actor))
{ m_Members.Clear(); return false; }
m_Members.Insert(actor);
}
m_Group = group; m_SourceId = sourceId; m_Strength = initialStrength; m_Faction = enemyFaction;
for (int i = 0; i < initialStrength; i++) { m_Dead.Insert(false); m_Released.Insert(false); }
m_Agents.Clear();
m_Bound = true;
return true;
}
// Call from the existing physical slow tick, not just each 180-second radio
// cycle: after native corpse deletion, a null reference supplies no evidence.
bool ObserveDeaths()
{
if (!Eligible()) return false;
for (int i = 0; i < m_Members.Count(); i++)
{
if (m_Dead[i] || m_Released[i]) continue;
IEntity actor = m_Members[i];
if (!actor) continue;
if (ERR_Actors.Player(actor) || SCR_FactionManager.SGetFaction(actor) != m_Faction)
{ m_Released[i] = true; continue; }
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (!damage) continue;
if (damage.IsDestroyed()) { m_Dead[i] = true; m_Deaths++; continue; }
// A living member who left this group is no longer ours to account for.
// Native death may detach a corpse, so group identity is checked after death.
if (ERR_Actors.Group(actor) != m_Group) m_Released[i] = true;
}
return true;
}
ERR_HQStatusReport Sample(float now, int messageId, IEntity caller)
{
if (!ERR_HQPursuitRules.Finite(now) || now < 0 || now < m_LastSampleAt || messageId <= 0
|| !ObserveDeaths()) return null;
int callerIndex = m_Members.Find(caller);
if (!caller || callerIndex < 0 || m_Dead[callerIndex] || m_Released[callerIndex]
|| ERR_Actors.Player(caller) || ERR_Actors.Group(caller) != m_Group
|| SCR_FactionManager.SGetFaction(caller) != m_Faction || ERR_HQRadio.Capability(caller) <= 0) return null;
vector position = m_Group.GetOrigin();
if (!ERR_HQPursuitRules.PositionValid(position)) return null;
ERR_HQStatusReport report = new ERR_HQStatusReport();
report.SourceId = m_SourceId; report.MessageId = messageId; report.Position = position;
report.Strength = m_Strength; report.ConfirmedLosses = m_Deaths; report.ObservedAt = now;
for (int i = 0; i < m_Members.Count(); i++)
{
if (m_Dead[i] || m_Released[i]) continue;
IEntity actor = m_Members[i];
if (!ERR_Actors.Alive(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != m_Group
|| SCR_FactionManager.SGetFaction(actor) != m_Faction) continue;
report.Count++;
if (ERR_Actors.CanFight(actor)) report.Fit++;
}
m_LastSampleAt = now;
return report;
}
void Clear()
{
m_Group = null; m_Faction = null; m_SourceId = ""; m_Strength = 0; m_Deaths = 0;
m_Bound = false; m_LastSampleAt = -1;
m_Members.Clear(); m_Dead.Clear(); m_Released.Clear(); m_Agents.Clear();
}
}
+37
View File
@@ -0,0 +1,37 @@
// A radio order snapshots an inference. It never refreshes the source sighting.
class ERR_HQTask
{
bool ReleaseOnly;
ref ERR_HQTask ReplacesTask;
string WatchSourceId;
int WatchMessageId;
int WatchRequestId;
bool Investigation() { return !WatchSourceId.IsEmpty(); }
int BeliefId;
int BeliefRevision;
ERR_HQPursuitRole Role;
vector Destination;
float QueuedAt;
float ExpiresAt;
float ArrivedAt = -1;
bool SweepQueued;
int ApproachBearing = -1;
string Name()
{
if (Investigation()) return "investigate missing patrol";
if (Role == ERR_HQPursuitRole.INTERCEPT) return "intercept";
if (Role == ERR_HQPursuitRole.CONTAIN) return "contain";
return "search";
}
}
// Finite per-track pacing, separate from belief confidence and report freshness.
class ERR_HQHuntWindow
{
string WatchSourceId;
int WatchMessageId;
int WatchRequestId;
bool Investigation() { return !WatchSourceId.IsEmpty(); }
int BeliefId;
float NextHunt;
}
+172
View File
@@ -0,0 +1,172 @@
// A local status snapshot is not HQ knowledge until its radio route delivers.
// Count includes living unconscious members; only explicit observed deaths add
// ConfirmedLosses. Neither a silent radio nor an absent member proves a death.
class ERR_HQStatusReport
{
string SourceId;
int MessageId;
vector Position;
int Strength;
int Count;
int Fit;
int ConfirmedLosses;
float ObservedAt = -1;
}
class ERR_HQWatchRules
{
static bool Valid(ERR_HQStatusReport report, float now)
{
if (!report || report.SourceId.IsEmpty() || report.SourceId.Length() > 96 || report.MessageId <= 0)
return false;
if (!ERR_HQPursuitRules.PositionValid(report.Position) || !ERR_HQPursuitRules.Finite(report.ObservedAt)
|| !ERR_HQPursuitRules.Finite(now) || report.ObservedAt < 0 || now < report.ObservedAt
|| now - report.ObservedAt > 120000) return false;
return report.Strength > 0 && report.Strength <= 32 && report.Count >= 0
&& report.Count <= report.Strength && report.Fit >= 0 && report.Fit <= report.Count
&& report.ConfirmedLosses >= 0 && report.ConfirmedLosses <= report.Strength - report.Count;
}
static bool RequestDue(float observedAt, float askedAt, float nextAttempt, bool closed, float now)
{
return !closed && ERR_HQPursuitRules.Finite(now) && observedAt >= 0 && askedAt < 0
&& now >= nextAttempt && now - observedAt >= 360000;
}
static bool InvestigationDue(float askedAt, float nextAttempt, bool closed, bool investigating, float now)
{
return !closed && !investigating && ERR_HQPursuitRules.Finite(now) && askedAt >= 0
&& now >= nextAttempt && now - askedAt >= 30000;
}
static int SquadLimit(int baseline, int confirmedLosses)
{
if (baseline <= 0) return 0;
if (baseline >= 8) return 8;
// Confirmed losses increase the shared pool only. Individual hunts still
// obey the independent pursuit cap of three and actual force availability.
int losses = Math.Clamp(confirmedLosses, 0, 4096);
int bonus = losses / 2;
return Math.Min(8, baseline + bonus);
}
}
// Detached HQ roster state: no entity/group handle and no live position lookup.
// Entries returned by Find/At are borrowed for HQ inspection, never mutated by
// observers. Only delivered Ingest and explicit request lifecycle methods write.
class ERR_HQWatchEntry
{
int RequestId;
string SourceId;
int MessageId;
vector Position;
int Strength;
int Count;
int Fit;
int ConfirmedLosses;
float ObservedAt = -1;
float LastDeliveredAt = -1;
float AskedAt = -1;
float NextAttempt;
bool Investigating;
bool Closed;
}
class ERR_HQWatch
{
protected ref array<ref ERR_HQWatchEntry> m_Entries = {};
protected ref map<string, ERR_HQWatchEntry> m_BySource = new map<string, ERR_HQWatchEntry>();
protected int m_ConfirmedLosses;
protected bool m_CapLogged;
int Count() { return m_Entries.Count(); }
ERR_HQWatchEntry Find(string sourceId) { return m_BySource.Get(sourceId); }
ERR_HQWatchEntry At(int index)
{
if (index < 0 || index >= m_Entries.Count()) return null;
return m_Entries[index];
}
int ConfirmedLosses() { return m_ConfirmedLosses; }
int SquadLimit(int baseline) { return ERR_HQWatchRules.SquadLimit(baseline, m_ConfirmedLosses); }
bool Ingest(ERR_HQStatusReport report, float now)
{
if (!ERR_HQWatchRules.Valid(report, now)) return false;
ERR_HQWatchEntry entry = Find(report.SourceId);
if (entry)
{
if (report.MessageId <= entry.MessageId || report.ObservedAt <= entry.ObservedAt
|| now < entry.LastDeliveredAt || report.Strength != entry.Strength
|| report.ConfirmedLosses < entry.ConfirmedLosses) return false;
}
else
{
// Keep closed identities as well: replay cannot resurrect a retired patrol
// or count the same cumulative casualty total again under its old source.
if (m_Entries.Count() >= 128)
{
if (!m_CapLogged) Print("Rereintroduced [watch]: 128-formation check-in cap reached.");
m_CapLogged = true;
return false;
}
entry = new ERR_HQWatchEntry();
entry.SourceId = report.SourceId;
m_Entries.Insert(entry);
m_BySource.Set(entry.SourceId, entry);
}
m_ConfirmedLosses += report.ConfirmedLosses - entry.ConfirmedLosses;
entry.MessageId = report.MessageId;
entry.Position = report.Position;
entry.Strength = report.Strength;
entry.Count = report.Count;
entry.Fit = report.Fit;
entry.ConfirmedLosses = report.ConfirmedLosses;
entry.ObservedAt = report.ObservedAt;
entry.LastDeliveredAt = now;
entry.AskedAt = -1;
entry.NextAttempt = 0;
entry.Investigating = false;
entry.Closed = entry.ConfirmedLosses == entry.Strength;
return true;
}
bool RequestDue(string sourceId, float now)
{
ERR_HQWatchEntry entry = Find(sourceId);
return entry && ERR_HQWatchRules.RequestDue(entry.ObservedAt, entry.AskedAt,
entry.NextAttempt, entry.Closed, now);
}
// Begin the deadline on HQ request attempt, including no route to a silent
// patrol. A missing downlink cannot prevent an investigation indefinitely.
bool MarkRequested(string sourceId, int statusMessageId, float now)
{
ERR_HQWatchEntry entry = Find(sourceId);
if (!entry || entry.MessageId != statusMessageId || entry.RequestId >= 2147483646
|| !RequestDue(sourceId, now)) return false;
entry.RequestId++;
entry.AskedAt = now;
return true;
}
bool InvestigationDue(string sourceId, float now)
{
ERR_HQWatchEntry entry = Find(sourceId);
return entry && ERR_HQWatchRules.InvestigationDue(entry.AskedAt, entry.NextAttempt,
entry.Closed, entry.Investigating, now);
}
bool MarkInvestigating(string sourceId, int statusMessageId, int requestId, float now)
{
ERR_HQWatchEntry entry = Find(sourceId);
if (!entry || entry.MessageId != statusMessageId || entry.RequestId != requestId
|| !InvestigationDue(sourceId, now)) return false;
entry.Investigating = true;
return true;
}
bool FinishInvestigation(string sourceId, int statusMessageId, int requestId, float now)
{
ERR_HQWatchEntry entry = Find(sourceId);
if (!entry || entry.MessageId != statusMessageId || entry.RequestId != requestId || !entry.Investigating
|| !ERR_HQPursuitRules.Finite(now) || now < entry.AskedAt + 30000) return false;
entry.Investigating = false;
entry.AskedAt = -1;
entry.NextAttempt = now + 180000;
return true;
}
void Clear()
{
m_Entries.Clear(); m_BySource.Clear(); m_ConfirmedLosses = 0; m_CapLogged = false;
}
}
@@ -10,7 +10,7 @@ class ERR_MortarPrediction
static bool Fresh(ERR_HQReport report, float now)
{
return report && report.Target && !report.Distress && now >= report.ObservedAt && now - report.ObservedAt <= MAX_REPORT_AGE_MS && report.TravelMode != "aircraft";
return report && report.Target && !report.Distress && now >= report.ObservedAt && now - report.ObservedAt <= MAX_REPORT_AGE_MS && report.TravelMode != "aircraft" && report.TravelMode != "body" && report.TravelMode != "dead body";
}
static bool Reject(string reason)
@@ -90,6 +90,7 @@ class ERR_MortarPrediction
FailureReason = "";
if (!report) return Reject("target report missing");
if (report.Distress) return Reject("distress report cannot authorize fire");
if (report.TravelMode == "body" || report.TravelMode == "dead body") return Reject("body evidence cannot authorize fire");
if (now < report.ObservedAt) return Reject("observation timestamp is in the future");
if (now - report.ObservedAt > MAX_REPORT_AGE_MS) return Reject("observation older than 60s");
float seconds = (now - report.ObservedAt) / 1000 + flightSeconds;
@@ -10,9 +10,19 @@ modded class ERR_HQ
if (unit.Group != group) continue;
if (unit.Assignment || unit.PendingOrder || unit.Waypoint || !unit.Eligible(m_Mode)) return false;
ERR_HQReport order = new ERR_HQReport();
order.Target = group;
order.Source = group;
order.Position = group.GetOrigin() + "40 0 0";
order.ObservedAt = GetGame().GetWorld().GetWorldTime();
order.MessageId = m_Journal.Message();
Accept(order, order.ObservedAt);
ERR_HQBelief belief = m_Memory.Get(group);
if (!belief) return false;
ERR_HQTask task = new ERR_HQTask();
task.BeliefId = belief.BeliefId; task.BeliefRevision = belief.BeliefRevision;
task.Destination = order.Position; task.QueuedAt = order.ObservedAt;
task.ExpiresAt = order.ObservedAt + 120000;
unit.PendingTask = task;
unit.PendingOrder = order;
DeliverOrder(unit, order.ObservedAt);
search = unit.Waypoint;