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

This commit is contained in:
2026-09-20 02:55:30 +02:00
parent e7b4141f69
commit a2aab17171
230 changed files with 37976 additions and 139 deletions
+4 -2
View File
@@ -18,8 +18,10 @@ class ERR_Actors
{
if (!actor) return null;
AIControlComponent control = AIControlComponent.Cast(actor.FindComponent(AIControlComponent));
if (!control || !control.GetAIAgent()) return null;
return SCR_AIGroup.Cast(control.GetAIAgent().GetParentGroup());
// Seated characters may delegate their inherited agent to a vehicle.
// Native group membership belongs to the character's controlled agent.
if (!control || !control.GetControlAIAgent()) return null;
return SCR_AIGroup.Cast(control.GetControlAIAgent().GetParentGroup());
}
static bool Player(IEntity actor)
@@ -0,0 +1,180 @@
enum ERR_ForceKind { GARRISON, PATROL, CHECKPOINT, CONVOY }
enum ERR_ForceDuty { HOLD, PATROL_ROUTE, MOVE_TO, SEARCH, RETURN }
// Owned authored polyline. Seal on registration so simulation never observes a
// partially edited route. Native waypoints and terrain queries belong elsewhere.
class ERR_ForceRoute
{
protected ref array<vector> m_Points = {};
protected bool m_Loop;
protected bool m_Sealed;
protected float m_Length;
void ERR_ForceRoute(bool loop = false) { m_Loop = loop; }
int Count() { return m_Points.Count(); }
vector At(int index)
{
if (index < 0 || index >= m_Points.Count()) return "0 0 0";
return m_Points[index];
}
bool Looped() { return m_Loop; }
float Length() { return m_Length; }
bool Add(vector point)
{
if (m_Sealed || m_Points.Count() >= 64 || !ERR_ForceRules.FiniteVector(point)) return false;
m_Points.Insert(point);
return true;
}
bool Seal()
{
if (m_Sealed) return true;
if (m_Points.IsEmpty() || (m_Loop && m_Points.Count() < 2)) return false;
float length;
for (int i = 1; i < m_Points.Count(); i++)
length += vector.DistanceXZ(m_Points[i - 1], m_Points[i]);
if (m_Loop) length += vector.DistanceXZ(m_Points[m_Points.Count() - 1], m_Points[0]);
if (!ERR_ForceRules.Finite(length) || (m_Loop && length <= 0.01)) return false;
m_Length = length;
m_Sealed = true;
return true;
}
}
class ERR_ForceRecord
{
int Id;
string SourceId;
ERR_ForceKind Kind;
vector Position;
float Heading;
vector HomePosition;
ERR_ForceDuty HomeDuty;
ref ERR_ForceRoute Route;
int RouteIndex;
float RouteProgress;
bool RouteFinished;
int InitialStrength;
int Strength;
int Casualties;
ERR_ForceDuty Duty;
vector DutyTarget;
int Importance = 1;
int RadioCapability;
SCR_AIGroup Group;
// A dormant group handle may remain for native member reuse. This explicit
// state, never handle presence, gates arithmetic and later materialisation.
bool Physical;
bool Pinned;
bool Eliminated;
float LastSimulatedAt;
float StateSince;
}
class ERR_ForceLedger
{
protected ref array<ref ERR_ForceRecord> m_Records = {};
protected ref map<int, ERR_ForceRecord> m_ById = new map<int, ERR_ForceRecord>();
protected ref map<string, int> m_Sources = new map<string, int>();
protected ref map<SCR_AIGroup, int> m_Groups = new map<SCR_AIGroup, int>();
protected int m_NextId;
protected bool m_CapLogged;
int Count() { return m_Records.Count(); }
ERR_ForceRecord Find(int id) { return m_ById.Get(id); }
ERR_ForceRecord FindGroup(SCR_AIGroup group)
{
if (!group || !m_Groups.Contains(group)) return null;
return Find(m_Groups.Get(group));
}
ERR_ForceRecord At(int index)
{
if (index < 0 || index >= m_Records.Count()) return null;
return m_Records[index];
}
ERR_ForceRecord Register(string sourceId, ERR_ForceKind kind, vector position, int strength,
ERR_ForceRoute route = null, SCR_AIGroup group = null, float now = 0)
{
if (!Replication.IsServer() || sourceId.IsEmpty() || sourceId.Length() > 96
|| !ERR_ForceRules.FiniteVector(position) || !ERR_ForceRules.Finite(now) || now < 0
|| strength < 1 || strength > 32 || kind < ERR_ForceKind.GARRISON || kind > ERR_ForceKind.CONVOY) return null;
if (m_Sources.Contains(sourceId))
{
ERR_ForceRecord known = Find(m_Sources.Get(sourceId));
if (known && known.Kind == kind && known.Group == group) return known;
return null;
}
if (group && FindGroup(group)) return null;
// Retired source IDs also occupy the finite pool: re-registering a removed
// formation cannot replace its casualties with a fresh full-strength unit.
if (m_Records.Count() >= 128 || m_Sources.Count() >= 128)
{
if (!m_CapLogged) Print("Rereintroduced [ledger]: 128-formation registration cap reached.");
m_CapLogged = true;
return null;
}
if (route && !route.Seal()) return null;
ERR_ForceRecord record = new ERR_ForceRecord();
record.Id = ++m_NextId; record.SourceId = sourceId; record.Kind = kind;
record.Position = position; record.HomePosition = position; record.DutyTarget = position;
record.Route = route; record.InitialStrength = strength; record.Strength = strength;
record.Group = group; record.Physical = group != null;
record.LastSimulatedAt = now; record.StateSince = now;
if (route) record.Duty = ERR_ForceDuty.PATROL_ROUTE;
else record.Duty = ERR_ForceDuty.HOLD;
record.HomeDuty = record.Duty;
m_Records.Insert(record); m_ById.Set(record.Id, record); m_Sources.Set(sourceId, record.Id);
if (group) m_Groups.Set(group, record.Id);
return record;
}
bool Unregister(int id)
{
if (!Replication.IsServer()) return false;
ERR_ForceRecord record = Find(id);
if (!record) return false;
if (record.Group) m_Groups.Remove(record.Group);
m_ById.Remove(id); m_Records.RemoveItem(record);
return true;
}
bool Eliminate(int id, float now)
{
if (!Replication.IsServer() || !ERR_ForceRules.Finite(now) || now < 0) return false;
ERR_ForceRecord record = Find(id);
if (!record || now < record.LastSimulatedAt) return false;
if (record.Eliminated) return true;
record.Strength = 0; record.Casualties = record.InitialStrength;
record.Eliminated = true; record.Physical = false; record.Pinned = false;
record.Duty = ERR_ForceDuty.HOLD; record.RouteFinished = true;
record.LastSimulatedAt = now; record.StateSince = now;
return true;
}
bool RecordPhysicalState(int id, vector position, float heading, int living, float now, bool physical = true)
{
if (!Replication.IsServer() || !ERR_ForceRules.FiniteVector(position)
|| !ERR_ForceRules.Finite(heading) || !ERR_ForceRules.Finite(now)) return false;
ERR_ForceRecord record = Find(id);
if (!record || record.Eliminated || now < record.LastSimulatedAt || living < 0 || living > record.Strength)
return false;
record.Position = position; record.Heading = heading;
if (living == 0) return Eliminate(id, now);
record.Strength = living; record.Casualties = record.InitialStrength - living;
if (record.Physical != physical) record.StateSince = now;
record.Physical = physical; record.LastSimulatedAt = now;
return true;
}
string Status()
{
int physical, dormant, eliminated, living, casualties;
foreach (ERR_ForceRecord record : m_Records)
{
if (record.Eliminated) eliminated++;
else if (record.Physical) physical++;
else dormant++;
living += record.Strength; casualties += record.Casualties;
}
return string.Format("Forces: %1 records, %2 physical, %3 dormant, %4 eliminated; %5 living, %6 casualties",
Count(), physical, dormant, eliminated, living, casualties);
}
void Clear()
{
m_Records.Clear(); m_ById.Clear(); m_Sources.Clear(); m_Groups.Clear();
m_NextId = 0; m_CapLogged = false;
}
}
@@ -0,0 +1,116 @@
class ERR_ForceRules
{
static bool Finite(float value) { return value == value && value > -1e20 && value < 1e20; }
static bool FiniteVector(vector value)
{
return Finite(value[0]) && Finite(value[1]) && Finite(value[2]);
}
static float Speed(ERR_ForceKind kind, ERR_ForceDuty duty)
{
if (kind < ERR_ForceKind.GARRISON || kind > ERR_ForceKind.CONVOY
|| duty <= ERR_ForceDuty.HOLD || duty > ERR_ForceDuty.RETURN) return 0;
if (kind == ERR_ForceKind.CONVOY) return 11;
return 1.4;
}
static int Advance(int index, int count, bool loop, bool arrived)
{
if (count < 1 || count > 64 || index < 0 || index > count) return -1;
if (!arrived || index == count) return index;
if (index + 1 < count) return index + 1;
if (loop) return 0;
return count; // terminal sentinel: a non-looping route finishes once
}
static vector Step(vector from, vector to, float speed, float seconds, out bool arrived)
{
arrived = false;
if (!FiniteVector(from) || !FiniteVector(to) || !Finite(speed) || !Finite(seconds)
|| speed < 0 || seconds < 0) return from;
float distance = vector.DistanceXZ(from, to);
if (distance <= 0.001) { arrived = true; return to; }
float travel = speed * seconds;
if (!Finite(travel)) return from;
if (travel >= distance) { arrived = true; return to; }
// Interpolate authored endpoint elevations, never query terrain/navmesh.
return from + (to - from) * (travel / distance);
}
static bool Due(int id, float now, float lastSimulatedAt, float interval)
{
if (id < 0 || !Finite(now) || !Finite(lastSimulatedAt) || !Finite(interval)
|| now < 0 || interval < 1000 || interval > 60000) return false;
int phaseIndex = id % 8;
float phase = phaseIndex * interval / 8;
if (lastSimulatedAt < 0) return now >= phase;
if (now - lastSimulatedAt < 1000) return false;
return Math.Floor((now - phase) / interval) > Math.Floor((lastSimulatedAt - phase) / interval);
}
static float Heading(vector from, vector to, float previous)
{
vector delta = to - from;
if (vector.DistanceXZ(from, to) <= 0.001) return previous;
float heading = Math.Atan2(delta[0], delta[2]) * Math.RAD2DEG;
if (heading < 0) heading += 360;
return heading;
}
}
// Called by the existing slow mission tick. Dormant arithmetic never consults
// an entity, observer, terrain or navmesh and never allocates per-record work.
class ERR_ForceSimulation
{
protected ERR_ForceLedger m_Ledger;
void ERR_ForceSimulation(ERR_ForceLedger ledger) { m_Ledger = ledger; }
protected void Route(ERR_ForceRecord record, float distance)
{
ERR_ForceRoute route = record.Route;
if (!route || record.RouteFinished || record.RouteIndex < 0 || record.RouteIndex >= route.Count()) return;
int visited;
while (visited <= route.Count())
{
vector target = route.At(record.RouteIndex);
float leg = vector.DistanceXZ(record.Position, target);
record.Heading = ERR_ForceRules.Heading(record.Position, target, record.Heading);
if (leg > distance)
{
bool arrived;
record.Position = ERR_ForceRules.Step(record.Position, target, 1, distance, arrived);
record.RouteProgress += distance;
return;
}
record.Position = target;
distance -= leg;
record.RouteProgress = 0;
record.RouteIndex = ERR_ForceRules.Advance(record.RouteIndex, route.Count(), route.Looped(), true);
if (record.RouteIndex == route.Count())
{
record.RouteFinished = true;
record.Duty = ERR_ForceDuty.HOLD;
return;
}
// At an authored vertex a whole lap returns to this exact state. Skip whole
// laps arithmetically, then visit at most one remaining lap (64 points).
if (route.Looped() && route.Length() > 0 && distance >= route.Length())
distance -= Math.Floor(distance / route.Length()) * route.Length();
if (distance <= 0) return;
visited++;
}
}
void Tick(float now)
{
if (!Replication.IsServer() || !m_Ledger) return;
for (int i = 0; i < m_Ledger.Count(); i++)
{
ERR_ForceRecord record = m_Ledger.At(i);
if (!record || record.Physical || record.Eliminated || record.Strength <= 0
|| !ERR_ForceRules.Due(record.Id, now, record.LastSimulatedAt, 2000)) continue;
float seconds = (now - record.LastSimulatedAt) / 1000;
record.LastSimulatedAt = now;
float speed = ERR_ForceRules.Speed(record.Kind, record.Duty);
if (speed <= 0 || seconds <= 0 || !ERR_ForceRules.Finite(speed * seconds)) continue;
if (record.Duty == ERR_ForceDuty.PATROL_ROUTE) { Route(record, speed * seconds); continue; }
record.Heading = ERR_ForceRules.Heading(record.Position, record.DutyTarget, record.Heading);
bool arrived;
record.Position = ERR_ForceRules.Step(record.Position, record.DutyTarget, speed, seconds, arrived);
if (arrived) record.Duty = ERR_ForceDuty.HOLD;
}
}
}
@@ -22,6 +22,11 @@ class ERR_GameMode : SCR_BaseGameMode
protected ref ERR_PlayerRoster m_Roster = new ERR_PlayerRoster();
protected ref ERR_AdminService m_Admin = new ERR_AdminService();
protected ref ERR_CommandService m_Command;
protected ref ERR_ForceLedger m_Forces = new ERR_ForceLedger();
protected ref ERR_ForceSimulation m_ForceSimulation;
protected ref array<AIAgent> m_ForceAgents = {};
protected int m_ForceSerial;
protected float m_NextForceSnapshot;
protected ref array<ERR_LocationComponent> m_Locations = {};
protected ref array<IEntity> m_OwnedEntities = {};
protected ref ERR_ExtractionService m_Extraction;
@@ -61,6 +66,7 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_RecoveryService Recovery() { return m_Recovery; }
ERR_PrisonEscapeService PrisonEscape() { return m_PrisonEscape; }
string RecoveryStatus() { return m_RecoveryStatus; }
ERR_ForceLedger Forces() { return m_Forces; }
ERR_CommandService Command() { return m_Command; }
ERR_Objectives Objectives() { return m_Objectives; }
ERR_ExtractionService Extraction() { return m_Extraction; }
@@ -206,10 +212,45 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (!Replication.IsServer() || !group) return;
Own(group);
if (group.GetFaction() == EnemyFaction() && !m_Forces.FindGroup(group))
{
int strength = ForceLiving(group);
if (strength > 0)
{
m_ForceSerial++;
ERR_ForceRecord record = m_Forces.Register("formation-" + m_ForceSerial.ToString(), ERR_ForceKind.GARRISON, group.GetOrigin(), strength, null, group, GetGame().GetWorld().GetWorldTime());
if (!record) { Print("Rereintroduced: force ledger refused physical formation", LogLevel.ERROR); return; }
}
}
if (m_Command) m_Command.RegisterGroup(group);
m_OnGroup.Invoke(group);
}
int ForceLiving(SCR_AIGroup group)
{
m_ForceAgents.Clear();
if (!group) return 0;
group.GetAgents(m_ForceAgents);
int living;
foreach (AIAgent agent : m_ForceAgents)
if (agent && ERR_Actors.Alive(agent.GetControlledEntity())) living++;
return living;
}
protected void TickForces(float now)
{
if (now >= m_NextForceSnapshot)
{
m_NextForceSnapshot = now + 2000;
for (int i = 0; i < m_Forces.Count(); i++)
{
ERR_ForceRecord record = m_Forces.At(i);
if (!record || !record.Physical || record.Eliminated) continue;
if (!record.Group) { m_Forces.Eliminate(record.Id, now); continue; }
m_Forces.RecordPhysicalState(record.Id, record.Group.GetOrigin(), record.Group.GetAngles()[0], ForceLiving(record.Group), now);
}
}
if (m_ForceSimulation) m_ForceSimulation.Tick(now);
}
bool Transition(ERR_Phase phase)
{
if (!Replication.IsServer() || !m_PreparationError.IsEmpty() || !ERR_MissionRules.CanTransition(m_Phase, phase)) return false;
@@ -330,6 +371,7 @@ class ERR_GameMode : SCR_BaseGameMode
OnPhaseChanged().Insert(m_Objectives.PhaseChanged);
OnTick().Insert(m_Objectives.Tick);
OnStop().Insert(m_Objectives.Stop);
m_ForceSimulation = new ERR_ForceSimulation(m_Forces);
m_Command = CreateCommandService();
GetGame().GetCallqueue().CallLater(ServerTick, 1000, true);
GetGame().GetCallqueue().CallLater(StartScenario, 1500, false);
@@ -406,6 +448,7 @@ class ERR_GameMode : SCR_BaseGameMode
if (m_Command) m_Command.Maintain(now);
// Maintenance can finish or stop the round; do not resume tactical work.
if (!Active()) return;
TickForces(now);
if (GetGame().GetPlayerManager().GetPlayerCount() == 0)
{
m_NoSurvivorsSince = -1;
@@ -469,6 +512,8 @@ class ERR_GameMode : SCR_BaseGameMode
GetGame().GetCallqueue().Remove(BindLocalObserver);
if (m_Command) m_Command.Stop();
m_OnStop.Invoke();
m_Forces.Clear();
m_ForceAgents.Clear();
if (Replication.IsServer())
{
array<int> connected = {};
+4 -1
View File
@@ -8,11 +8,12 @@ class ERR_Settings
static array<string> Keys()
{
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile"};
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn"};
}
static bool Valid(string key, int value)
{
if (key == "stingerChanceOneIn") return value >= 0 && value <= 10000;
if (key == "reportIntervalSeconds") return value >= 5 && value <= 60;
if (key == "supportSquadLimit") return value >= 0 && value <= 8;
if (key == "responseRadiusMetres") return value >= 500 && value <= 5000;
@@ -44,6 +45,7 @@ class ERR_Settings
static int AddedDefault(string key)
{
if (key == "stingerChanceOneIn") return 15;
if (key == "startTimeMinutes") return 840;
if (key == "timeMultiplier") return 1;
if (key == "startDateCode" || key == "weatherProfile") return 0;
@@ -63,6 +65,7 @@ class ERR_Settings
m_Values.Set("timeMultiplier", AddedDefault("timeMultiplier"));
m_Values.Set("startDateCode", AddedDefault("startDateCode"));
m_Values.Set("weatherProfile", AddedDefault("weatherProfile"));
m_Values.Set("stingerChanceOneIn", AddedDefault("stingerChanceOneIn"));
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
map<string, int> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded;
+22 -1
View File
@@ -51,6 +51,14 @@ class ERR_SiteService
if (site.Transport) count++;
return count;
}
// Own physical group identity only; command providers do not inspect players.
ERR_SiteRuntime FindGroup(SCR_AIGroup group)
{
if (!group) return null;
foreach (ERR_SiteRuntime site : m_Sites)
if (site.Group == group) return site;
return null;
}
ERR_SiteRuntime Find(string id)
{
foreach (ERR_SiteRuntime site : m_Sites)
@@ -421,7 +429,20 @@ class ERR_SiteService
foreach (ERR_SiteRuntime site : m_Sites)
{
if (site.Definition.Kind == ERR_LocationKind.PRISON && site.Location) site.Location.Discover(0, 100);
if (site.Group) m_Mode.RegisterGroup(site.Group);
if (site.Group)
{
ERR_ForceRoute route;
ERR_ForceKind kind = ERR_ForceKind.GARRISON;
if (site.Definition.Patrol && !site.Definition.Patrol.IsEmpty())
{
route = new ERR_ForceRoute(true);
vector anchor = site.Definition.Position + site.Definition.GuardOffset;
foreach (ERR_SitePatrolPoint point : site.Definition.Patrol) route.Add(anchor + point.Offset);
kind = ERR_ForceKind.PATROL;
}
m_Mode.Forces().Register(site.Definition.Id, kind, site.Group.GetOrigin(), m_Mode.ForceLiving(site.Group), route, site.Group, GetGame().GetWorld().GetWorldTime());
m_Mode.RegisterGroup(site.Group);
}
}
}
void PhaseChanged(ERR_Phase previous, ERR_Phase current)