Capture current mission, AI, map and administration development

Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
@@ -0,0 +1,297 @@
// 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 (!ERR_Actors.CanFight(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 = {};
SCR_AIGroup LocalSource;
IEntity LocalWitness;
ref ERR_HQStatusSampler LocalRoster;
bool Downlink;
bool Originated;
float OriginDue = -1;
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");
// An originating report needs three seconds on its original physical
// caller before any network transmission. Later completed hops remain
// independent of that caller, and HQ downlinks do not repeat this call.
if (!Downlink && !Originated)
{
if (Nodes.IsEmpty() || !Nodes[0].Valid(Nodes.Count() == 1))
return Fail(journal, "originating call lost its living owned radio carrier");
if (LocalRoster && !ERR_HQLocalHandoff.Valid(LocalSource, LocalRoster, LocalWitness, Nodes[0]))
return Fail(journal, "local sighting handoff lost original squad member, recipient or 100m reach");
if (OriginDue < 0)
{
OriginDue = now + 3000;
journal.Write("CALL", Nodes[0].Label() + " originating report; three seconds before transmission", MessageId);
}
if (now < OriginDue) return 0;
Originated = true;
journal.Write("CALL COMPLETE", Nodes[0].Label() + " completed originating report", MessageId);
}
int target = Cursor + 1;
if (Sending)
{
// A transmission takes two seconds. Validate both endpoints on every
// observed tick; only delivery logging and cursor advancement wait for Due.
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))
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");
}
if (now < Due)
return 0;
if (target < Nodes.Count())
{
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;
protected int m_LocalChecks;
void Clear()
{
m_Nodes.Clear();
m_Parents.Clear();
m_Depths.Clear();
m_Indices.Clear();
m_Cells.Clear();
Overflow = false;
m_LocalChecks = 0;
}
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);
}
}
}
}
}
// At most 4096 local pairs across all reports per network rebuild. Indexes
// provide candidate routes only; current original actors/distance are checked.
ERR_HQRoute LocalRoute(SCR_AIGroup source, ERR_HQStatusSampler roster, array<IEntity> busy)
{
if (Overflow || !source || !roster || !roster.OwnsRoster(source) || !busy || busy.Count() > 256) return null;
int count = roster.InitialStrength();
if (count < 1 || count > 32) return null;
foreach (ERR_HQRadioNode node : m_Nodes)
{
if (!node || node.Group == source || busy.Contains(node.Carrier)) continue;
for (int i = 0; i < count; i++)
{
if (++m_LocalChecks > 4096) return null;
IEntity witness = roster.InitialMember(i);
if (!ERR_HQLocalHandoff.Valid(source, roster, witness, node)) continue;
ERR_HQRoute route = Route(node.Carrier);
if (!route) continue;
route.LocalSource = source; route.LocalWitness = witness; route.LocalRoster = roster;
return route;
}
}
return null;
}
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,4 @@
{
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "85e4d2cb27426c07b8631ca411f4b8f5c821f9f331c8c2719cbfa71b3687f08e",
"tests/hq_network_regression.py": "f7021b7bbdfdc2eefcdfade2bc4ad2108e1312cd07e8e09025783c626e7c8cae"
}
@@ -0,0 +1,294 @@
// 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 (!ERR_Actors.CanFight(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 = {};
SCR_AIGroup LocalSource;
IEntity LocalWitness;
ref ERR_HQStatusSampler LocalRoster;
bool Downlink;
bool Originated;
float OriginDue = -1;
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");
// An originating report needs three seconds on its original physical
// caller before any network transmission. Later completed hops remain
// independent of that caller, and HQ downlinks do not repeat this call.
if (!Downlink && !Originated)
{
if (Nodes.IsEmpty() || !Nodes[0].Valid(Nodes.Count() == 1))
return Fail(journal, "originating call lost its living owned radio carrier");
if (LocalRoster && !ERR_HQLocalHandoff.Valid(LocalSource, LocalRoster, LocalWitness, Nodes[0]))
return Fail(journal, "local sighting handoff lost original squad member, recipient or 100m reach");
if (OriginDue < 0)
{
OriginDue = now + 3000;
journal.Write("CALL", Nodes[0].Label() + " originating report; three seconds before transmission", MessageId);
}
if (now < OriginDue) return 0;
Originated = true;
journal.Write("CALL COMPLETE", Nodes[0].Label() + " completed originating report", MessageId);
}
int target = Cursor + 1;
if (Sending)
{
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))
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;
protected int m_LocalChecks;
void Clear()
{
m_Nodes.Clear();
m_Parents.Clear();
m_Depths.Clear();
m_Indices.Clear();
m_Cells.Clear();
Overflow = false;
m_LocalChecks = 0;
}
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);
}
}
}
}
}
// At most 4096 local pairs across all reports per network rebuild. Indexes
// provide candidate routes only; current original actors/distance are checked.
ERR_HQRoute LocalRoute(SCR_AIGroup source, ERR_HQStatusSampler roster, array<IEntity> busy)
{
if (Overflow || !source || !roster || !roster.OwnsRoster(source) || !busy || busy.Count() > 256) return null;
int count = roster.InitialStrength();
if (count < 1 || count > 32) return null;
foreach (ERR_HQRadioNode node : m_Nodes)
{
if (!node || node.Group == source || busy.Contains(node.Carrier)) continue;
for (int i = 0; i < count; i++)
{
if (++m_LocalChecks > 4096) return null;
IEntity witness = roster.InitialMember(i);
if (!ERR_HQLocalHandoff.Valid(source, roster, witness, node)) continue;
ERR_HQRoute route = Route(node.Carrier);
if (!route) continue;
route.LocalSource = source; route.LocalWitness = witness; route.LocalRoster = roster;
return route;
}
}
return null;
}
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,104 @@
#!/usr/bin/env python3
"""Execute production bounded radio graph and hop-delivery state machine."""
from pathlib import Path
import os, re, subprocess, tempfile
ROOT=Path(__file__).resolve().parents[1]
source=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c')).read_text()
def extract(signature):
a=source.index(signature);b=source.index('{',a);end=b+1;depth=1
while depth:depth+=(source[end]=='{')-(source[end]=='}');end+=1
return source[a:end]
def trans(s):
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('null','nullptr')
s=s.replace('string.Format','Format').replace('string ', 'std::string ')
s=s.replace('Math.Round(distance).ToString()', 'std::to_string(int(distance))')
s=re.sub(r'\b(\w+)\.ToString\(\)',r'std::to_string(\1)',s)
for name in ['Math','vector','ERR_HQLocalHandoff']:s=s.replace(name+'.',name+'::')
for typ in ['IEntity','SCR_AIGroup','ERR_HQRoute','ERR_HQRadioNode','ERR_HQJournal']:
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
s=s.replace('array<int> queue = {}','Arr<int> queue').replace('array<int> cell','Arr<int>* cell').replace('array<int> neighbors','Arr<int>* neighbors')
s=s.replace('cell = {};','cell = new Arr<int>();')
s=re.sub(r'foreach\s*\(int (\w+) : neighbors\)',r'for (auto \1 : *neighbors)',s)
for name in ['carrier','caller','node','route','journal','cell']:
s=s.replace(name+'.',name+'->')
s=re.sub(r'(Nodes|m_Nodes)\[([^\]]+)\]\.',r'\1[\2]->',s)
s=s.replace('Carrier.GetOrigin()', 'Carrier->GetOrigin()')
s=re.sub(r'\b(int|float|bool) (\w+);',r'\1 \2{};',s)
return s
headers=r'''
#include <cassert>
#include <cmath>
#include <algorithm>
#include <vector>
#include <map>
#include <string>
#include <iostream>
struct vector{float v[3]{};float operator[](int i)const{return v[i];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
vector V(float x,float z=0){return {{x,0,z}};}
namespace Math{int Floor(float x){return std::floor(x);}}
std::string Format(const char*,int x,int z){return std::to_string(x)+":"+std::to_string(z);}
template<class T>struct Arr:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T x){this->push_back(x);}void InsertAt(T x,int i){this->insert(this->begin()+i,x);}};
template<class K,class V>struct Map:std::map<K,V>{void Clear(){this->clear();}bool Contains(K k){return this->count(k);}bool Find(K k,V& v){if(!Contains(k))return false;v=this->at(k);return true;}V Get(K k){if(!Contains(k))return {};return this->at(k);}void Insert(K k,V v){(*this)[k]=v;}};
struct SCR_AIGroup{bool enemy=true;};struct IEntity{vector pos;bool alive=true;int radio=1;bool captured=false;vector GetOrigin(){return pos;}};
struct ERR_HQRadioNode{IEntity* Carrier{};SCR_AIGroup* Group{};int UnitId{},Capability{};bool Valid(bool manpack=false){return Carrier&&Carrier->alive&&!Carrier->captured&&Carrier->radio>=(manpack?2:1);}std::string Label(){return "unit "+std::to_string(UnitId);}};
struct ERR_HQJournal{Arr<std::string> events;void Write(std::string kind,std::string text,int){events.Insert(kind+" "+text);}};
'''
headers+='struct ERR_HQStatusSampler{};struct ERR_HQLocalHandoff{static bool Valid(SCR_AIGroup*,ERR_HQStatusSampler*,IEntity*,ERR_HQRadioNode*){return true;}};\n'
routefields='SCR_AIGroup* LocalSource=nullptr;IEntity* LocalWitness=nullptr;ERR_HQStatusSampler* LocalRoster=nullptr;Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{},Originated{};int Cursor{},MessageId{};float Due{},StartedAt=-1,OriginDue=-1;vector SentFrom;std::string Payload;'
networkfields='int m_LocalChecks=0;Arr<ERR_HQRadioNode*> m_Nodes;Arr<int> m_Parents,m_Depths;Map<IEntity*,int> m_Indices;Map<std::string,Arr<int>*> m_Cells;bool Overflow{};'
route='struct ERR_HQRoute{'+routefields+'\n'+'\n'.join(trans(extract(x)) for x in ['int Advance(', 'protected string SenderLabel(', 'protected int Fail('])+'};\n'
network='struct Network{'+networkfields+'\n'+'\n'.join(trans(extract(x)) for x in ['void Clear(', 'protected string Cell(', 'void Add(', 'void Build(', 'ERR_HQRoute Route('])+'};\n'
tests=r'''
int main(){
Network network;SCR_AIGroup group;IEntity c[5];for(int i=0;i<5;i++)c[i].pos=V(i*500);
c[0].radio=2;for(int i=0;i<5;i++)network.Add(&c[i],&group,37+i,c[i].radio);network.Build();
assert(network.Route(&c[0])->Nodes.Count()==1);assert(network.Route(&c[3])->Nodes.Count()==4);assert(!network.Route(&c[4]));
ERR_HQJournal journal;auto up=network.Route(&c[2]);assert(up->Nodes.Count()==3);
assert(up->Advance(0,&journal)==0);assert(!up->Sending&&!up->Originated&&up->OriginDue==3000);
assert(journal.events.Count()==1&&journal.events.back().find("CALL ")==0);
assert(up->Advance(2999,&journal)==0);assert(journal.events.Count()==1);
assert(up->Advance(3000,&journal)==0);assert(up->Sending&&up->Originated&&up->Due==5000);
assert(up->Advance(5000,&journal)==0);
c[2].alive=false;assert(up->Advance(7000,&journal)==0);c[0].alive=false;assert(up->Advance(9000,&journal)==-1);
assert(journal.events.back().find("LOST")!=std::string::npos); // final transmission needs its live sender
for(auto& carrier:c)carrier.alive=true;
auto good=network.Route(&c[2]);assert(good->Advance(0,&journal)==0);assert(good->Advance(3000,&journal)==0);assert(good->Advance(5000,&journal)==0);
c[2].alive=false;assert(good->Advance(7000,&journal)==0);assert(good->Advance(9000,&journal)==1);
assert(journal.events.back().find("HQ received")!=std::string::npos); // a completed earlier hop survives its sender
auto severed=network.Route(&c[0]);assert(severed->Advance(0,&journal)==0);assert(severed->Advance(3000,&journal)==0);c[0].radio=0;assert(severed->Advance(5000,&journal)==-1);c[0].radio=2;
for(auto& carrier:c)carrier.alive=true;
auto lost=network.Route(&c[2]);assert(lost->Advance(0,&journal)==0);assert(lost->Advance(3000,&journal)==0);c[1].radio=0;assert(lost->Advance(5000,&journal)==-1);assert(lost->Advance(7000,&journal)==-1);
c[1].radio=1;auto moving=network.Route(&c[2]);moving->Advance(0,&journal);moving->Advance(3000,&journal);c[1].pos=V(499);assert(moving->Advance(5000,&journal)==-1);c[1].pos=V(500);
// No originating transmission may survive radio loss, death/incapacitation, or capture during the hold.
for(int reason=0;reason<3;reason++){
auto interrupted=network.Route(&c[0]);ERR_HQJournal calls;assert(interrupted->Advance(10000,&calls)==0);
if(reason==0)c[0].radio=1;
if(reason==1)c[0].alive=false;
if(reason==2)c[0].captured=true;
assert(interrupted->Advance(11500,&calls)==-1);assert(!interrupted->Sending&&!interrupted->Originated);
assert(calls.events.Count()==2&&calls.events[0].find("CALL ")==0&&calls.events[1].find("LOST ")==0);
c[0].radio=2;c[0].alive=true;c[0].captured=false;
assert(interrupted->Advance(15000,&calls)==-1);assert(calls.events.Count()==2);
}
auto direct=network.Route(&c[0]);ERR_HQJournal directLog;
assert(direct->Advance(0,&directLog)==0);assert(direct->Advance(2999,&directLog)==0);
assert(direct->Advance(3000,&directLog)==0);assert(direct->Advance(4999,&directLog)==0);assert(direct->Advance(5000,&directLog)==1);
assert(directLog.events.Count()==4); // CALL, CALL COMPLETE, TRANSMIT, RECEIVE exactly once.
ERR_HQRoute empty;assert(empty.Advance(0,&journal)==-1);assert(!empty.Originated&&!empty.Sending);
int beforeDown=journal.events.Count();auto down=network.Route(&c[2],true);assert(down->Nodes[0]->Carrier==&c[0]);assert(down->Advance(0,&journal)==0);
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
network.Clear();assert(!network.Route(&c[0]));
Network crowded;std::vector<IEntity> many(2050);for(auto& carrier:many){carrier.radio=2;crowded.Add(&carrier,&group,1,2);}assert(crowded.Overflow&&crowded.m_Nodes.Count()==2048);
crowded.Build();assert(crowded.Overflow); // bounded dense graph work
std::cout<<"PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds\n";
}
'''
with tempfile.TemporaryDirectory(prefix='eai-hq-network-') as tmp:
cpp=Path(tmp)/'test.cpp';exe=Path(tmp)/'test';cpp.write_text(headers+route+network+tests)
subprocess.run(['g++','-std=c++17','-Wall','-Wextra',str(cpp),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True)
@@ -0,0 +1,22 @@
{
"finding": "An in-flight two-second hop ignores sender and receiver validity/link reach during intermediate ticks; loss and restoration before Due can deliver an interrupted transmission.",
"minimal_fix": "Evaluate existing sender, receiver and fixed sender-position range guards before early waiting return. Keep RECEIVE logs, cursor advancement and next-hop submission due-only.",
"red": "Actual Advance compiles then fails sender interruption against exact baseline; sender-only revision compiles then fails receiver interruption.",
"green": [
"Existing actual graph/hop program plus all new sender/receiver/range cases passes.",
"Actual radio-node validity test passes, including explicit one-second capability cache."
],
"scope": [
"Life/consciousness/group/faction checked on each observed transmitting tick; capability retains existing <=1s inventory cache.",
"Radio link range remains measured from fixed original sending position; sender movement semantics unchanged.",
"Completed earlier hops remain independent, originating hold and network graph bounds unchanged.",
"No native or multiplayer qualification; no claim to catch events between observed ticks."
],
"files": {
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"tests/hq_network_regression.py": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50"
},
"previous_revision_preserved": "sender-only/",
"target_modified": false,
"native_run": false
}
@@ -0,0 +1,94 @@
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c
@@ -85,10 +85,8 @@
int target = Cursor + 1;
if (Sending)
{
- 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.
+ // A transmission takes two seconds. Validate both endpoints on every
+ // observed tick; only delivery logging and cursor advancement wait for Due.
if (Cursor >= 0 && !Nodes[Cursor].Valid(!Downlink && Cursor == Nodes.Count() - 1))
return Fail(journal, "transmitting carrier lost radio during transmission");
if (target < Nodes.Count())
@@ -97,6 +95,11 @@
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");
+ }
+ if (now < Due)
+ return 0;
+ if (target < Nodes.Count())
+ {
journal.Write("RECEIVE", Nodes[target].Label() + " received from " + SenderLabel(), MessageId);
}
else
--- a/tests/hq_network_regression.py
+++ b/tests/hq_network_regression.py
@@ -89,6 +89,64 @@
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
+ // The sender must stay valid on EVERY observed tick of the two-second hop,
+ // not only at completion. Recovery before Due cannot revive an interrupted hop.
+ for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){
+ auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log;
+ assert(interrupted->Advance(0,&log)==0);
+ float sendingAt=3000;
+ if(downlink)sendingAt=2000;
+ assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
+ if(reason==0)c[0].alive=false;
+ if(reason==1)c[0].captured=true;
+ if(reason==2)c[0].radio=0;
+ assert(interrupted->Advance(sendingAt+1000,&log)==-1);
+ assert(interrupted->Failed&&log.events.back().find("LOST ")==0);
+ int terminalEvents=log.events.Count();
+ c[0].alive=true;c[0].captured=false;c[0].radio=2;
+ assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
+ }
+ // Receiver interruption and leaving/re-entering the link also cancel the
+ // same physical short-range hop in either direction, before its due time.
+ for(bool downlink:{false,true})for(int reason=0;reason<4;reason++){
+ auto interrupted=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
+ assert(interrupted->Advance(0,&log)==0);
+ float sendingAt=downlink?2000:3000;
+ assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
+ assert(interrupted->Nodes[interrupted->Cursor+1]->Carrier==&c[1]);
+ if(reason==0)c[1].alive=false;
+ if(reason==1)c[1].captured=true;
+ if(reason==2)c[1].radio=0;
+ if(reason==3)c[1].pos=V(3000);
+ assert(interrupted->Advance(sendingAt+1000,&log)==-1);
+ int terminalEvents=log.events.Count();
+ c[1].alive=true;c[1].captured=false;c[1].radio=1;c[1].pos=V(500);
+ assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
+ }
+ // HQ's first downlink leg has no physical sender; its original receiving
+ // manpack must still survive while waiting, rather than use a late recovery.
+ auto firstDown=network.Route(&c[1],true);ERR_HQJournal firstDownLog;
+ assert(firstDown->Advance(0,&firstDownLog)==0&&firstDown->Cursor==-1);
+ c[0].radio=1;assert(firstDown->Advance(1000,&firstDownLog)==-1);
+ c[0].radio=2;assert(firstDown->Advance(2000,&firstDownLog)==-1);
+ // Healthy receiver-bearing hops keep the cursor and RECEIVE log unchanged
+ // during intermediate polling, with both 500m boundary links still valid.
+ for(bool downlink:{false,true}){
+ auto healthy=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
+ assert(healthy->Advance(0,&log)==0);
+ float sendingAt=downlink?2000:3000;
+ assert(healthy->Advance(sendingAt,&log)==0&&healthy->Sending);
+ int cursor=healthy->Cursor,events=log.events.Count();
+ assert(healthy->Advance(sendingAt+1000,&log)==0&&healthy->Cursor==cursor&&log.events.Count()==events);
+ assert(healthy->Advance(sendingAt+2000,&log)==(downlink?1:0)&&healthy->Cursor==cursor+1);
+ }
+ // Healthy intermediate polling does not transmit early or duplicate log entries.
+ auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog;
+ assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0);
+ int transmittingEvents=waitingLog.events.Count();
+ assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending);
+ assert(waitingLog.events.Count()==transmittingEvents);
+ assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1);
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
@@ -0,0 +1 @@
PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds
@@ -0,0 +1,9 @@
test: /tmp/eai-hq-network-r9cy6ith/test.cpp:270: int main(): Assertion `interrupted->Advance(sendingAt+1000,&log)==-1' failed.
Traceback (most recent call last):
File "/tmp/erri-radio-review-1720/tests/hq_network_regression.py", line 162, in <module>
subprocess.run([str(exe)],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/eai-hq-network-r9cy6ith/test']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1 @@
PASS: actual radio node rejects current incapacitation, death, ownership and faction changes despite cached inventory; one-second capability refresh remains explicit
@@ -0,0 +1 @@
PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds
@@ -0,0 +1,26 @@
[
{
"label": "baseline",
"exit": 1,
"runtime_assertion": true,
"source_sha256": "85e4d2cb27426c07b8631ca411f4b8f5c821f9f331c8c2719cbfa71b3687f08e",
"test_sha256": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50",
"log": "/tmp/erri-radio-review-1720/independent-baseline.log"
},
{
"label": "proposal",
"exit": 0,
"runtime_assertion": false,
"source_sha256": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"test_sha256": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50",
"log": "/tmp/erri-radio-review-1720/independent-proposal.log"
},
{
"label": "node-validity",
"exit": 0,
"runtime_assertion": false,
"source_sha256": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"test_sha256": "2297ae23b7a5fece845c13f16432dece6e2dde55ecc097a5be6311f470dd5242",
"log": "/tmp/erri-radio-review-1720/independent-node-validity.log"
}
]
@@ -0,0 +1,48 @@
{
"verdict": "approved; no concrete blocker found",
"source_manifest": {
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"tests/hq_network_regression.py": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50"
},
"patch_sha256": "2294435350ab3f078ec5b957c783a3f4ddee0872aa1d107e948809f7572f17ac",
"independent_runs": [
{
"label": "baseline",
"exit": 1,
"runtime_assertion": true,
"source_sha256": "85e4d2cb27426c07b8631ca411f4b8f5c821f9f331c8c2719cbfa71b3687f08e",
"test_sha256": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50",
"log": "/tmp/erri-radio-review-1720/independent-baseline.log"
},
{
"label": "proposal",
"exit": 0,
"runtime_assertion": false,
"source_sha256": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"test_sha256": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50",
"log": "/tmp/erri-radio-review-1720/independent-proposal.log"
},
{
"label": "node-validity",
"exit": 0,
"runtime_assertion": false,
"source_sha256": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"test_sha256": "2297ae23b7a5fece845c13f16432dece6e2dde55ecc097a5be6311f470dd5242",
"log": "/tmp/erri-radio-review-1720/independent-node-validity.log"
},
{
"label": "sender-only with final receiver/range regression",
"exit": 1,
"runtime_assertion": true,
"source_sha256": "cce142d1ad276bf590b360041b9e3a6403cc4e64599a9c2a6184f041005b3063"
}
],
"checks": [
"Existing sender, receiving carrier and receiver-to-SentFrom range guards now run during each observed Sending tick. Completed previous hops are not revalidated.",
"Due still gates RECEIVE, Cursor advancement, Sending=false and completion return. Healthy intermediate polls emit no new events or early progress.",
"HQ-to-first-manpack leg retains Cursor=-1 sender exemption and manpack requirement for recipient. Final manpack-to-HQ leg retains no receiver entity requirement.",
"Existing node Valid code unchanged: life, consciousness, faction and group live; radio inventory capability refresh cached at one second. No between-tick or instantaneous inventory detection claim.",
"Terminal Fail latch prevents loss/restoration before Due from resurrecting cancelled route; source prefix/cache/route construction untouched."
],
"limits": "Actual production Advance and node Valid run against native boundary doubles; no engine or multiplayer qualification. Only review artifacts written; source/target unchanged."
}
@@ -0,0 +1,9 @@
test: /tmp/eai-hq-network-g_ls6ji6/test.cpp:288: int main(): Assertion `interrupted->Advance(sendingAt+1000,&log)==-1' failed.
Traceback (most recent call last):
File "/tmp/erri-radio-review-1720/tests/hq_network_regression.py", line 162, in <module>
subprocess.run([str(exe)],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/eai-hq-network-g_ls6ji6/test']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1 @@
PASS: actual radio node rejects current incapacitation, death, ownership and faction changes despite cached inventory; one-second capability refresh remains explicit
@@ -0,0 +1,9 @@
test: /tmp/eai-hq-network-xkjbk9fa/test.cpp:270: int main(): Assertion `interrupted->Advance(sendingAt+1000,&log)==-1' failed.
Traceback (most recent call last):
File "/tmp/erri-radio-review-1720/tests/hq_network_regression.py", line 162, in <module>
subprocess.run([str(exe)],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/eai-hq-network-xkjbk9fa/test']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1,6 @@
{
"red": 1,
"sender-only-red": 1,
"green": 0,
"node-green": 0
}
@@ -0,0 +1,9 @@
test: /tmp/eai-hq-network-pwha6jac/test.cpp:288: int main(): Assertion `interrupted->Advance(sendingAt+1000,&log)==-1' failed.
Traceback (most recent call last):
File "/tmp/erri-radio-review-1720/tests/hq_network_regression.py", line 162, in <module>
subprocess.run([str(exe)],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/eai-hq-network-pwha6jac/test']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1,294 @@
// 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 (!ERR_Actors.CanFight(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 = {};
SCR_AIGroup LocalSource;
IEntity LocalWitness;
ref ERR_HQStatusSampler LocalRoster;
bool Downlink;
bool Originated;
float OriginDue = -1;
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");
// An originating report needs three seconds on its original physical
// caller before any network transmission. Later completed hops remain
// independent of that caller, and HQ downlinks do not repeat this call.
if (!Downlink && !Originated)
{
if (Nodes.IsEmpty() || !Nodes[0].Valid(Nodes.Count() == 1))
return Fail(journal, "originating call lost its living owned radio carrier");
if (LocalRoster && !ERR_HQLocalHandoff.Valid(LocalSource, LocalRoster, LocalWitness, Nodes[0]))
return Fail(journal, "local sighting handoff lost original squad member, recipient or 100m reach");
if (OriginDue < 0)
{
OriginDue = now + 3000;
journal.Write("CALL", Nodes[0].Label() + " originating report; three seconds before transmission", MessageId);
}
if (now < OriginDue) return 0;
Originated = true;
journal.Write("CALL COMPLETE", Nodes[0].Label() + " completed originating report", MessageId);
}
int target = Cursor + 1;
if (Sending)
{
// 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 (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;
protected int m_LocalChecks;
void Clear()
{
m_Nodes.Clear();
m_Parents.Clear();
m_Depths.Clear();
m_Indices.Clear();
m_Cells.Clear();
Overflow = false;
m_LocalChecks = 0;
}
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);
}
}
}
}
}
// At most 4096 local pairs across all reports per network rebuild. Indexes
// provide candidate routes only; current original actors/distance are checked.
ERR_HQRoute LocalRoute(SCR_AIGroup source, ERR_HQStatusSampler roster, array<IEntity> busy)
{
if (Overflow || !source || !roster || !roster.OwnsRoster(source) || !busy || busy.Count() > 256) return null;
int count = roster.InitialStrength();
if (count < 1 || count > 32) return null;
foreach (ERR_HQRadioNode node : m_Nodes)
{
if (!node || node.Group == source || busy.Contains(node.Carrier)) continue;
for (int i = 0; i < count; i++)
{
if (++m_LocalChecks > 4096) return null;
IEntity witness = roster.InitialMember(i);
if (!ERR_HQLocalHandoff.Valid(source, roster, witness, node)) continue;
ERR_HQRoute route = Route(node.Carrier);
if (!route) continue;
route.LocalSource = source; route.LocalWitness = witness; route.LocalRoster = roster;
return route;
}
}
return null;
}
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,19 @@
{
"finding": "A two-second in-flight hop ignores its sender during intermediate ticks; loss and restoration before Due can deliver an interrupted transmission.",
"minimal_fix": "Evaluate existing transmitting-carrier validation before early waiting return. Keep receiver/range, final delivery, inventory sample cache, and completed earlier hops unchanged.",
"red": "Actual Advance compiles then fails intermediate loss assertion against exact saved baseline.",
"green": [
"Actual network graph/hop regression passes all original and new cases.",
"Actual radio-node validity regression passes, including explicit one-second inventory cache."
],
"scope": [
"Life/consciousness/group/faction are live on every observed transmitting tick. Inventory capability keeps existing <=1s cached policy.",
"No claim to observe changes between HQ ticks, native transport or multiplayer behavior."
],
"files": {
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "cce142d1ad276bf590b360041b9e3a6403cc4e64599a9c2a6184f041005b3063",
"tests/hq_network_regression.py": "f6609e52834502e4a50b61847177f1fcbd990655cacc20a5cedb47336d8587ad"
},
"target_modified": false,
"native_run": false
}
@@ -0,0 +1,50 @@
--- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c
+++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c
@@ -85,12 +85,12 @@
int target = Cursor + 1;
if (Sending)
{
- 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 (now < Due)
+ return 0;
if (target < Nodes.Count())
{
if (!Nodes[target].Valid(Downlink && target == 0))
--- a/tests/hq_network_regression.py
+++ b/tests/hq_network_regression.py
@@ -89,6 +89,30 @@
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
+ // The sender must stay valid on EVERY observed tick of the two-second hop,
+ // not only at completion. Recovery before Due cannot revive an interrupted hop.
+ for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){
+ auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log;
+ assert(interrupted->Advance(0,&log)==0);
+ float sendingAt=3000;
+ if(downlink)sendingAt=2000;
+ assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
+ if(reason==0)c[0].alive=false;
+ if(reason==1)c[0].captured=true;
+ if(reason==2)c[0].radio=0;
+ assert(interrupted->Advance(sendingAt+1000,&log)==-1);
+ assert(interrupted->Failed&&log.events.back().find("LOST ")==0);
+ int terminalEvents=log.events.Count();
+ c[0].alive=true;c[0].captured=false;c[0].radio=2;
+ assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
+ }
+ // Healthy intermediate polling does not transmit early or duplicate log entries.
+ auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog;
+ assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0);
+ int transmittingEvents=waitingLog.events.Count();
+ assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending);
+ assert(waitingLog.events.Count()==transmittingEvents);
+ assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1);
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
@@ -0,0 +1 @@
PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds
@@ -0,0 +1 @@
PASS: actual radio node rejects current incapacitation, death, ownership and faction changes despite cached inventory; one-second capability refresh remains explicit
@@ -0,0 +1,9 @@
test: /tmp/eai-hq-network-tt_m7egw/test.cpp:270: int main(): Assertion `interrupted->Advance(sendingAt+1000,&log)==-1' failed.
Traceback (most recent call last):
File "/tmp/erri-radio-review-1720/tests/hq_network_regression.py", line 128, in <module>
subprocess.run([str(exe)],check=True)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.14/subprocess.py", line 578, in run
raise CalledProcessError(retcode, process.args,
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['/tmp/eai-hq-network-tt_m7egw/test']' died with <Signals.SIGABRT: 6>.
@@ -0,0 +1,5 @@
{
"red": 1,
"green": 0,
"node-green": 0
}
@@ -0,0 +1,4 @@
{
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "cce142d1ad276bf590b360041b9e3a6403cc4e64599a9c2a6184f041005b3063",
"tests/hq_network_regression.py": "f6609e52834502e4a50b61847177f1fcbd990655cacc20a5cedb47336d8587ad"
}
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Execute production bounded radio graph and hop-delivery state machine."""
from pathlib import Path
import os, re, subprocess, tempfile
ROOT=Path(__file__).resolve().parents[1]
source=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c')).read_text()
def extract(signature):
a=source.index(signature);b=source.index('{',a);end=b+1;depth=1
while depth:depth+=(source[end]=='{')-(source[end]=='}');end+=1
return source[a:end]
def trans(s):
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('null','nullptr')
s=s.replace('string.Format','Format').replace('string ', 'std::string ')
s=s.replace('Math.Round(distance).ToString()', 'std::to_string(int(distance))')
s=re.sub(r'\b(\w+)\.ToString\(\)',r'std::to_string(\1)',s)
for name in ['Math','vector','ERR_HQLocalHandoff']:s=s.replace(name+'.',name+'::')
for typ in ['IEntity','SCR_AIGroup','ERR_HQRoute','ERR_HQRadioNode','ERR_HQJournal']:
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
s=s.replace('array<int> queue = {}','Arr<int> queue').replace('array<int> cell','Arr<int>* cell').replace('array<int> neighbors','Arr<int>* neighbors')
s=s.replace('cell = {};','cell = new Arr<int>();')
s=re.sub(r'foreach\s*\(int (\w+) : neighbors\)',r'for (auto \1 : *neighbors)',s)
for name in ['carrier','caller','node','route','journal','cell']:
s=s.replace(name+'.',name+'->')
s=re.sub(r'(Nodes|m_Nodes)\[([^\]]+)\]\.',r'\1[\2]->',s)
s=s.replace('Carrier.GetOrigin()', 'Carrier->GetOrigin()')
s=re.sub(r'\b(int|float|bool) (\w+);',r'\1 \2{};',s)
return s
headers=r'''
#include <cassert>
#include <cmath>
#include <algorithm>
#include <vector>
#include <map>
#include <string>
#include <iostream>
struct vector{float v[3]{};float operator[](int i)const{return v[i];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
vector V(float x,float z=0){return {{x,0,z}};}
namespace Math{int Floor(float x){return std::floor(x);}}
std::string Format(const char*,int x,int z){return std::to_string(x)+":"+std::to_string(z);}
template<class T>struct Arr:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T x){this->push_back(x);}void InsertAt(T x,int i){this->insert(this->begin()+i,x);}};
template<class K,class V>struct Map:std::map<K,V>{void Clear(){this->clear();}bool Contains(K k){return this->count(k);}bool Find(K k,V& v){if(!Contains(k))return false;v=this->at(k);return true;}V Get(K k){if(!Contains(k))return {};return this->at(k);}void Insert(K k,V v){(*this)[k]=v;}};
struct SCR_AIGroup{bool enemy=true;};struct IEntity{vector pos;bool alive=true;int radio=1;bool captured=false;vector GetOrigin(){return pos;}};
struct ERR_HQRadioNode{IEntity* Carrier{};SCR_AIGroup* Group{};int UnitId{},Capability{};bool Valid(bool manpack=false){return Carrier&&Carrier->alive&&!Carrier->captured&&Carrier->radio>=(manpack?2:1);}std::string Label(){return "unit "+std::to_string(UnitId);}};
struct ERR_HQJournal{Arr<std::string> events;void Write(std::string kind,std::string text,int){events.Insert(kind+" "+text);}};
'''
headers+='struct ERR_HQStatusSampler{};struct ERR_HQLocalHandoff{static bool Valid(SCR_AIGroup*,ERR_HQStatusSampler*,IEntity*,ERR_HQRadioNode*){return true;}};\n'
routefields='SCR_AIGroup* LocalSource=nullptr;IEntity* LocalWitness=nullptr;ERR_HQStatusSampler* LocalRoster=nullptr;Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{},Originated{};int Cursor{},MessageId{};float Due{},StartedAt=-1,OriginDue=-1;vector SentFrom;std::string Payload;'
networkfields='int m_LocalChecks=0;Arr<ERR_HQRadioNode*> m_Nodes;Arr<int> m_Parents,m_Depths;Map<IEntity*,int> m_Indices;Map<std::string,Arr<int>*> m_Cells;bool Overflow{};'
route='struct ERR_HQRoute{'+routefields+'\n'+'\n'.join(trans(extract(x)) for x in ['int Advance(', 'protected string SenderLabel(', 'protected int Fail('])+'};\n'
network='struct Network{'+networkfields+'\n'+'\n'.join(trans(extract(x)) for x in ['void Clear(', 'protected string Cell(', 'void Add(', 'void Build(', 'ERR_HQRoute Route('])+'};\n'
tests=r'''
int main(){
Network network;SCR_AIGroup group;IEntity c[5];for(int i=0;i<5;i++)c[i].pos=V(i*500);
c[0].radio=2;for(int i=0;i<5;i++)network.Add(&c[i],&group,37+i,c[i].radio);network.Build();
assert(network.Route(&c[0])->Nodes.Count()==1);assert(network.Route(&c[3])->Nodes.Count()==4);assert(!network.Route(&c[4]));
ERR_HQJournal journal;auto up=network.Route(&c[2]);assert(up->Nodes.Count()==3);
assert(up->Advance(0,&journal)==0);assert(!up->Sending&&!up->Originated&&up->OriginDue==3000);
assert(journal.events.Count()==1&&journal.events.back().find("CALL ")==0);
assert(up->Advance(2999,&journal)==0);assert(journal.events.Count()==1);
assert(up->Advance(3000,&journal)==0);assert(up->Sending&&up->Originated&&up->Due==5000);
assert(up->Advance(5000,&journal)==0);
c[2].alive=false;assert(up->Advance(7000,&journal)==0);c[0].alive=false;assert(up->Advance(9000,&journal)==-1);
assert(journal.events.back().find("LOST")!=std::string::npos); // final transmission needs its live sender
for(auto& carrier:c)carrier.alive=true;
auto good=network.Route(&c[2]);assert(good->Advance(0,&journal)==0);assert(good->Advance(3000,&journal)==0);assert(good->Advance(5000,&journal)==0);
c[2].alive=false;assert(good->Advance(7000,&journal)==0);assert(good->Advance(9000,&journal)==1);
assert(journal.events.back().find("HQ received")!=std::string::npos); // a completed earlier hop survives its sender
auto severed=network.Route(&c[0]);assert(severed->Advance(0,&journal)==0);assert(severed->Advance(3000,&journal)==0);c[0].radio=0;assert(severed->Advance(5000,&journal)==-1);c[0].radio=2;
for(auto& carrier:c)carrier.alive=true;
auto lost=network.Route(&c[2]);assert(lost->Advance(0,&journal)==0);assert(lost->Advance(3000,&journal)==0);c[1].radio=0;assert(lost->Advance(5000,&journal)==-1);assert(lost->Advance(7000,&journal)==-1);
c[1].radio=1;auto moving=network.Route(&c[2]);moving->Advance(0,&journal);moving->Advance(3000,&journal);c[1].pos=V(499);assert(moving->Advance(5000,&journal)==-1);c[1].pos=V(500);
// No originating transmission may survive radio loss, death/incapacitation, or capture during the hold.
for(int reason=0;reason<3;reason++){
auto interrupted=network.Route(&c[0]);ERR_HQJournal calls;assert(interrupted->Advance(10000,&calls)==0);
if(reason==0)c[0].radio=1;
if(reason==1)c[0].alive=false;
if(reason==2)c[0].captured=true;
assert(interrupted->Advance(11500,&calls)==-1);assert(!interrupted->Sending&&!interrupted->Originated);
assert(calls.events.Count()==2&&calls.events[0].find("CALL ")==0&&calls.events[1].find("LOST ")==0);
c[0].radio=2;c[0].alive=true;c[0].captured=false;
assert(interrupted->Advance(15000,&calls)==-1);assert(calls.events.Count()==2);
}
auto direct=network.Route(&c[0]);ERR_HQJournal directLog;
assert(direct->Advance(0,&directLog)==0);assert(direct->Advance(2999,&directLog)==0);
assert(direct->Advance(3000,&directLog)==0);assert(direct->Advance(4999,&directLog)==0);assert(direct->Advance(5000,&directLog)==1);
assert(directLog.events.Count()==4); // CALL, CALL COMPLETE, TRANSMIT, RECEIVE exactly once.
ERR_HQRoute empty;assert(empty.Advance(0,&journal)==-1);assert(!empty.Originated&&!empty.Sending);
int beforeDown=journal.events.Count();auto down=network.Route(&c[2],true);assert(down->Nodes[0]->Carrier==&c[0]);assert(down->Advance(0,&journal)==0);
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
// The sender must stay valid on EVERY observed tick of the two-second hop,
// not only at completion. Recovery before Due cannot revive an interrupted hop.
for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){
auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log;
assert(interrupted->Advance(0,&log)==0);
float sendingAt=3000;
if(downlink)sendingAt=2000;
assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
if(reason==0)c[0].alive=false;
if(reason==1)c[0].captured=true;
if(reason==2)c[0].radio=0;
assert(interrupted->Advance(sendingAt+1000,&log)==-1);
assert(interrupted->Failed&&log.events.back().find("LOST ")==0);
int terminalEvents=log.events.Count();
c[0].alive=true;c[0].captured=false;c[0].radio=2;
assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
}
// Healthy intermediate polling does not transmit early or duplicate log entries.
auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog;
assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0);
int transmittingEvents=waitingLog.events.Count();
assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending);
assert(waitingLog.events.Count()==transmittingEvents);
assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1);
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
network.Clear();assert(!network.Route(&c[0]));
Network crowded;std::vector<IEntity> many(2050);for(auto& carrier:many){carrier.radio=2;crowded.Add(&carrier,&group,1,2);}assert(crowded.Overflow&&crowded.m_Nodes.Count()==2048);
crowded.Build();assert(crowded.Overflow); // bounded dense graph work
std::cout<<"PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds\n";
}
'''
with tempfile.TemporaryDirectory(prefix='eai-hq-network-') as tmp:
cpp=Path(tmp)/'test.cpp';exe=Path(tmp)/'test';cpp.write_text(headers+route+network+tests)
subprocess.run(['g++','-std=c++17','-Wall','-Wextra',str(cpp),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True)
@@ -0,0 +1,4 @@
{
"addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c": "0b73be993628a55e3b21c8670d540e39c938093dd5cb4d586e800ac530509b25",
"tests/hq_network_regression.py": "63c7525b26ae6b6e2369307250e89223a77e7e28321daf4309ab76f21ca1bc50"
}
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Execute production bounded radio graph and hop-delivery state machine."""
from pathlib import Path
import os, re, subprocess, tempfile
ROOT=Path(__file__).resolve().parents[1]
source=Path(os.environ.get('ERR_NETWORK_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HQNetwork.c')).read_text()
def extract(signature):
a=source.index(signature);b=source.index('{',a);end=b+1;depth=1
while depth:depth+=(source[end]=='{')-(source[end]=='}');end+=1
return source[a:end]
def trans(s):
s=re.sub(r'//[^\n]*','',s).replace('protected ','').replace('null','nullptr')
s=s.replace('string.Format','Format').replace('string ', 'std::string ')
s=s.replace('Math.Round(distance).ToString()', 'std::to_string(int(distance))')
s=re.sub(r'\b(\w+)\.ToString\(\)',r'std::to_string(\1)',s)
for name in ['Math','vector','ERR_HQLocalHandoff']:s=s.replace(name+'.',name+'::')
for typ in ['IEntity','SCR_AIGroup','ERR_HQRoute','ERR_HQRadioNode','ERR_HQJournal']:
s=re.sub(r'\b'+typ+r' (\w+)',typ+r'* \1',s)
s=s.replace('array<int> queue = {}','Arr<int> queue').replace('array<int> cell','Arr<int>* cell').replace('array<int> neighbors','Arr<int>* neighbors')
s=s.replace('cell = {};','cell = new Arr<int>();')
s=re.sub(r'foreach\s*\(int (\w+) : neighbors\)',r'for (auto \1 : *neighbors)',s)
for name in ['carrier','caller','node','route','journal','cell']:
s=s.replace(name+'.',name+'->')
s=re.sub(r'(Nodes|m_Nodes)\[([^\]]+)\]\.',r'\1[\2]->',s)
s=s.replace('Carrier.GetOrigin()', 'Carrier->GetOrigin()')
s=re.sub(r'\b(int|float|bool) (\w+);',r'\1 \2{};',s)
return s
headers=r'''
#include <cassert>
#include <cmath>
#include <algorithm>
#include <vector>
#include <map>
#include <string>
#include <iostream>
struct vector{float v[3]{};float operator[](int i)const{return v[i];}static float DistanceXZ(vector a,vector b){return std::hypot(a[0]-b[0],a[2]-b[2]);}};
vector V(float x,float z=0){return {{x,0,z}};}
namespace Math{int Floor(float x){return std::floor(x);}}
std::string Format(const char*,int x,int z){return std::to_string(x)+":"+std::to_string(z);}
template<class T>struct Arr:std::vector<T>{int Count(){return this->size();}bool IsEmpty(){return this->empty();}void Clear(){this->clear();}void Insert(T x){this->push_back(x);}void InsertAt(T x,int i){this->insert(this->begin()+i,x);}};
template<class K,class V>struct Map:std::map<K,V>{void Clear(){this->clear();}bool Contains(K k){return this->count(k);}bool Find(K k,V& v){if(!Contains(k))return false;v=this->at(k);return true;}V Get(K k){if(!Contains(k))return {};return this->at(k);}void Insert(K k,V v){(*this)[k]=v;}};
struct SCR_AIGroup{bool enemy=true;};struct IEntity{vector pos;bool alive=true;int radio=1;bool captured=false;vector GetOrigin(){return pos;}};
struct ERR_HQRadioNode{IEntity* Carrier{};SCR_AIGroup* Group{};int UnitId{},Capability{};bool Valid(bool manpack=false){return Carrier&&Carrier->alive&&!Carrier->captured&&Carrier->radio>=(manpack?2:1);}std::string Label(){return "unit "+std::to_string(UnitId);}};
struct ERR_HQJournal{Arr<std::string> events;void Write(std::string kind,std::string text,int){events.Insert(kind+" "+text);}};
'''
headers+='struct ERR_HQStatusSampler{};struct ERR_HQLocalHandoff{static bool Valid(SCR_AIGroup*,ERR_HQStatusSampler*,IEntity*,ERR_HQRadioNode*){return true;}};\n'
routefields='SCR_AIGroup* LocalSource=nullptr;IEntity* LocalWitness=nullptr;ERR_HQStatusSampler* LocalRoster=nullptr;Arr<ERR_HQRadioNode*> Nodes;bool Downlink{},Sending{},Failed{},Originated{};int Cursor{},MessageId{};float Due{},StartedAt=-1,OriginDue=-1;vector SentFrom;std::string Payload;'
networkfields='int m_LocalChecks=0;Arr<ERR_HQRadioNode*> m_Nodes;Arr<int> m_Parents,m_Depths;Map<IEntity*,int> m_Indices;Map<std::string,Arr<int>*> m_Cells;bool Overflow{};'
route='struct ERR_HQRoute{'+routefields+'\n'+'\n'.join(trans(extract(x)) for x in ['int Advance(', 'protected string SenderLabel(', 'protected int Fail('])+'};\n'
network='struct Network{'+networkfields+'\n'+'\n'.join(trans(extract(x)) for x in ['void Clear(', 'protected string Cell(', 'void Add(', 'void Build(', 'ERR_HQRoute Route('])+'};\n'
tests=r'''
int main(){
Network network;SCR_AIGroup group;IEntity c[5];for(int i=0;i<5;i++)c[i].pos=V(i*500);
c[0].radio=2;for(int i=0;i<5;i++)network.Add(&c[i],&group,37+i,c[i].radio);network.Build();
assert(network.Route(&c[0])->Nodes.Count()==1);assert(network.Route(&c[3])->Nodes.Count()==4);assert(!network.Route(&c[4]));
ERR_HQJournal journal;auto up=network.Route(&c[2]);assert(up->Nodes.Count()==3);
assert(up->Advance(0,&journal)==0);assert(!up->Sending&&!up->Originated&&up->OriginDue==3000);
assert(journal.events.Count()==1&&journal.events.back().find("CALL ")==0);
assert(up->Advance(2999,&journal)==0);assert(journal.events.Count()==1);
assert(up->Advance(3000,&journal)==0);assert(up->Sending&&up->Originated&&up->Due==5000);
assert(up->Advance(5000,&journal)==0);
c[2].alive=false;assert(up->Advance(7000,&journal)==0);c[0].alive=false;assert(up->Advance(9000,&journal)==-1);
assert(journal.events.back().find("LOST")!=std::string::npos); // final transmission needs its live sender
for(auto& carrier:c)carrier.alive=true;
auto good=network.Route(&c[2]);assert(good->Advance(0,&journal)==0);assert(good->Advance(3000,&journal)==0);assert(good->Advance(5000,&journal)==0);
c[2].alive=false;assert(good->Advance(7000,&journal)==0);assert(good->Advance(9000,&journal)==1);
assert(journal.events.back().find("HQ received")!=std::string::npos); // a completed earlier hop survives its sender
auto severed=network.Route(&c[0]);assert(severed->Advance(0,&journal)==0);assert(severed->Advance(3000,&journal)==0);c[0].radio=0;assert(severed->Advance(5000,&journal)==-1);c[0].radio=2;
for(auto& carrier:c)carrier.alive=true;
auto lost=network.Route(&c[2]);assert(lost->Advance(0,&journal)==0);assert(lost->Advance(3000,&journal)==0);c[1].radio=0;assert(lost->Advance(5000,&journal)==-1);assert(lost->Advance(7000,&journal)==-1);
c[1].radio=1;auto moving=network.Route(&c[2]);moving->Advance(0,&journal);moving->Advance(3000,&journal);c[1].pos=V(499);assert(moving->Advance(5000,&journal)==-1);c[1].pos=V(500);
// No originating transmission may survive radio loss, death/incapacitation, or capture during the hold.
for(int reason=0;reason<3;reason++){
auto interrupted=network.Route(&c[0]);ERR_HQJournal calls;assert(interrupted->Advance(10000,&calls)==0);
if(reason==0)c[0].radio=1;
if(reason==1)c[0].alive=false;
if(reason==2)c[0].captured=true;
assert(interrupted->Advance(11500,&calls)==-1);assert(!interrupted->Sending&&!interrupted->Originated);
assert(calls.events.Count()==2&&calls.events[0].find("CALL ")==0&&calls.events[1].find("LOST ")==0);
c[0].radio=2;c[0].alive=true;c[0].captured=false;
assert(interrupted->Advance(15000,&calls)==-1);assert(calls.events.Count()==2);
}
auto direct=network.Route(&c[0]);ERR_HQJournal directLog;
assert(direct->Advance(0,&directLog)==0);assert(direct->Advance(2999,&directLog)==0);
assert(direct->Advance(3000,&directLog)==0);assert(direct->Advance(4999,&directLog)==0);assert(direct->Advance(5000,&directLog)==1);
assert(directLog.events.Count()==4); // CALL, CALL COMPLETE, TRANSMIT, RECEIVE exactly once.
ERR_HQRoute empty;assert(empty.Advance(0,&journal)==-1);assert(!empty.Originated&&!empty.Sending);
int beforeDown=journal.events.Count();auto down=network.Route(&c[2],true);assert(down->Nodes[0]->Carrier==&c[0]);assert(down->Advance(0,&journal)==0);
assert(down->Advance(2000,&journal)==0);assert(down->Advance(4000,&journal)==0);assert(down->Advance(6000,&journal)==1);
assert(!down->Originated&&down->OriginDue==-1);for(int j=beforeDown;j<journal.events.Count();j++)assert(journal.events[j].find("CALL")!=0);
auto captured=network.Route(&c[2],true);captured->Advance(0,&journal);c[0].captured=true;assert(captured->Advance(2000,&journal)==-1);c[0].captured=false;
// The sender must stay valid on EVERY observed tick of the two-second hop,
// not only at completion. Recovery before Due cannot revive an interrupted hop.
for(bool downlink:{false,true})for(int reason=0;reason<3;reason++){
auto interrupted=network.Route(downlink?&c[1]:&c[0],downlink);ERR_HQJournal log;
assert(interrupted->Advance(0,&log)==0);
float sendingAt=3000;
if(downlink)sendingAt=2000;
assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
if(reason==0)c[0].alive=false;
if(reason==1)c[0].captured=true;
if(reason==2)c[0].radio=0;
assert(interrupted->Advance(sendingAt+1000,&log)==-1);
assert(interrupted->Failed&&log.events.back().find("LOST ")==0);
int terminalEvents=log.events.Count();
c[0].alive=true;c[0].captured=false;c[0].radio=2;
assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
}
// Receiver interruption and leaving/re-entering the link also cancel the
// same physical short-range hop in either direction, before its due time.
for(bool downlink:{false,true})for(int reason=0;reason<4;reason++){
auto interrupted=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
assert(interrupted->Advance(0,&log)==0);
float sendingAt=downlink?2000:3000;
assert(interrupted->Advance(sendingAt,&log)==0&&interrupted->Sending);
assert(interrupted->Nodes[interrupted->Cursor+1]->Carrier==&c[1]);
if(reason==0)c[1].alive=false;
if(reason==1)c[1].captured=true;
if(reason==2)c[1].radio=0;
if(reason==3)c[1].pos=V(3000);
assert(interrupted->Advance(sendingAt+1000,&log)==-1);
int terminalEvents=log.events.Count();
c[1].alive=true;c[1].captured=false;c[1].radio=1;c[1].pos=V(500);
assert(interrupted->Advance(sendingAt+2000,&log)==-1&&log.events.Count()==terminalEvents);
}
// HQ's first downlink leg has no physical sender; its original receiving
// manpack must still survive while waiting, rather than use a late recovery.
auto firstDown=network.Route(&c[1],true);ERR_HQJournal firstDownLog;
assert(firstDown->Advance(0,&firstDownLog)==0&&firstDown->Cursor==-1);
c[0].radio=1;assert(firstDown->Advance(1000,&firstDownLog)==-1);
c[0].radio=2;assert(firstDown->Advance(2000,&firstDownLog)==-1);
// Healthy receiver-bearing hops keep the cursor and RECEIVE log unchanged
// during intermediate polling, with both 500m boundary links still valid.
for(bool downlink:{false,true}){
auto healthy=network.Route(downlink?&c[1]:&c[2],downlink);ERR_HQJournal log;
assert(healthy->Advance(0,&log)==0);
float sendingAt=downlink?2000:3000;
assert(healthy->Advance(sendingAt,&log)==0&&healthy->Sending);
int cursor=healthy->Cursor,events=log.events.Count();
assert(healthy->Advance(sendingAt+1000,&log)==0&&healthy->Cursor==cursor&&log.events.Count()==events);
assert(healthy->Advance(sendingAt+2000,&log)==(downlink?1:0)&&healthy->Cursor==cursor+1);
}
// Healthy intermediate polling does not transmit early or duplicate log entries.
auto waiting=network.Route(&c[0]);ERR_HQJournal waitingLog;
assert(waiting->Advance(0,&waitingLog)==0&&waiting->Advance(3000,&waitingLog)==0);
int transmittingEvents=waitingLog.events.Count();
assert(waiting->Advance(4000,&waitingLog)==0&&waiting->Sending);
assert(waitingLog.events.Count()==transmittingEvents);
assert(waiting->Advance(5000,&waitingLog)==1&&waitingLog.events.Count()==transmittingEvents+1);
auto timeout=network.Route(&c[2]);timeout->Advance(0,&journal);assert(timeout->Advance(30001,&journal)==-1);
Network disconnected;disconnected.Add(&c[1],&group,1,1);disconnected.Build();assert(!disconnected.Route(&c[1]));
Network boundary;c[1].pos=V(501);boundary.Add(&c[0],&group,1,2);boundary.Add(&c[1],&group,2,1);boundary.Build();assert(!boundary.Route(&c[1]));
network.Clear();assert(!network.Route(&c[0]));
Network crowded;std::vector<IEntity> many(2050);for(auto& carrier:many){carrier.radio=2;crowded.Add(&carrier,&group,1,2);}assert(crowded.Overflow&&crowded.m_Nodes.Count()==2048);
crowded.Build();assert(crowded.Overflow); // bounded dense graph work
std::cout<<"PASS: real-carrier shortest-hop paths, 500m boundary, three-hop cap, no-manpack failure, three-second originating-call interruption, unchanged downlink hop timing, completed-hop survival, interrupted final uplink, radio loss, capture, motion, timeout, reset and network bounds\n";
}
'''
with tempfile.TemporaryDirectory(prefix='eai-hq-network-') as tmp:
cpp=Path(tmp)/'test.cpp';exe=Path(tmp)/'test';cpp.write_text(headers+route+network+tests)
subprocess.run(['g++','-std=c++17','-Wall','-Wextra',str(cpp),'-o',str(exe)],check=True)
subprocess.run([str(exe)],check=True)