Split checkpoint guards between VirtualHQ and local defence; record Workshop 0.0.151
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-10 13:33:00 +01:00
parent 4b737c825f
commit 9889bd6404
23 changed files with 1557 additions and 78 deletions
@@ -218,9 +218,9 @@ class ERR_AdminService
if (key == "objectGuardHQMode") return "0: all hold their posts; 1: half hold, half respond to HQ; 2: all may respond to HQ. All report contacts. Next round.";
if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. HQ participation is configured separately; all report. Towers and chimneys use suitable platforms. Lost guards are not replaced.";
if (key == "hunterSpawnDistanceMetres") return "Opening UAZ and troop-truck patrol placement. Applies next round.";
if (key == "largeRoadblockCount") return "Set 0..30 main-road checkpoints (default 4; 0 disables large posts). Eight guards per post. Applies next round; unsuitable sites are skipped.";
if (key == "largeRoadblockCount") return "Set 0..30 main-road checkpoints (default 4; 0 disables large posts). Four guards: two HQ responders, two local sentries outside HQ. Extended cover. Applies next round; unsuitable sites are skipped.";
if (key == "mainRoadWidthMetres") return "Set main-road minimum width to 5..12 metres (default 6). Wider roads use large checkpoints; narrower roads use small ones. Applies next round.";
if (key == "roadblockCount") return "Set 0..30 small-road checkpoints (default 4; 0 disables small posts). Four guards per post. Applies next round; unsuitable sites are skipped.";
if (key == "roadblockCount") return "Set 0..30 small-road checkpoints (default 4; 0 disables small posts). Four guards: two HQ responders, two local sentries outside HQ. Compact cover. Applies next round; unsuitable sites are skipped.";
if (key == "telemetryEnabled") return "Default on. Sends mission results and player identities to the configured statistics service. Disabling clears unsent events immediately.";
if (key == "restartOnEmpty") return "On (1) restarts after the last disconnect; Off (0) preserves the round for reconnects. Default On. Applies immediately; normal round endings still apply.";
if (key == "emptyRestartHours") return "0 disables idle restarts; 1..168 real hours continuously empty. Default 5. Applies immediately, measured from mission start or the last disconnect. Connected players, including spectators, prevent idle restarts.";
@@ -14,12 +14,18 @@ class ERR_RoadblockProbeRules
return 0;
}
static int Target() { return SmallTarget() + LargeTarget(); }
static int Guards() { return SmallTarget() * 4 + LargeTarget() * 8; }
static int Guards() { return Target() * 4; }
static int Props() { return SmallTarget() * 8 + LargeTarget() * 14; }
}
modded class ERR_HQ
{
bool ERR_ProbeRoadblockRegistered(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) return true;
return false;
}
bool ERR_ProbeRoadblockGroup(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
@@ -48,7 +54,7 @@ modded class ERR_Roadblocks
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "roadblocks") { super.BuildPost(); return; }
// Inspect the actual spawned models before the transaction clears its refs.
if (m_BuildHold)
if (m_BuildHold && m_BuildLocalHold)
{
bool valid = ShareProps();
for (int i = 0; i < m_BuildCovers.Count() && valid; i++)
@@ -110,15 +116,39 @@ modded class ERR_Roadblocks
}
int ERR_ProbePostCount() { return m_Positions.Count(); }
bool ERR_ProbeBackgroundPending() { return m_Background; }
bool ERR_ProbePostPair(int index)
{
if (index < 0 || index >= m_Groups.Count() || index >= m_LocalGroups.Count()) return false;
SCR_AIGroup group = m_Groups[index];
SCR_AIGroup localGroup = m_LocalGroups[index];
ERR_HQ hq = ERR_HQ.Get();
if (!group || !localGroup || group == localGroup || !hq || !Contains(group) || LocalOnly(group) || Contains(localGroup) || !LocalOnly(localGroup)) return false;
if (!hq.ERR_ProbeRoadblockGroup(group) || hq.ERR_ProbeRoadblockRegistered(localGroup)) return false;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
ERR_ForceRecord localRecord = m_Mode.Forces().FindGroup(localGroup);
if (!record || !localRecord || record == localRecord || record.SourceId == localRecord.SourceId) return false;
array<SCR_AIGroup> pair = {group, localGroup};
array<IEntity> bodies = {};
foreach (SCR_AIGroup memberGroup : pair)
{
ERR_ForceRecord memberRecord = m_Mode.Forces().FindGroup(memberGroup);
if (!memberRecord || !memberRecord.Physical || memberRecord.Eliminated || memberRecord.Kind != ERR_ForceKind.CHECKPOINT || memberRecord.InitialStrength != 2 || m_Mode.ForceLiving(memberGroup) != 2) return false;
if (memberGroup.GetFaction() != m_Mode.EnemyFaction()) return false;
array<AIAgent> agents = {}; memberGroup.GetAgents(agents);
if (agents.Count() != 2) return false;
foreach (AIAgent agent : agents)
{
if (!agent || !agent.GetControlledEntity() || bodies.Contains(agent.GetControlledEntity())) return false;
bodies.Insert(agent.GetControlledEntity());
}
}
return bodies.Count() == 4;
}
bool ERR_ProbeCompleteRoadblocks()
{
if (m_ERRLayoutFailed || m_ERRLayoutPosts < ERR_RoadblockProbeRules.Target() || m_Target != ERR_RoadblockProbeRules.Target() || m_Positions.Count() != m_Target || m_Background || !m_Ready) return false;
foreach (SCR_AIGroup group : m_Groups)
{
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
if (!group || !record || record.Kind != ERR_ForceKind.CHECKPOINT || (record.InitialStrength != 4 && record.InitialStrength != 8) || m_Mode.ForceLiving(group) != record.InitialStrength) return false;
if (group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
}
if (m_Groups.Count() != m_Target || m_LocalGroups.Count() != m_Target) return false;
for (int index = 0; index < m_Groups.Count(); index++) if (!ERR_ProbePostPair(index)) return false;
if (m_SmallPlaced != ERR_RoadblockProbeRules.SmallTarget() || m_LargePlaced != ERR_RoadblockProbeRules.LargeTarget()) return false;
int count = m_Positions.Count();
BackgroundStep();
@@ -129,16 +159,13 @@ modded class ERR_Roadblocks
{
if (!m_Ready || m_Target != m_SmallTarget + m_LargeTarget || m_Groups.IsEmpty() || m_Groups.Count() > m_Target || m_Groups.Count() != m_Positions.Count()) return false;
int count = m_Groups.Count();
foreach (SCR_AIGroup group : m_Groups)
{
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
if (!record || record.Kind != ERR_ForceKind.CHECKPOINT || (record.InitialStrength != 4 && record.InitialStrength != 8) || m_Mode.ForceLiving(group) != record.InitialStrength
|| group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
}
if (m_LocalGroups.Count() != count) return false;
for (int index = 0; index < count; index++) if (!ERR_ProbePostPair(index)) return false;
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
// Repeated preparation cannot replenish or create another squad.
if (Prepare(definition) != ERR_PrepareResult.READY || m_Groups.Count() != count) return false;
PrintFormat("ERR ROADBLOCK NATIVE: posts=%1 soldiers=%2 ledger=checkpoint HQ=available repeatedPrepare=unchanged", count, m_SmallPlaced * 4 + m_LargePlaced * 8);
if (Prepare(definition) != ERR_PrepareResult.READY || m_Groups.Count() != count || m_LocalGroups.Count() != count) return false;
for (int after = 0; after < count; after++) if (!ERR_ProbePostPair(after)) return false;
PrintFormat("ERR ROADBLOCK NATIVE: posts=%1 soldiers=%2 ledger=two-pairs HQ=two local=two repeatedPrepare=unchanged", count, count * 4);
return true;
}
}
@@ -176,7 +203,7 @@ modded class ERR_GameMode
PrintFormat("ERR SMOKE PASS: roadblocks prison unlocked with %1/%2 posts; placement observation started", m_ERRRoadblockInitial, ERR_RoadblockProbeRules.Target());
}
if (posts.ERR_ProbeBackgroundPending()) return;
if (!posts.ERR_ProbeCompleteRoadblocks() || (ERR_RoadblockProbeRules.Target() > 4 && posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial)) { ERR_RoadblockProbeFail("physical small/large posts, independent quotas, replicated props, ledger and HQ admission"); return; }
if (!posts.ERR_ProbeCompleteRoadblocks() || (ERR_RoadblockProbeRules.Target() > 4 && posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial)) { ERR_RoadblockProbeFail("physical small/large posts, independent quotas, replicated props, separate pair ledgers and two-only HQ admission"); return; }
PrintFormat("ERR SMOKE PASS: roadblocks %1 physical posts / %2 guards / %3 replicated checkpoint props with distance streaming disabled; verified model footprints and centre lane; placement complete after prison unlock, no extra posts on repeat tick", posts.ERR_ProbePostCount(), ERR_RoadblockProbeRules.Guards(), ERR_RoadblockProbeRules.Props());
ERR_StopRoadblockProbe();
Transition(ERR_Phase.DEFEAT);
@@ -631,6 +631,13 @@ class ERR_HQ : ERR_CommandService
override void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !group) return;
// Local checkpoint guards retain normal equipment and native defence AI,
// without HQ reporting, status sampling or remote tasking.
if (m_Roadblocks && m_Roadblocks.LocalOnly(group))
{
m_Equipment.RegisterGroup(group, 0, false);
return;
}
// Other hostile factions retain their own intelligence, but receive the same
// scoped skill/aim profile. Do not share enemy HQ knowledge across hostile sides.
if (m_Mode.IsMissionEnemy(group.GetFaction()) && group.GetFaction() != m_Mode.EnemyFaction()) m_Equipment.RegisterGroup(group, 0, false);
@@ -11,7 +11,6 @@ class ERR_RoadblockRules
}
static int GuardCount(bool large)
{
if (large) return 8;
return 4;
}
static bool LargeRoad(float width, float threshold) { return width >= threshold; }
@@ -58,6 +57,7 @@ class ERR_Roadblocks
protected ref ERR_RoadblockMining m_Mining;
protected ref array<vector> m_Positions = {};
protected ref array<SCR_AIGroup> m_Groups = {};
protected ref array<SCR_AIGroup> m_LocalGroups = {};
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected ref ERR_SettlementSafety m_Settlements = new ERR_SettlementSafety();
protected bool m_Begun, m_Ready, m_Stopped, m_HaveCandidate, m_Background;
@@ -68,8 +68,9 @@ class ERR_Roadblocks
protected float m_Shoulder;
protected bool m_Large;
protected int m_SmallTarget, m_LargeTarget, m_SmallPlaced, m_LargePlaced, m_MainRoadWidth;
protected SCR_AIGroup m_BuildGroup;
protected AIWaypoint m_BuildHold;
protected SCR_AIGroup m_BuildGroup, m_BuildLocalGroup;
protected int m_PostSerial;
protected AIWaypoint m_BuildHold, m_BuildLocalHold;
protected ref array<IEntity> m_BuildSpawned = {};
protected ref array<vector> m_BuildPositions = {};
protected ref array<ref ERR_RoadblockProp> m_BuildCovers = {};
@@ -82,6 +83,7 @@ class ERR_Roadblocks
void ERR_Roadblocks(ERR_GameMode mode) { m_Mode = mode; m_Mining = new ERR_RoadblockMining(mode); }
void MineTick(array<ref ERR_HQUnit> units, array<IEntity> attempted, float now) { if (!m_Stopped) m_Mining.Tick(units, attempted, now); }
bool Contains(SCR_AIGroup group) { return group && m_Groups.Contains(group); }
bool LocalOnly(SCR_AIGroup group) { return group && m_LocalGroups.Contains(group); }
string Status()
{
if (!m_Begun) return "Roadblocks: awaiting terrain preparation.";
@@ -89,7 +91,7 @@ class ERR_Roadblocks
if (m_Ready && m_Positions.Count() < m_Target) progress = "placement stopped; see server log";
if (m_Background) progress = "background placement pending";
else if (!m_Ready) progress = "initial placement in progress";
return string.Format("Checkpoints: small %1/%2 (4 guards), large %3/%4 (8 guards). Main-road cutoff %5 m. No replacements. %6.", m_SmallPlaced, m_SmallTarget, m_LargePlaced, m_LargeTarget, m_MainRoadWidth, progress);
return string.Format("Checkpoints: small %1/%2 (4 guards), large %3/%4 (4 guards). Each post: 2 HQ responders, 2 local guards. Main-road cutoff %5 m. No replacements. %6.", m_SmallPlaced, m_SmallTarget, m_LargePlaced, m_LargeTarget, m_MainRoadWidth, progress);
}
protected ERR_PrepareResult Finish(string reason)
{
@@ -256,7 +258,8 @@ class ERR_Roadblocks
protected void CancelPost()
{
Rollback(m_BuildSpawned, m_BuildGroup);
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
ERR_Actors.DeleteSpawned(m_BuildLocalGroup);
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null; m_BuildLocalGroup = null; m_BuildLocalHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
}
// A free Game Master camera does not force-stream bare, non-editable props
@@ -265,7 +268,7 @@ class ERR_Roadblocks
// Do not report a completed post if any physical prop lacks a network node.
protected bool ShareProps()
{
if (m_BuildCovers.Count() != ERR_RoadblockRules.PropCount(m_Large) || m_BuildSpawned.Count() != m_BuildPositions.Count() + m_BuildCovers.Count() + 1) return false;
if (m_BuildCovers.Count() != ERR_RoadblockRules.PropCount(m_Large) || m_BuildSpawned.Count() != m_BuildPositions.Count() + m_BuildCovers.Count() + 2) return false;
for (int i = m_BuildPositions.Count(); i < m_BuildPositions.Count() + m_BuildCovers.Count(); i++)
{
IEntity entity = m_BuildSpawned[i];
@@ -283,6 +286,15 @@ class ERR_Roadblocks
protected void BuildPost()
{
if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); return; }
// Separate native groups prevent HQ orders from pulling the local pair away.
// Create the second group on its own callback, within the same transaction.
if (!m_BuildLocalGroup)
{
m_BuildLocalGroup = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", m_Centre));
if (!m_BuildLocalGroup) { CancelPost(); return; }
m_BuildLocalGroup.SetFaction(m_Mode.EnemyFaction()); m_BuildLocalGroup.SetSpawnImmediately(false); m_BuildLocalGroup.SetDeleteWhenEmpty(false); m_BuildLocalGroup.SetPermanentLOD(0);
return;
}
// One actor/prop/waypoint per callback. Recheck moving obstructions and
// player distance while the transaction is in progress; roll back on failure.
if (m_BuildActors < m_BuildPositions.Count())
@@ -292,7 +304,10 @@ class ERR_Roadblocks
IEntity actor = ERR_Actors.Spawn(m_Roster[m_BuildActors % m_Roster.Count()], point);
if (!actor) { CancelPost(); return; }
m_BuildSpawned.Insert(actor);
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !m_BuildGroup.AddAIEntityToGroup(actor)) { CancelPost(); return; }
// Alternate the roster and shoulders: each pair gets a guard on each side.
SCR_AIGroup guardGroup = m_BuildGroup;
if (m_BuildActors % 2 == 1) guardGroup = m_BuildLocalGroup;
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !guardGroup.AddAIEntityToGroup(actor)) { CancelPost(); return; }
m_BuildActors++;
return;
}
@@ -312,29 +327,48 @@ class ERR_Roadblocks
m_BuildSpawned.Insert(m_BuildHold); m_BuildHold.SetCompletionRadius(25);
return;
}
if (!m_BuildLocalHold)
{
m_BuildLocalHold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), m_BuildPositions[1]));
if (!m_BuildLocalHold) { CancelPost(); return; }
m_BuildSpawned.Insert(m_BuildLocalHold); m_BuildLocalHold.SetCompletionRadius(25);
return;
}
if (!ShareProps())
{
PrintFormat("Reintroduced [roadblocks]: rejected post at %1; checkpoint props missing replication nodes", m_Centre);
CancelPost(); return;
}
string id = "roadblock-" + m_Positions.Count().ToString();
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, m_BuildPositions.Count(), null, m_BuildGroup, GetGame().GetWorld().GetWorldTime()))
{ CancelPost(); return; }
// Both pairs stay in the finite force ledger, but only one joins HQ. A failed
// second admission retires the first ID; a later attempt uses a fresh serial.
m_PostSerial++;
string id = "roadblock-" + m_PostSerial.ToString();
int strength = m_BuildPositions.Count() / 2;
ERR_ForceRecord responders = m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, strength, null, m_BuildGroup, GetGame().GetWorld().GetWorldTime());
if (!responders) { CancelPost(); return; }
if (!m_Mode.Forces().Register(id + "-local", ERR_ForceKind.CHECKPOINT, m_Centre, strength, null, m_BuildLocalGroup, GetGame().GetWorld().GetWorldTime()))
{
m_Mode.Forces().Unregister(responders.Id);
CancelPost(); return;
}
foreach (IEntity ownedEntity : m_BuildSpawned) m_Mode.Own(ownedEntity);
m_Mode.Own(m_BuildGroup); m_Groups.Insert(m_BuildGroup); m_Positions.Insert(m_Centre);
m_Mode.Own(m_BuildLocalGroup); m_LocalGroups.Insert(m_BuildLocalGroup);
if (m_Large) m_LargePlaced++; else m_SmallPlaced++;
m_BuildLocalGroup.AddWaypoint(m_BuildLocalHold);
m_BuildGroup.AddWaypoint(m_BuildHold);
m_Mode.RegisterGroup(m_BuildGroup); m_BuildGroup.ActivateAI();
m_Mode.RegisterGroup(m_BuildLocalGroup); m_BuildLocalGroup.ActivateAI();
m_Mining.Register(m_BuildGroup, m_BuildHold, m_Right);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 large=%3 guards=%4 props=%5 networkedProps=%5 propStreaming=always roadPassage=open HQ=registered", id, m_Centre, m_Large, m_BuildPositions.Count(), m_BuildCovers.Count());
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
PrintFormat("Reintroduced [roadblocks]: %1 at %2 large=%3 guards=%4 props=%5 networkedProps=%5 propStreaming=always roadPassage=open HQ=2 local=2", id, m_Centre, m_Large, m_BuildPositions.Count(), m_BuildCovers.Count());
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null; m_BuildLocalGroup = null; m_BuildLocalHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
m_NextSearch = System.GetTickCount() + 2000;
}
protected vector GuardPosition(int side, int row)
{
float along = side * (row * 2 - 2.5);
if (m_Large) along = row * 6 - 9;
if (m_Large) along = row * 18 - 9;
return ERR_Actors.Ground(m_Centre + m_Right * (side * (m_Shoulder + 1.5)) + m_Forward * along);
}
// 0: reject, 1: awaiting native tile, 2: ready.