Build independent Everon successor foundation and native regressions

This commit is contained in:
2026-09-19 01:37:41 +02:00
commit 2550bc0423
155 changed files with 15098 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
class ERR_HQUnit
{
int Id;
SCR_AIGroup Group;
SCR_AIGroupUtilityComponent Utility;
IEntity Radio;
float NextReport;
ref ERR_HQRoute Uplink;
ref ERR_HQReport PendingReport;
ref ERR_HQRoute Downlink;
ref ERR_HQReport PendingOrder;
ref ERR_HQReport Assignment;
AIWaypoint Waypoint;
bool Eligible(ERR_GameMode mode)
{
if (!Group || !Utility || Group.GetPlayerCount(true) > 0 || Group.IsSlave() || Group.GetFaction() != mode.EnemyFaction()) return false;
array<AIAgent> agents = {};
Group.GetAgents(agents);
bool survivor;
foreach (AIAgent agent : agents)
{
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue;
survivor = true;
if (CompartmentAccessComponent.GetVehicleIn(agent.GetControlledEntity())) return false;
}
return survivor;
}
void Release()
{
if (Waypoint)
{
if (Group) Group.RemoveWaypoint(Waypoint);
SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
}
Waypoint = null;
Assignment = null;
PendingOrder = null;
Downlink = null;
}
}
class ERR_HQ : ERR_CommandService
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_HQUnit> m_Units = {};
protected ref array<ref ERR_HQReport> m_Contacts = {};
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; }
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());
}
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;
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;
int phase = unit.Id % 5;
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
m_Units.Insert(unit);
m_Journal.Write("REGISTER", string.Format("group %1", unit.Id));
}
override void UnregisterGroup(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
unit.Release();
unit.Group = null;
unit.Utility = null;
}
}
protected int Assigned()
{
int total;
foreach (ERR_HQUnit unit : m_Units) if (unit.Assignment || unit.PendingOrder) total++;
return total;
}
protected void IndexRadios()
{
m_Network.Clear();
foreach (ERR_HQUnit unit : m_Units)
{
unit.Radio = null;
if (!unit.Group || unit.Group.GetPlayerCount(true) > 0 || unit.Group.GetFaction() != m_Mode.EnemyFaction()) continue;
array<AIAgent> agents = {};
unit.Group.GetAgents(agents);
int best;
foreach (AIAgent agent : agents)
{
if (!agent) continue;
IEntity actor = agent.GetControlledEntity();
int capability = ERR_HQRadio.Capability(actor);
if (capability <= 0) continue;
m_Network.Add(actor, unit.Group, unit.Id, capability);
if (capability > best) { unit.Radio = actor; best = capability; }
}
}
m_Network.Build();
}
protected void Observe(ERR_HQUnit unit, float now)
{
if (!unit.Utility || !unit.Radio || unit.Uplink || now < unit.NextReport) return;
unit.NextReport = now + m_Mode.Settings().Get("reportIntervalSeconds") * 1000;
array<ref ERR_HQVisualContact> samples = {};
ERR_HQObservation.Gather(unit.Utility, now, samples);
if (samples.IsEmpty()) return;
ERR_HQVisualContact seen = samples[0];
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!route) return;
ERR_HQReport report = new ERR_HQReport();
report.MessageId = m_Journal.Message();
report.Target = seen.Entity;
report.Source = unit.Group;
report.Position = seen.Position;
report.ObservedAt = seen.ObservedAt;
ERR_HQObservation.Describe(report, samples, now);
route.MessageId = report.MessageId;
route.Payload = report.Description(now);
unit.PendingReport = report;
unit.Uplink = route;
m_Journal.Write("REPORT", route.Payload, report.MessageId);
}
protected void Accept(ERR_HQReport report, float now)
{
if (!report || !ERR_MissionRules.ContactFresh(report.ObservedAt, now)) return;
for (int i = 0; i < m_Contacts.Count(); i++)
{
ERR_HQReport previous = m_Contacts[i];
if (previous.Target != report.Target) continue;
if (previous.ObservedAt >= report.ObservedAt) return;
float seconds = (report.ObservedAt - previous.ObservedAt) / 1000;
if (seconds > 0 && seconds < 30 && vector.DistanceXZ(previous.Position, report.Position) < 500)
{
report.Velocity = (report.Position - previous.Position) / seconds;
report.HasVelocity = report.Velocity.Length() <= 100;
}
m_Contacts[i] = report.Snapshot();
return;
}
if (m_Contacts.Count() >= 64) m_Contacts.Remove(0);
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; }
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_FactionCatalog.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: %2", unit.Id, order.Description(now)), order.MessageId);
}
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++;
}
}
}
override void Tick(float now)
{
if (!Replication.IsServer()) return;
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);
for (int i = m_Units.Count() - 1; i >= 0; i--)
{
ERR_HQUnit unit = m_Units[i];
if (unit.Uplink)
{
int delivered = unit.Uplink.Advance(now, m_Journal);
if (delivered == 1) Accept(unit.PendingReport, now);
if (delivered != 0) { unit.Uplink = null; unit.PendingReport = 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 (!unit.Group && !unit.Uplink) { unit.Release(); m_Units.Remove(i); continue; }
Observe(unit, now);
}
if (now >= m_NextAssignment) { Assign(now); m_NextAssignment = now + 5000; }
m_Journal.Flush();
}
override void Stop()
{
foreach (ERR_HQUnit unit : m_Units) unit.Release();
m_Units.Clear();
m_Contacts.Clear();
m_Network.Clear();
m_Journal.Write("STOP", "HQ stopped; tactical orders released");
m_Journal.Flush();
}
}
modded class ERR_GameMode
{
protected override ERR_CommandService CreateCommandService() { return new ERR_HQ(this); }
}
+101
View File
@@ -0,0 +1,101 @@
// Server-owned audit stream. The file contains every event; chat reads a bounded
// recent-history window with explicit gap notices for clients that fall behind.
class ERR_HQJournal
{
protected ref array<string> m_Recent = {};
protected ref array<string> m_Pending = {};
protected int m_Sequence;
protected int m_Message;
protected static int s_Part;
protected static int s_Session;
protected string m_Session;
void ERR_HQJournal()
{
s_Session++;
m_Session = string.Format("%1-%2", System.GetUnixTime(), s_Session);
}
protected float m_RetryAt;
protected int m_Lost;
string Path()
{
if (s_Part == 0)
return "$logs:rereintroduced-hq.log";
return "$logs:rereintroduced-hq-" + s_Part.ToString() + ".log";
}
int Message()
{
return ++m_Message;
}
int Sequence()
{
return m_Sequence;
}
void Write(string kind, string message, int messageId = 0)
{
if (!Replication.IsServer())
return;
float seconds = GetGame().GetWorld().GetWorldTime() / 1000;
string line = string.Format("#%1 t=%2s M%3 %4 %5", ++m_Sequence, Math.Round(seconds * 10) / 10.0, messageId, kind, message) + " [session=" + m_Session + "]";
if (m_Recent.Count() >= 300)
m_Recent.Remove(0);
m_Recent.Insert(line);
if (m_Pending.Count() < 4096)
m_Pending.Insert(line);
else
m_Lost++;
}
void Read(int after, out array<string> lines, out int cursor, int limit = 8)
{
lines.Clear();
cursor = Math.Clamp(after, 0, m_Sequence);
int first = m_Sequence - m_Recent.Count();
if (cursor < first)
{
lines.Insert(string.Format("HQ log: %1 older entries are outside chat history; see %2", first - cursor, Path()));
cursor = first;
}
int characters;
for (int i = cursor - first; i < m_Recent.Count() && lines.Count() < Math.Clamp(limit, 1, 40); i++)
{
// Bound each menu RPC without skipping unread records. Chat still
// requests eight; the live menu can drain a busy journal faster.
if (characters + m_Recent[i].Length() > 16000 && !lines.IsEmpty())
break;
lines.Insert(m_Recent[i]);
characters += m_Recent[i].Length();
cursor++;
}
}
void Flush()
{
if (m_Pending.IsEmpty() || !Replication.IsServer())
return;
float now = GetGame().GetWorld().GetWorldTime();
if (now < m_RetryAt)
return;
FileHandle file = FileIO.OpenFile(Path(), FileMode.APPEND);
if (!file)
{
m_RetryAt = now + 30000;
PrintFormat("Escapists AI [HQ log]: cannot open %1; retry in 30s, chat history remains available", Path(), level: LogLevel.ERROR);
return;
}
foreach (string line : m_Pending)
file.WriteLine(line);
if (m_Lost > 0)
file.WriteLine(string.Format("HQ log: %1 entries lost while file was unavailable and buffer full", m_Lost));
bool rotate = file.GetLength() >= 5242880;
file.Close();
m_Pending.Clear();
m_Lost = 0;
if (rotate)
s_Part++;
}
}
+241
View File
@@ -0,0 +1,241 @@
// Radio propagation is simulated using actual living carriers and inventory.
// A manpack is the HQ uplink; short-range hops are limited to 500 m and three
// retransmissions. No virtual relay is fabricated when the network is broken.
class ERR_HQRadioNode
{
IEntity Carrier;
SCR_AIGroup Group;
int UnitId;
int Capability;
protected float m_CheckedAt = -1;
protected int m_LiveCapability;
bool Valid(bool manpack = false)
{
ERR_GameMode factions = ERR_GameMode.Get();
if (!Carrier || !Group || Group.GetPlayerCount(true) > 0 || !factions || Group.GetFaction() != factions.EnemyFaction() || ERR_Actors.Group(Carrier) != Group)
return false;
int required = 1;
if (manpack)
required = 2;
float now = GetGame().GetWorld().GetWorldTime();
if (m_CheckedAt < 0 || now < m_CheckedAt || now - m_CheckedAt >= 1000)
{
m_CheckedAt = now;
m_LiveCapability = ERR_HQRadio.Capability(Carrier);
}
return m_LiveCapability >= required;
}
string Label()
{
return "unit " + UnitId.ToString();
}
}
class ERR_HQRoute
{
ref array<ref ERR_HQRadioNode> Nodes = {};
bool Downlink;
int Cursor;
bool Sending;
float Due;
vector SentFrom;
float StartedAt = -1;
int MessageId;
string Payload;
bool Failed;
// -1 failed, 0 travelling, 1 delivered. Each hop really gates delivery.
int Advance(float now, ERR_HQJournal journal)
{
if (Failed)
return -1;
if (StartedAt < 0)
{
StartedAt = now;
if (Downlink)
Cursor = -1; // first leg is HQ to the manpack
}
if (now - StartedAt > 30000)
return Fail(journal, "route timed out");
int target = Cursor + 1;
if (Sending)
{
if (now < Due)
return 0;
if (target < Nodes.Count())
{
if (!Nodes[target].Valid(Downlink && target == 0))
return Fail(journal, "receiving carrier lost radio, died or changed ownership");
if (Cursor >= 0 && vector.DistanceXZ(SentFrom, Nodes[target].Carrier.GetOrigin()) > 500)
return Fail(journal, "receiver moved beyond the 500m link");
journal.Write("RECEIVE", Nodes[target].Label() + " received from " + SenderLabel(), MessageId);
}
else
journal.Write("RECEIVE", "HQ received from " + SenderLabel(), MessageId);
Cursor++;
Sending = false;
if ((!Downlink && Cursor >= Nodes.Count()) || (Downlink && Cursor == Nodes.Count() - 1))
return 1;
target = Cursor + 1;
}
if (Nodes.IsEmpty())
return Fail(journal, "no physical radio route");
if (Cursor >= 0 && !Nodes[Cursor].Valid(!Downlink && Cursor == Nodes.Count() - 1))
return Fail(journal, "transmitting carrier lost radio, died or changed ownership");
string recipient = "HQ";
string link = "long-range";
if (target < Nodes.Count())
{
if (!Nodes[target].Valid(Downlink && target == 0))
return Fail(journal, "next relay unavailable");
recipient = Nodes[target].Label();
if (Cursor >= 0)
{
SentFrom = Nodes[Cursor].Carrier.GetOrigin();
float distance = vector.DistanceXZ(SentFrom, Nodes[target].Carrier.GetOrigin());
if (distance > 500)
return Fail(journal, "next relay beyond 500m");
link = Math.Round(distance).ToString() + "m short-range";
}
}
journal.Write("TRANSMIT", SenderLabel() + " -> " + recipient + " (" + link + ")", MessageId);
Sending = true;
Due = now + 2000;
return 0;
}
protected string SenderLabel()
{
if (Cursor < 0)
return "HQ";
return Nodes[Cursor].Label();
}
protected int Fail(ERR_HQJournal journal, string reason)
{
Failed = true;
journal.Write("LOST", reason + "; " + Payload, MessageId);
return -1;
}
}
class ERR_HQNetwork
{
protected ref array<ref ERR_HQRadioNode> m_Nodes = {};
protected ref array<int> m_Parents = {};
protected ref array<int> m_Depths = {};
protected ref map<IEntity, int> m_Indices = new map<IEntity, int>();
protected ref map<string, ref array<int>> m_Cells = new map<string, ref array<int>>();
bool Overflow;
void Clear()
{
m_Nodes.Clear();
m_Parents.Clear();
m_Depths.Clear();
m_Indices.Clear();
m_Cells.Clear();
Overflow = false;
}
protected string Cell(int x, int z)
{
return string.Format("%1:%2", x, z);
}
void Add(IEntity carrier, SCR_AIGroup group, int unitId, int capability)
{
if (m_Indices.Contains(carrier))
return;
if (m_Nodes.Count() >= 2048)
{
Overflow = true;
return;
}
ERR_HQRadioNode node = new ERR_HQRadioNode();
node.Carrier = carrier;
node.Group = group;
node.UnitId = unitId;
node.Capability = capability;
int index = m_Nodes.Count();
m_Nodes.Insert(node);
m_Indices.Insert(carrier, index);
m_Parents.Insert(-2);
m_Depths.Insert(99);
vector position = carrier.GetOrigin();
string key = Cell(Math.Floor(position[0] / 500), Math.Floor(position[2] / 500));
array<int> cell = m_Cells.Get(key);
if (!cell)
{
cell = {};
m_Cells.Insert(key, cell);
}
cell.Insert(index);
}
void Build()
{
array<int> queue = {};
int checks;
for (int root = 0; root < m_Nodes.Count(); root++)
{
if (m_Nodes[root].Capability == 2)
{
m_Parents[root] = -1;
m_Depths[root] = 0;
queue.Insert(root);
}
}
for (int head = 0; head < queue.Count(); head++)
{
int from = queue[head];
if (m_Depths[from] >= 3)
continue;
vector position = m_Nodes[from].Carrier.GetOrigin();
int x = Math.Floor(position[0] / 500);
int z = Math.Floor(position[2] / 500);
for (int dx = -1; dx <= 1; dx++)
{
for (int dz = -1; dz <= 1; dz++)
{
array<int> neighbors = m_Cells.Get(Cell(x + dx, z + dz));
if (!neighbors)
continue;
foreach (int candidate : neighbors)
{
if (++checks > 100000)
{
Overflow = true;
return;
}
if (m_Parents[candidate] != -2 || vector.DistanceXZ(position, m_Nodes[candidate].Carrier.GetOrigin()) > 500)
continue;
m_Parents[candidate] = from;
m_Depths[candidate] = m_Depths[from] + 1;
queue.Insert(candidate);
}
}
}
}
}
ERR_HQRoute Route(IEntity caller, bool downlink = false)
{
int index;
if (!m_Indices.Find(caller, index) || m_Parents[index] == -2)
return null;
ERR_HQRoute route = new ERR_HQRoute();
route.Downlink = downlink;
while (index >= 0)
{
if (downlink)
route.Nodes.InsertAt(m_Nodes[index], 0);
else
route.Nodes.Insert(m_Nodes[index]);
index = m_Parents[index];
}
return route;
}
}
@@ -0,0 +1,199 @@
// These samples exist only at intake. HQ never revisits their entity loadouts.
class ERR_HQVisualContact
{
IEntity Entity;
vector Position;
float ObservedAt;
bool Escapist;
string Travel = "unknown";
string Weapon = "unknown";
}
class ERR_HQObservation
{
// Require current native line of sight before reading visible equipment.
static bool IsClearSighting(float age, float traceFraction, float exposure)
{
return age >= 0 && age <= 0.5 && traceFraction >= 1 && exposure > 0;
}
static bool CanIdentifyWeapon(float distance, float exposure)
{
return distance >= 0 && distance <= 150 && exposure >= 0.5;
}
static string WeaponCategory(EWeaponType type)
{
switch (type)
{
case EWeaponType.WT_HANDGUN: return "pistol";
case EWeaponType.WT_RIFLE: return "rifle";
case EWeaponType.WT_SNIPERRIFLE: return "sniper rifle";
case EWeaponType.WT_MACHINEGUN: return "machine gun";
case EWeaponType.WT_ROCKETLAUNCHER: return "rocket launcher";
case EWeaponType.WT_GRENADELAUNCHER: return "grenade launcher";
case EWeaponType.WT_FRAGGRENADE: return "grenade";
case EWeaponType.WT_SMOKEGRENADE: return "smoke grenade";
}
return "unidentified weapon";
}
static ERR_HQVisualContact Capture(BaseTarget target, float now)
{
if (!target || target.GetTargetCategory() != ETargetCategory.ENEMY || !IsClearSighting(target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure()))
return null;
DamageManagerComponent damage = target.GetDamageManagerComponent();
if (damage && damage.IsDestroyed())
return null;
IEntity entity = target.GetTargetEntity();
if (!entity)
return null;
ERR_HQVisualContact seen = new ERR_HQVisualContact();
seen.Entity = entity;
seen.Position = target.GetLastSeenPosition();
seen.ObservedAt = now - target.GetTimeSinceSeen() * 1000;
SCR_ChimeraCharacter character = SCR_ChimeraCharacter.Cast(entity);
if (character)
{
if (GetGame().GetPlayerManager())
seen.Escapist = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(entity) > 0;
seen.Travel = "on foot";
if (CompartmentAccessComponent.GetVehicleIn(entity))
seen.Travel = "in vehicle";
// Read only the drawn weapon; never enumerate weapons or inventory.
if (CanIdentifyWeapon(target.GetDistance(), target.GetExposure()))
{
CharacterControllerComponent controller = CharacterControllerComponent.Cast(entity.FindComponent(CharacterControllerComponent));
if (controller)
{
IEntity heldItem = controller.GetCurrentItemInHands();
BaseWeaponComponent weapon;
if (heldItem)
weapon = BaseWeaponComponent.Cast(heldItem.FindComponent(BaseWeaponComponent));
seen.Weapon = "none drawn";
if (weapon)
seen.Weapon = WeaponCategory(weapon.GetWeaponType());
}
}
}
else if (Vehicle.Cast(entity))
{
seen.Travel = "vehicle";
if (target.GetUnitType() == EAIUnitType.UnitType_Aircraft)
seen.Travel = "aircraft";
}
return seen;
}
static void Gather(SCR_AIGroupUtilityComponent utility, float now, out array<ref ERR_HQVisualContact> samples)
{
samples.Clear();
if (!utility)
return;
array<BaseTarget> visible = {};
utility.ERR_HQGetVisualTargets(visible);
foreach (BaseTarget target : visible)
{
ERR_HQVisualContact seen = Capture(target, now);
if (!seen)
continue;
ERR_HQVisualContact existing;
foreach (ERR_HQVisualContact sample : samples)
{
if (sample.Entity == seen.Entity)
{
existing = sample;
break;
}
}
if (!existing)
{
if (samples.Count() < 64)
samples.Insert(seen);
}
else if (seen.ObservedAt > existing.ObservedAt || (seen.ObservedAt == existing.ObservedAt && existing.Weapon == "unknown" && seen.Weapon != "unknown"))
{
existing.Position = seen.Position;
existing.ObservedAt = seen.ObservedAt;
existing.Travel = seen.Travel;
existing.Weapon = seen.Weapon;
}
}
}
static void Describe(ERR_HQReport report, array<ref ERR_HQVisualContact> samples, float now)
{
// New observations must not silently inherit old equipment/travel details.
report.SeenEscapists = -1;
report.TravelMode = "unknown";
report.SeenWeapons = "unknown";
report.DetailsObservedAt = -1;
if (report.Distress)
return;
ERR_HQVisualContact anchor;
foreach (ERR_HQVisualContact sample : samples)
{
if (sample.Entity == report.Target && now - sample.ObservedAt >= 0 && now - sample.ObservedAt <= 500)
{
anchor = sample;
break;
}
}
if (!anchor)
return;
report.DetailsObservedAt = anchor.ObservedAt;
report.TravelMode = anchor.Travel;
report.SeenWeapons = anchor.Weapon;
array<IEntity> counted = {};
array<string> weapons = {};
string travel;
foreach (ERR_HQVisualContact sighting : samples)
{
if (!sighting.Escapist || !sighting.Entity || counted.Contains(sighting.Entity) || now - sighting.ObservedAt < 0 || now - sighting.ObservedAt > 500 || vector.DistanceXZ(anchor.Position, sighting.Position) > 100)
continue;
counted.Insert(sighting.Entity);
if (travel.IsEmpty())
travel = sighting.Travel;
else if (travel != sighting.Travel)
travel = "mixed travel";
if (sighting.Weapon != "unknown" && !weapons.Contains(sighting.Weapon) && weapons.Count() < 4)
weapons.Insert(sighting.Weapon);
}
if (counted.IsEmpty())
return; // A visible vehicle does not reveal its hidden occupants.
report.SeenEscapists = counted.Count();
// A vehicle anchor remains a vehicle report, even with people near it.
if (anchor.Escapist)
report.TravelMode = travel;
report.SeenWeapons = "unknown";
if (!weapons.IsEmpty())
{
report.SeenWeapons = weapons[0];
for (int i = 1; i < weapons.Count(); i++)
report.SeenWeapons += ", " + weapons[i];
}
}
}
modded class SCR_AIGroupUtilityComponent
{
void ERR_HQGetVisualTargets(out array<BaseTarget> targets)
{
targets.Clear();
foreach (SCR_AIInfoComponent info : m_aInfoComponents)
{
if (!info || !info.m_Perception)
continue;
AIAgent observer = AIAgent.Cast(info.GetOwner());
if (!observer || !ERR_Actors.CanFight(observer.GetControlledEntity()))
continue;
array<BaseTarget> own = {};
info.m_Perception.GetTargetsList(own, ETargetCategory.ENEMY);
foreach (BaseTarget target : own)
{
if (target && ERR_HQObservation.IsClearSighting(target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure()))
targets.Insert(target);
}
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// A radio's category is protected in the stock gadget component.
modded class SCR_RadioComponent
{
bool ERR_IsHQManpack()
{
return m_iRadioCategory == ERadioCategory.MANPACK;
}
}
class ERR_HQRadio
{
// 0: no radio, 1: basic radio, 2: manpack. Inventory is checked live.
static int Capability(IEntity character)
{
if (!ERR_Actors.CanFight(character))
return 0;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(character.FindComponent(InventoryStorageManagerComponent));
if (!inventory)
return 0;
array<IEntity> items = {};
inventory.GetItems(items);
int result;
foreach (IEntity item : items)
{
if (!item || !item.FindComponent(BaseRadioComponent))
continue;
result = 1;
SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
if (gadget && gadget.ERR_IsHQManpack())
return 2;
}
return result;
}
}
// HQ uses entity identity only for matching observations, never for its origin.
// Visible details are captured at intake and copied as scalar snapshots.
// Reports and orders contain immutable observed coordinates and timestamps.
class ERR_HQReport
{
int MessageId;
IEntity Target;
SCR_AIGroup Source;
vector Position;
vector Velocity;
float ObservedAt = -1;
float LastSent = -60000;
float PerceptionStamp = -1;
bool HasVelocity;
bool Engaged;
bool Distress;
int SeenEscapists = -1;
string TravelMode = "unknown";
string SeenWeapons = "unknown";
float DetailsObservedAt = -1;
ERR_HQReport Snapshot()
{
ERR_HQReport copy = new ERR_HQReport();
copy.MessageId = MessageId;
copy.Target = Target;
copy.Source = Source;
copy.Position = Position;
copy.Velocity = Velocity;
copy.ObservedAt = ObservedAt;
copy.Engaged = Engaged;
copy.Distress = Distress;
copy.HasVelocity = HasVelocity;
copy.SeenEscapists = SeenEscapists;
copy.TravelMode = TravelMode;
copy.SeenWeapons = SeenWeapons;
copy.DetailsObservedAt = DetailsObservedAt;
return copy;
}
string TravelDirection()
{
if (!HasVelocity)
return "unknown";
if (Velocity.Length() < 0.5)
return "stationary";
float x = Math.AbsFloat(Velocity[0]);
float z = Math.AbsFloat(Velocity[2]);
string northSouth = "N";
string eastWest = "E";
if (Velocity[2] < 0)
northSouth = "S";
if (Velocity[0] < 0)
eastWest = "W";
if (x < z * 0.414214)
return northSouth;
if (z < x * 0.414214)
return eastWest;
return northSouth + eastWest;
}
string Description(float now, bool brief = false)
{
if (Distress)
return string.Format("Distress at friendly X=%1 Z=%2; enemy details unknown (%3s ago)", Math.Round(Position[0]), Math.Round(Position[2]), Math.Round(Math.Max(0, now - ObservedAt) / 1000));
string count = "count unknown";
if (SeenEscapists >= 0)
count = "at least " + SeenEscapists.ToString() + " spotted nearby";
string motion = "speed/direction unknown";
if (HasVelocity)
{
float speed = Math.Round(Velocity.Length() * 10) / 10.0;
float azimuth = Math.Atan2(Velocity[0], Velocity[2]) * Math.RAD2DEG;
if (azimuth < 0)
azimuth += 360;
int heading = Math.Round(azimuth);
motion = string.Format("%1 km/h, azimuth %2 degrees (%3)", Math.Round(speed * 3.6), heading % 360, TravelDirection());
}
string details = count + "; " + TravelMode + "; " + motion + "; seen weapons: " + SeenWeapons;
if (brief)
return details;
string velocity = "unknown";
if (HasVelocity)
velocity = string.Format("[%1,%2] m/s", Math.Round(Velocity[0] * 10) / 10.0, Math.Round(Velocity[2] * 10) / 10.0);
return string.Format("X=%1 Z=%2: %3; vector X/Z=%4 (%5s ago)", Math.Round(Position[0]), Math.Round(Position[2]), details, velocity, Math.Round(Math.Max(0, now - ObservedAt) / 1000));
}
static vector Predict(vector position, vector velocity, float seconds)
{
vector offset = velocity * Math.Clamp(seconds, 0, 20);
offset[1] = 0;
if (offset.Length() > 300)
offset = offset.Normalized() * 300;
return position + offset;
}
int Priority(float now)
{
if (Distress)
return 0;
if (now - ObservedAt <= 15000)
{
if (Engaged)
return 3;
return 2;
}
return 1;
}
}
@@ -0,0 +1,31 @@
// An idle rotor or zero speed alone is normal during takeoff, landing and reload.
// Confirm prolonged grounding, but recognize actual loss of flight capability.
class ERR_HelicopterAvailability
{
protected float m_fGroundedSince = -1;
string Check(float now, float spawnedAt, bool destroyed, bool disabled, bool livingPilot, bool playerControlled, bool servicing, float height, float speed)
{
if (destroyed) return "destroyed";
if (now - spawnedAt < 20000)
{
m_fGroundedSince = -1;
return "";
}
if (disabled) return "engine, rotor or airframe disabled";
if (playerControlled)
{
m_fGroundedSince = -1;
return "";
}
if (!livingPilot) return "no living pilot";
if (servicing || height > 5 || speed > 2)
{
m_fGroundedSince = -1;
return "";
}
if (m_fGroundedSince < 0) m_fGroundedSince = now;
if (now - m_fGroundedSince >= 60000) return "grounded for 60 seconds";
return "";
}
}
@@ -0,0 +1,131 @@
// All target coordinates come from delivered HQ observations, never a player's
// current transform. Road projection stops at an unknown junction/road end.
class ERR_MortarPrediction
{
// Read immediately after a failed synchronous prediction; no extra world scan.
static string FailureReason;
// Includes observation uplink, HQ downlink and native aiming. All stages
// retain the original observation time; radio delivery never renews it.
static const float MAX_REPORT_AGE_MS = 60000;
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";
}
static bool Reject(string reason)
{
FailureReason = reason;
return false;
}
static vector ClosestOnSegment(vector point, vector start, vector end)
{
vector segment = end - start;
segment[1] = 0;
vector offset = point - start;
offset[1] = 0;
float lengthSq = segment.LengthSq();
if (lengthSq < 0.01)
return start;
return vector.Lerp(start, end, Math.Clamp(vector.Dot(offset, segment) / lengthSq, 0, 1));
}
static bool FollowRoad(array<vector> points, vector observed, vector velocity, float seconds, out vector impact)
{
if (!points || points.Count() < 2)
return Reject("road polyline has fewer than two points");
int segment = -1;
float closest = 20;
vector projected;
for (int i = 0; i < points.Count() - 1; i++)
{
vector candidate = ClosestOnSegment(observed, points[i], points[i + 1]);
float distance = vector.DistanceXZ(candidate, observed);
if (distance < closest)
{
closest = distance;
segment = i;
projected = candidate;
}
}
if (segment < 0)
return Reject("observed vehicle is not within 20m of a road segment");
vector forward = points[segment + 1] - points[segment];
forward[1] = 0;
forward.Normalize();
vector travel = velocity;
travel[1] = 0;
float speed = travel.Length();
travel.Normalize();
float alignment = vector.Dot(forward, travel);
if (Math.AbsFloat(alignment) < 0.7)
return Reject("vehicle heading crosses road; alignment below 0.7");
int step = 1;
int next = segment + 1;
if (alignment < 0)
{
step = -1;
next = segment;
}
float remaining = speed * seconds;
vector position = projected;
while (next >= 0 && next < points.Count())
{
float length = vector.DistanceXZ(position, points[next]);
if (length >= remaining && length > 0.01)
{
impact = vector.Lerp(position, points[next], remaining / length);
return true;
}
remaining -= length;
position = points[next];
next += step;
}
return Reject("prediction reaches unknown road end/junction");
}
static bool Predict(ERR_HQReport report, float now, float flightSeconds, out vector impact)
{
FailureReason = "";
if (!report) return Reject("target report missing");
if (report.Distress) return Reject("distress report 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;
if (seconds < 0 || seconds > 90)
return Reject("observation age plus flight outside 0..90s");
if (report.TravelMode == "aircraft") return Reject("aircraft target unsupported");
if (report.TravelMode == "mixed travel") return Reject("mixed travel cannot be predicted");
impact = report.Position;
if (!report.HasVelocity || report.Velocity.Length() < 0.5)
return true;
if (report.Velocity.Length() * seconds > 1800)
return Reject("predicted travel exceeds 1800m");
bool vehicle = report.TravelMode == "vehicle" || report.TravelMode == "in vehicle";
if (vehicle)
{
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager())
return Reject("road network unavailable");
BaseRoad road;
float roadDistance;
world.GetRoadNetworkManager().GetClosestRoad(report.Position, road, roadDistance, skipNavlinks: true);
if (!road) return Reject("no road near reported vehicle position");
if (roadDistance > 20) return Reject("reported vehicle farther than 20m from road");
array<vector> points = {};
road.GetPoints(points);
return FollowRoad(points, report.Position, report.Velocity, seconds, impact);
}
impact += report.Velocity * seconds;
return true;
}
static bool ThreatensFriendly(vector impact, vector position, vector velocity, float flightSeconds)
{
velocity[1] = 0;
vector future = position + velocity * Math.Clamp(flightSeconds, 0, 90);
// Protect the entire predicted path, not only where a soldier is now.
return vector.DistanceXZ(impact, ClosestOnSegment(impact, position, future)) <= 100;
}
}
@@ -0,0 +1,16 @@
// One delivered HQ order drives a continuous native loading/firing cycle.
class ERR_MortarSalvo
{
static const int ROUNDS = 8;
static const float RADIUS_METRES = 40;
static const float TIMEOUT_MS = 120000;
static vector Offset(int site, int order, int shot)
{
// Stable throughout one ballistic solve; successive shells cover the disk
// instead of clustering on the same point or changing every iteration.
float radius = RADIUS_METRES * Math.Sqrt((shot + 1.0) / ROUNDS);
float angle = (site * 53 + order * 97 + shot * 137.5) * Math.DEG2RAD;
return Vector(Math.Sin(angle) * radius, 0, Math.Cos(angle) * radius);
}
}
+9
View File
@@ -0,0 +1,9 @@
GameProject {
ID "ERR_Ai"
GUID "5192138CB9D5B911"
TITLE "Escapists Rereintroduced - Ai"
Dependencies {
"58D0FB3206B6F859"
"2528466DE220D91C"
}
}