Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

703 lines
46 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--- before/README.md
+++ after/README.md
@@ -76,4 +76,4 @@
Random field hospitals and camps, their permanent/mobile guard split, finite supplies and authoring options are documented in [Field sites](docs/field-sites.md).
-Town population and its 700-metre spawning rule: [town garrisons](docs/town-garrisons.md).
+Town population, configurable factory guards and safe tower/roof posts: [town and object garrisons](docs/town-garrisons.md).
--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c
@@ -8,12 +8,12 @@
{
static array<string> Keys()
{
- return {"hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
+ return {"objectGuardCount", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
}
static bool Available(string key, bool hq, bool intel)
{
if (key == "aiIntelChancePercent") return intel;
- if (key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
+ if (key == "objectGuardCount" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
return Keys().Contains(key);
}
// A short journal has no older off-screen lines, even if its slider is zero.
@@ -44,7 +44,8 @@
static string Command(string key, string value)
{
string prefix;
- if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance";
+ if (key == "objectGuardCount") prefix = "mission objectguards";
+ else if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance";
else if (key == "roadblockCount") prefix = "mission roadblocks";
else if (key == "civilianTrafficCount") prefix = "mission traffic";
else if (key == "startMode") prefix = "mission start";
--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c
@@ -4,6 +4,7 @@
{
static string CoreSetting(string section, string option)
{
+ if (section == "mission" && option == "objectguards") return "objectGuardCount";
if (section == "mission" && option == "hunterdistance") return "hunterSpawnDistanceMetres";
if (section == "mission" && option == "roadblocks") return "roadblockCount";
if (section == "mission" && option == "traffic") return "civilianTrafficCount";
@@ -145,6 +146,7 @@
}
protected string SettingHelp(string key)
{
+ if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. Half stay at posts (odd extra stays), half respond to HQ; both report. Spawn only 1000m from every player as sites approach 1400m. Towers/chimneys use safe platforms; no replacement waves.";
if (key == "hunterSpawnDistanceMetres") return "Set 800..3000 metres (default 1000). Applies next round to the opening UAZs and troop truck, on separate sides. Roads allow 250m variation; unsuitable terrain uses safe objective-road fallback.";
if (key == "roadblockCount") return "Set 0..12 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped.";
if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved.";
@@ -157,7 +159,7 @@
}
protected string RoadblockTiming(string key)
{
- if (key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round";
+ if (key == "objectGuardCount" || key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round";
return "";
}
protected void SettingCommand(ERR_Settings settings, string key, array<string> parts, array<string> replies)
--- before/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c
+++ after/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c
@@ -9,11 +9,12 @@
static array<string> Keys()
{
- return {"hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
+ return {"objectGuardCount", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
+ if (key == "objectGuardCount") return value >= 0 && value <= 16;
if (key == "hunterSpawnDistanceMetres") return value >= 800 && value <= 3000;
if (key == "roadblockCount") return value >= 0 && value <= 12;
if (key == "civilianTrafficCount") return value >= 0 && value <= 6;
@@ -102,6 +103,7 @@
static int AddedDefault(string key)
{
+ if (key == "objectGuardCount") return 8;
if (key == "hunterSpawnDistanceMetres") return 1000;
if (key == "roadblockCount") return 4;
if (key == "civilianTrafficCount") return 3;
@@ -122,6 +124,7 @@
void ERR_Settings(string path = PATH)
{
m_Path = path;
+ m_Values.Set("objectGuardCount", AddedDefault("objectGuardCount"));
m_Values.Set("hunterSpawnDistanceMetres", AddedDefault("hunterSpawnDistanceMetres"));
m_Values.Set("roadblockCount", AddedDefault("roadblockCount"));
m_Values.Set("civilianTrafficCount", AddedDefault("civilianTrafficCount"));
--- before/addons/core/Scripts/Game/ERR/10_AI/ERR_TownGarrisons.c
+++ after/addons/core/Scripts/Game/ERR/10_AI/ERR_TownGarrisons.c
@@ -10,11 +10,11 @@
if (!ERR_ForceRules.FiniteVector(player) || vector.DistanceXZ(position, player) < minimum) return false;
return true;
}
- static bool Approached(vector centre, array<vector> players)
+ static bool Approached(vector centre, array<vector> players, float distance = DISTANCE)
{
if (!players || !ERR_ForceRules.FiniteVector(centre)) return false;
foreach (vector player : players)
- if (ERR_ForceRules.FiniteVector(player) && vector.DistanceXZ(centre, player) <= DISTANCE) return true;
+ if (ERR_ForceRules.FiniteVector(player) && vector.DistanceXZ(centre, player) <= distance) return true;
return false;
}
}
@@ -25,6 +25,9 @@
string Id;
string Name;
int Count;
+ bool FixedGuard;
+ ref ERR_GuardedObjectSite ObjectSite;
+ int GuardIndex;
bool Finished;
bool Admitted;
float NextLog;
@@ -37,14 +40,20 @@
// check, so native expansion cannot introduce late soldiers beside a player.
class ERR_TownGarrisons
{
+ protected ref ERR_ObjectGarrisons m_Objects;
+ protected int m_ObjectGuardCount;
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_TownGarrison> m_Towns = {};
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected float m_Next;
protected int m_Cursor;
protected bool m_Stopped;
- void ERR_TownGarrisons(ERR_GameMode mode) { m_Mode = mode; }
-
+ void ERR_TownGarrisons(ERR_GameMode mode) { m_Mode = mode; m_ObjectGuardCount = mode.Settings().Get("objectGuardCount"); }
+
+ protected bool ObjectsOnly() { return false; }
+ protected float SpawnDistance() { return 700; }
+ protected float ActivationDistance() { return 700; }
+ protected void Admitted(ERR_TownGarrison town) {}
protected void Players(out array<vector> positions)
{
positions.Clear();
@@ -88,6 +97,7 @@
{
if (!group) return false;
foreach (ERR_TownGarrison town : m_Towns) if (town.Group == group && town.Admitted) return true;
+ if (m_Objects && m_Objects.Contains(group)) return true;
return false;
}
protected void Waiting(ERR_TownGarrison town, float now, string reason)
@@ -109,7 +119,7 @@
float angle = ((first + direction) * 15) * Math.DEG2RAD;
float radius = Math.Min(ring * 40, 185);
vector anchor = ERR_Actors.Ground(town.Centre + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius));
- if (!ERR_TownSpawnRules.Safe(anchor, players, 710)) continue;
+ if (!ERR_TownSpawnRules.Safe(anchor, players, SpawnDistance() + 10)) continue;
if (navmesh && !navmesh.IsTileLoaded(anchor)) { navmesh.LoadTileIn(anchor); continue; }
positions.Clear();
map<int, vector> reserved = new map<int, vector>();
@@ -121,7 +131,7 @@
vector navigable;
if (!path.GetClosestPositionOnNavmesh(point, "3 3 3", navigable)) break;
point = ERR_Actors.Ground(navigable);
- if (vector.DistanceXZ(point, town.Centre) > ERR_TownSpawnRules.TOWN_RADIUS || !ERR_TownSpawnRules.Safe(point, players, 702) || !m_Placement.Clear(point, reserved)) break;
+ if (vector.DistanceXZ(point, town.Centre) > ERR_TownSpawnRules.TOWN_RADIUS || !ERR_TownSpawnRules.Safe(point, players, SpawnDistance() + 2) || !m_Placement.Clear(point, reserved)) break;
positions.Insert(point); reserved.Set(slot, point);
}
if (positions.Count() == town.Count) return true;
@@ -130,7 +140,7 @@
}
protected void Attempt(ERR_TownGarrison town, array<vector> players, float now)
{
- if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players)) return;
+ if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players, ActivationDistance())) return;
if (m_Mode.Forces().Count() >= 250) { Waiting(town, now, "mission force budget is full"); return; }
if (!town.Group)
{
@@ -142,7 +152,7 @@
town.Group.SetPermanentLOD(0);
}
array<vector> positions = {};
- if (!Plan(town, players, positions)) { Waiting(town, now, "no clear navigable town position at least 700 m from every player"); return; }
+ if (!Plan(town, players, positions)) { Waiting(town, now, "no clear site position outside the player exclusion distance"); return; }
Resource profile = Resource.Load(m_Mode.FactionSelection().Choice("enemy").Group);
array<ResourceName> roster = {};
IEntitySource source;
@@ -158,17 +168,17 @@
}
Players(players);
foreach (vector planned : positions)
- if (!ERR_TownSpawnRules.Safe(planned, players)) { Waiting(town, now, "a player approached during preparation"); return; }
+ if (!ERR_TownSpawnRules.Safe(planned, players, SpawnDistance())) { Waiting(town, now, "a player approached during preparation"); return; }
town.Finished = true;
float nearest = 1e20;
for (int index = 0; index < positions.Count(); index++)
{
Players(players);
- if (!ERR_TownSpawnRules.Safe(positions[index], players)) { Fail(town, "player entered the exclusion area during creation"); return; }
+ if (!ERR_TownSpawnRules.Safe(positions[index], players, SpawnDistance())) { Fail(town, "player entered the exclusion area during creation"); return; }
IEntity actor = ERR_Actors.Spawn(roster[index % roster.Count()], positions[index]);
if (!actor) { Fail(town, "native soldier creation failed"); return; }
m_Mode.Own(actor);
- if (!ERR_TownSpawnRules.Safe(actor.GetOrigin(), players) || !ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !town.Group.AddAIEntityToGroup(actor))
+ if (!ERR_TownSpawnRules.Safe(actor.GetOrigin(), players, SpawnDistance()) || !ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !town.Group.AddAIEntityToGroup(actor))
{ ERR_Actors.DeleteSpawned(actor); Fail(town, "native soldier placement or group admission failed"); return; }
foreach (vector observer : players) nearest = Math.Min(nearest, vector.DistanceXZ(actor.GetOrigin(), observer));
}
@@ -178,8 +188,10 @@
town.Admitted = true;
town.Hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), positions[0]));
if (town.Hold) { m_Mode.Own(town.Hold); town.Hold.SetCompletionRadius(60); town.Group.AddWaypoint(town.Hold); }
- m_Mode.RegisterGroup(town.Group); town.Group.ActivateAI();
- ERR_HQ.Event("TOWN GARRISON", string.Format("%1 at %2: spawned %3 enemy soldiers; nearest player at creation %4 m. All report to VirtualHQ and are available for orders. One squad this round; no replacements.", town.Name, town.Centre, town.Count, Math.Round(nearest)));
+ m_Mode.RegisterGroup(town.Group); Admitted(town); town.Group.ActivateAI();
+ string role = "VirtualHQ response";
+ if (town.FixedGuard) role = "fixed local guard; reports to VirtualHQ";
+ ERR_HQ.Event("TOWN GARRISON", string.Format("%1 at %2: spawned %3 soldiers; nearest player %4 m; role=%5; post=%6. No replacements this round.", town.Name, town.Centre, town.Count, Math.Round(nearest), role, positions[0]));
}
protected void Fail(ERR_TownGarrison town, string reason)
{
@@ -191,6 +203,15 @@
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || m_Mode.Phase() <= ERR_Phase.PRISON || now < m_Next) return;
m_Next = now + 2000;
+ if (!ObjectsOnly())
+ {
+ if (!m_Objects)
+ {
+ m_Objects = new ERR_ObjectGarrisons(m_Mode);
+ m_Objects.ConfigureCount(m_ObjectGuardCount);
+ }
+ m_Objects.Tick(now);
+ }
array<vector> players = {}; Players(players);
if (players.IsEmpty()) return;
Discover(players);
@@ -200,13 +221,14 @@
{
m_Cursor = m_Cursor % m_Towns.Count();
ERR_TownGarrison town = m_Towns[m_Cursor++];
- if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players)) continue;
+ if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players, ActivationDistance())) continue;
Attempt(town, players, now); break;
}
}
void Stop()
{
m_Stopped = true;
+ if (m_Objects) m_Objects.Stop();
foreach (ERR_TownGarrison town : m_Towns)
{
if (town.Hold) { if (town.Group) town.Group.RemoveWaypoint(town.Hold); ERR_Actors.DeleteSpawned(town.Hold); town.Hold = null; }
@@ -215,3 +237,223 @@
}
}
}
+
+class ERR_GuardedObjectSite
+{
+ vector Centre;
+ string Id;
+ ref array<IEntity> Structures = {};
+}
+
+class ERR_ObjectGuardRules
+{
+ static int Fixed(int total) { return (total + 1) / 2; }
+ static bool Structure(string prefab, int type)
+ {
+ prefab.ToLower();
+ // Industrial crates, barrels and military equipment are not guarded sites.
+ if (prefab.Contains("/props/") || prefab.Contains("/weapons/") || prefab.Contains("/characters/") || prefab.Contains("/vehicles/") || prefab.Contains("/buildingparts/") || prefab.Contains("/flags/") || prefab.Contains("/ruins/")) return false;
+ if (prefab.Contains("factory") || prefab.Contains("industrial") || prefab.Contains("warehouse") || prefab.Contains("chimney") || prefab.Contains("watchtower") || prefab.Contains("watertower") || prefab.Contains("military") || prefab.Contains("powerplant")) return true;
+ return type == EMapDescriptorType.MDT_STACK || type == EMapDescriptorType.MDT_TOWER
+ || type == EMapDescriptorType.MDT_WATERTOWER || type == EMapDescriptorType.MDT_FUELSTATION
+ || type == EMapDescriptorType.MDT_TRANSMITTER || type == EMapDescriptorType.MDT_FORTRESS
+ || type == EMapDescriptorType.MDT_BUNKER || type == EMapDescriptorType.MDT_PORT;
+ }
+ static bool Support(float fraction, float normalY, float delta)
+ {
+ return ERR_ForceRules.Finite(fraction) && fraction >= 0 && fraction < 1
+ && ERR_ForceRules.Finite(normalY) && normalY >= 0.85 && ERR_ForceRules.Finite(delta) && Math.AbsFloat(delta) <= 0.2;
+ }
+}
+
+// Static structure surfaces only. Never turn a chimney's bounding-box top into
+// an assumed platform: every footprint corner must hit actual solid geometry.
+class ERR_ObjectGuardPlacement
+{
+ protected int m_Traces;
+ void Reset() { m_Traces = 128; }
+ protected float Trace(TraceParam params, bool staticOnly = false)
+ {
+ if (m_Traces <= 0) return -1;
+ m_Traces--;
+ if (staticOnly) return GetGame().GetWorld().TraceMove(params, StaticSurface);
+ return GetGame().GetWorld().TraceMove(params, null);
+ }
+ protected bool StaticSurface(IEntity entity, vector start = "0 0 0", vector direction = "0 0 0")
+ {
+ return !Vehicle.Cast(entity) && !ChimeraCharacter.Cast(entity);
+ }
+ bool Clear(vector feet)
+ {
+ if (!ERR_ForceRules.FiniteVector(feet) || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), feet)) return false;
+ for (int x = -1; x <= 1; x++)
+ for (int z = -1; z <= 1; z++)
+ {
+ TraceParam floor = new TraceParam();
+ vector sample = feet + Vector(x * 0.45, 0, z * 0.45);
+ floor.Start = sample + "0 0.25 0"; floor.End = sample - "0 0.45 0";
+ floor.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
+ float fraction = Trace(floor, true);
+ float surfaceY = floor.Start[1] - fraction * 0.7;
+ if (!ERR_ObjectGuardRules.Support(fraction, floor.TraceNorm[1], surfaceY - (feet[1] - 0.1))) return false;
+ }
+ TraceBox body = new TraceBox();
+ body.Start = feet + "0 0.05 0"; body.End = body.Start + "0 0.01 0";
+ body.Mins = "-0.45 0 -0.45"; body.Maxs = "0.45 1.8 0.45";
+ body.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
+ if (Trace(body) < 1) return false;
+ // Reject enclosed flues and cramped interior pockets with no useful outlook.
+ int open;
+ for (int side = 0; side < 4; side++)
+ {
+ float angle = side * 90 * Math.DEG2RAD;
+ TraceParam view = new TraceParam(); view.Start = feet + "0 1.5 0";
+ view.End = view.Start + Vector(Math.Sin(angle) * 3, 0, Math.Cos(angle) * 3);
+ view.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
+ if (Trace(view) >= 1) open++;
+ }
+ return open >= 2;
+ }
+ bool Find(IEntity structure, array<vector> players, int offset, out vector position)
+ {
+ if (!structure || m_Traces <= 0) return false;
+ vector low, high; structure.GetWorldBounds(low, high);
+ if (!ERR_ForceRules.FiniteVector(low) || !ERR_ForceRules.FiniteVector(high) || high[1] - low[1] > 150) return false;
+ for (int cell = 0; cell < 9; cell++)
+ {
+ int index = (cell + offset) % 9;
+ vector seed = (low + high) * 0.5;
+ int column = index % 3;
+ int row = index / 3;
+ seed[0] = seed[0] + (column - 1) * Math.Min((high[0] - low[0]) * 0.3, 8);
+ seed[2] = seed[2] + (row - 1) * Math.Min((high[2] - low[2]) * 0.3, 8);
+ if (!ERR_TownSpawnRules.Safe(seed, players, 1002)) continue;
+ float ceiling = high[1] + 2;
+ // Roof first, then up to three lower floors/platforms inside the structure.
+ for (int floorIndex = 0; floorIndex < 4; floorIndex++)
+ {
+ TraceParam down = new TraceParam(); down.Start = Vector(seed[0], ceiling, seed[2]);
+ down.End = Vector(seed[0], low[1] - 0.5, seed[2]); down.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
+ float fraction = Trace(down, true);
+ if (fraction >= 1 || fraction < 0) break;
+ vector point = down.Start + (down.End - down.Start) * fraction + "0 0.1 0";
+ // Ground beside a building is not an elevated/interior post. Keep looking
+ // for a platform; ordinary ground fallback must pass the navmesh planner.
+ float ground = GetGame().GetWorld().GetSurfaceY(point[0], point[2]);
+ if (point[1] >= ground + 0.6 && Clear(point)) { position = point; return true; }
+ ceiling = point[1] - 0.6;
+ if (ceiling <= low[1]) break;
+ }
+ }
+ return false;
+ }
+}
+
+// Reuse the town service's synchronous creation, final all-player distance
+// checks, native faction roster, force ledger and once-per-round lifecycle.
+class ERR_ObjectGarrisons : ERR_TownGarrisons
+{
+ protected int m_Count;
+ protected float m_DiscoverAt;
+ protected int m_QueryCount;
+ protected ref array<ref ERR_GuardedObjectSite> m_Sites = {};
+ protected ref ERR_ObjectGuardPlacement m_Posts = new ERR_ObjectGuardPlacement();
+ void ERR_ObjectGarrisons(ERR_GameMode mode) { m_Mode = mode; m_Count = mode.Settings().Get("objectGuardCount"); }
+ // Parent snapshots the setting at round setup, before the prison opens.
+ void ConfigureCount(int count) { m_Count = count; }
+ override protected bool ObjectsOnly() { return true; }
+ override protected float SpawnDistance() { return 1000; }
+ override protected float ActivationDistance() { return 1400; }
+ override protected bool Settlement(IEntity entity)
+ {
+ if (!entity || Vehicle.Cast(entity) || ChimeraCharacter.Cast(entity)) return false;
+ string prefab;
+ if (entity.GetPrefabData()) prefab = entity.GetPrefabData().GetPrefabName();
+ MapDescriptorComponent descriptor = MapDescriptorComponent.Cast(entity.FindComponent(MapDescriptorComponent));
+ int type = -1; if (descriptor) type = descriptor.GetBaseType();
+ return ERR_ObjectGuardRules.Structure(prefab, type);
+ }
+ override protected bool Remember(IEntity entity)
+ {
+ if (++m_QueryCount > 256) return false;
+ vector centre = entity.GetOrigin();
+ ERR_GuardedObjectSite site;
+ foreach (ERR_GuardedObjectSite known : m_Sites)
+ if (vector.DistanceXZ(known.Centre, centre) < 250) { site = known; break; }
+ if (site)
+ {
+ if (site.Structures.Count() < 24 && !site.Structures.Contains(entity)) site.Structures.Insert(entity);
+ return true;
+ }
+ if (m_Sites.Count() >= 32) return true;
+ site = new ERR_GuardedObjectSite(); site.Centre = centre; site.Id = "object-" + m_Sites.Count().ToString();
+ site.Structures.Insert(entity); m_Sites.Insert(site);
+ int fixed = ERR_ObjectGuardRules.Fixed(m_Count);
+ // Each fixed sentry has its own post/defend group: a rooftop sentry must not
+ // be ordered down toward a shared ground-level squad leader.
+ for (int i = 0; i <= fixed; i++)
+ {
+ int count = 1; if (i == fixed) count = m_Count - fixed;
+ if (count == 0) continue;
+ ERR_TownGarrison entry = new ERR_TownGarrison();
+ entry.ObjectSite = site; entry.Centre = centre; entry.Count = count; entry.FixedGuard = i < fixed; entry.GuardIndex = i;
+ entry.Id = site.Id + "-post-" + i.ToString(); entry.Name = entry.Id;
+ m_Towns.Insert(entry);
+ }
+ ERR_HQ.Event("OBJECT GUARDS", string.Format("%1 at %2: planned %3 fixed guards and %4 HQ responders; activation 1400m, spawn exclusion 1000m", site.Id, centre, fixed, m_Count - fixed));
+ return true;
+ }
+ override protected void Discover(array<vector> players)
+ {
+ float now = GetGame().GetWorld().GetWorldTime();
+ if (m_Count <= 0 || now < m_DiscoverAt) return;
+ m_DiscoverAt = now + 10000;
+ foreach (vector player : players)
+ {
+ m_QueryCount = 0;
+ GetGame().GetWorld().QueryEntitiesByAABB(player - "1400 5000 1400", player + "1400 5000 1400", Remember, Settlement, EQueryEntitiesFlags.ALL);
+ }
+ }
+ protected bool Tower(IEntity entity)
+ {
+ if (!entity) return false;
+ string prefab; if (entity.GetPrefabData()) prefab = entity.GetPrefabData().GetPrefabName();
+ prefab.ToLower();
+ if (prefab.Contains("tower") || prefab.Contains("chimney")) return true;
+ MapDescriptorComponent descriptor = MapDescriptorComponent.Cast(entity.FindComponent(MapDescriptorComponent));
+ if (!descriptor) return false;
+ int type = descriptor.GetBaseType();
+ return type == EMapDescriptorType.MDT_STACK || type == EMapDescriptorType.MDT_TOWER || type == EMapDescriptorType.MDT_WATERTOWER;
+ }
+ override protected bool Plan(ERR_TownGarrison town, array<vector> players, out array<vector> positions)
+ {
+ if (town.FixedGuard && town.ObjectSite)
+ {
+ m_Posts.Reset();
+ int count = town.ObjectSite.Structures.Count();
+ int checked;
+ // Prefer tower/chimney platforms before roofs on the rest of the compound.
+ for (int priority = 0; priority < 2; priority++)
+ for (int i = 0; i < count && checked < 8; i++)
+ {
+ IEntity structure = town.ObjectSite.Structures[(i + town.GuardIndex) % count];
+ bool tower = Tower(structure);
+ if ((priority == 0 && !tower) || (priority == 1 && tower)) continue;
+ checked++;
+ vector point;
+ if (!m_Posts.Find(structure, players, town.GuardIndex, point)) continue;
+ // Keep each structure associated with its original compound.
+ if (vector.DistanceXZ(point, town.Centre) > 250) continue;
+ positions.Clear(); positions.Insert(point); return true;
+ }
+ }
+ return super.Plan(town, players, positions);
+ }
+ override protected void Admitted(ERR_TownGarrison town)
+ {
+ if (!town.FixedGuard) return;
+ ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
+ if (hq) hq.Dedicate(town.Group, town.ObjectSite.Id);
+ if (town.Hold) town.Hold.SetCompletionRadius(2);
+ }
+}
--- before/addons/core/UI/layouts/ERR/AdminMenu.layout
+++ after/addons/core/UI/layouts/ERR/AdminMenu.layout
@@ -145,26 +145,31 @@
TextWidgetClass { Name "Saved_roadblockCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 232 OffsetTop 232 SizeX 456 OffsetRight -964 SizeY 20 OffsetBottom -252 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "Help_roadblockCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 252 OffsetTop 252 SizeX 456 OffsetRight -964 SizeY 14 OffsetBottom -266 } Color 0.9 0.92 0.94 1 Text "Four guards per post. Unsuitable sites are skipped." "Font Size" 12 "Min Font Size" 12 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass {
- Name "WeaponRetentionLabel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 264 OffsetTop 264 SizeX 500 OffsetRight -528 SizeY 32 OffsetBottom -296 }
+ Name "WeaponRetentionLabel" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 278 OffsetTop 278 SizeX 444 OffsetRight -472 SizeY 24 OffsetBottom -302 }
Color 0.9 0.92 0.94 1 Text "Keep weapon when unconscious" "Font Size" 20 "Min Font Size" 20
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
TextWidgetClass {
- Name "Saved_keepWeaponOnUnconscious" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 308 OffsetTop 308 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -334 }
+ Name "Saved_keepWeaponOnUnconscious" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 346 OffsetTop 346 SizeX 444 OffsetRight -472 SizeY 20 OffsetBottom -366 }
Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 17 "Min Font Size" 17 Wrap 1
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {
- Name "KeepWeaponOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 264 OffsetTop 264 SizeX 140 OffsetRight -800 SizeY 42 OffsetBottom -306 }
+ Name "KeepWeaponOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 306 OffsetTop 306 SizeX 140 OffsetRight -168 SizeY 36 OffsetBottom -342 }
components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enabled" } }
}
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {
- Name "KeepWeaponOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 812 OffsetLeft 812 PositionY 264 OffsetTop 264 SizeX 152 OffsetRight -964 SizeY 42 OffsetBottom -306 }
+ Name "KeepWeaponOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 180 OffsetLeft 180 PositionY 306 OffsetTop 306 SizeX 152 OffsetRight -332 SizeY 36 OffsetBottom -342 }
components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disabled" } }
}
-TextWidgetClass {
- Name "MissionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 340 OffsetTop 340 SizeX 936 OffsetRight -964 SizeY 42 OffsetBottom -382 }
- Color 0.9 0.92 0.94 1 Text "Enabled: your held firearm stays with you and is equipped after revival. Disabled: normal weapon dropping. Applies from the next knockout; existing casualties keep their chosen rule." "Font Size" 19 "Min Font Size" 19 Wrap 1
+TextWidgetClass { Name "Label_objectGuardCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 278 OffsetTop 278 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -302 } Color 0.9 0.92 0.94 1 Text "Guards per object site (0-16; 0 off)" "Font Size" 20 "Min Font Size" 20 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
+ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "objectGuardCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 306 OffsetTop 306 SizeX 100 OffsetRight -608 SizeY 36 OffsetBottom -342 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } }
+ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_objectGuardCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 620 OffsetLeft 620 PositionY 306 OffsetTop 306 SizeX 112 OffsetRight -732 SizeY 36 OffsetBottom -342 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply guards" } } }
+TextWidgetClass { Name "Saved_objectGuardCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 346 OffsetTop 346 SizeX 456 OffsetRight -964 SizeY 20 OffsetBottom -366 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
+TextWidgetClass { Name "Help_objectGuardCount" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 370 OffsetTop 370 SizeX 456 OffsetRight -964 SizeY 16 OffsetBottom -386 } Color 0.9 0.92 0.94 1 Text "Half hold posts, half HQ. Spawn 1 km away. Next round." "Font Size" 12 "Min Font Size" 12 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
+TextWidgetClass {
+ Name "MissionHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 370 OffsetTop 370 SizeX 444 OffsetRight -472 SizeY 16 OffsetBottom -386 }
+ Color 0.9 0.92 0.94 1 Text "Applies next knockout; existing casualties keep their rule." "Font Size" 12 "Min Font Size" 12 Wrap 1
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
}
TextWidgetClass {
--- before/docs/town-garrisons.md
+++ after/docs/town-garrisons.md
@@ -19,3 +19,19 @@
`tests/test_town_garrisons.py` executes the actual distance/activation policy with exact boundaries, split players, empty/invalid observations and a player crossing the exclusion distance. `tests/test_intel_registration.py` checks the actual HQ registration method's town radio selection and unrestricted order eligibility. The isolated `town-garrisons` Workbench fixture exercises native map descriptors, navigation, three- and ten-member physical squads, blocked placement with a second observer in town, HQ binding and no replacement after elimination. It is not a live dedicated-server playtest.
The 24 September 2026 local Workbench test completed with **12 passing assertions and no script/runtime failures**. Minimum player distances at creation were 718 m for the three-man squad and 720 m for the ten-man squad. Repository checks passed. Existing RHS/WCS localization and vanilla resource keyword errors remain in the native log. The runner hit a transient exiting-process classification error during cleanup; a separate read-only follow-up confirmed that its private prefix was idle and addon sources were unchanged. Original runner results are preserved alongside the follow-up in `.local/analysis/town-garrisons-2026-09-24`.
+
+## Factory and infrastructure guards
+
+The Mission menu now has **Object guards per site** (`objectGuardCount`, or `/esc mission objectguards set N`). The range is 0–16 soldiers per site, default 8; zero disables these additional guards. The setting is captured when the round's HQ is created and changes apply next round. Existing town populations keep their separate 3–10 soldier / 700 m rules.
+
+After the prison opens, the service discovers nearby industrial, military and infrastructure structures from their prefab paths or map descriptor types. It groups structures within 250 m into one site, rather than spawning a squad for each building. Props, vehicles, weapons, characters, building fragments, flags and ruins are excluded. Custom terrain objects need a recognizable structure path or descriptor; unlabelled custom assets are not guaranteed to be discovered.
+
+Sites activate within 1,400 m, but every actual guard spawn must remain at least **1,000 m horizontally from every controlled player**. Unsafe or unnavigable sites wait, and rapid approaches or split players may prevent some guards from spawning. There are at most 32 discovered sites, 24 stored structures per site and one group attempt per two seconds in this service, subject to the shared mission force budget. Discovery runs every ten seconds.
+
+Half the soldiers stay as fixed guards (the extra soldier stays when the count is odd). Each has its own small defend post and is reserved from general HQ retasking. The other half form a mobile squad that VirtualHQ can order away. Both use the selected enemy faction and the existing equipment, radio and observation systems; proximity activation does not tell HQ where players are.
+
+Fixed guards first try tower and chimney platforms, then other roofs or raised interior floors. Placement traces require solid support under the whole footprint, sufficient standing space and an open outlook. Narrow rims, holes, steep surfaces and enclosed flues are rejected. If no usable post is found within the bounded search, the guard uses the ordinary navigable ground planner. Responders always use navigable ground. These are finite formations with no replacement waves after casualties and no distance-based respawn cycle.
+
+`OBJECT GUARDS` logs identify site discovery and the planned fixed/response split. `TOWN GARRISON` admission logs include the role, post coordinate and minimum player distance. The setting persistence, boundaries, fixed/response split, support rules and fixed-post HQ admission hook are exercised by the repository tests. Native validation evidence for this addition is recorded separately in `docs/evidence/object-guards-2026-10-06`.
+
+The 6 October 2026 isolated Linux engine test spawned eight soldiers at a real Everon industrial compound: four fixed guards on four separate light-tower platforms approximately 15.11 m above ground, and four mobile ground soldiers. All eight survived the 33-second observation after AI release. Each tower guard received a physical HQ manpack, all four fixed groups were reserved, and the response group remained eligible for HQ orders. Geometry probes also tested 80 real structures. This proves the exercised placement/registration path, not every custom terrain, chimney asset or a multiplayer firefight.
--- before/tests/test_admin_menu.py
+++ after/tests/test_admin_menu.py
@@ -10,7 +10,7 @@
layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array<string> Keys('))
settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array<string> Keys('))
-assert len(keys) == 19 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"}
+assert len(keys) == 20 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"}
assert set(keys) <= set(settings) # Every menu field must have a server setting.
# Native layouts place child widget declarations inside an anonymous Children
# block. A missing brace can preserve most widget names yet prevent menu load.
@@ -48,7 +48,7 @@
assert len(names) == len(set(names)), 'Menu widget names must be unique'
for key in keys:
assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Help_' + key))
- if key not in ('startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
+ if key not in ('startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names
else: assert 'Random_startMode' not in names
for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'JournalContent', 'JournalScroll', 'PauseJournal', 'LatestJournal', 'Status'):
assert name in names
@@ -99,7 +99,7 @@
{"intelRevealChancePercent","intel reveal"},{"startTimeMinutes","mission time"},
{"timeMultiplier","mission speed"},{"startDateCode","mission date"},
{"weatherProfile","mission weather"},{"viewDistanceMetres","mission viewdistance"},{"reportIntervalSeconds","hq interval"},
- {"hunterSpawnDistanceMetres","mission hunterdistance"},{"roadblockCount","mission roadblocks"},{"civilianTrafficCount","mission traffic"},{"heliTimerMinutes","hq heli timer"},{"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"},{"antiAirChancePercent","hq aa"},{"antiPersonnelMineChancePercent","hq mines"}};
+ {"objectGuardCount","mission objectguards"},{"hunterSpawnDistanceMetres","mission hunterdistance"},{"roadblockCount","mission roadblocks"},{"civilianTrafficCount","mission traffic"},{"heliTimerMinutes","hq heli timer"},{"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"},{"antiAirChancePercent","hq aa"},{"antiPersonnelMineChancePercent","hq mines"}};
for(auto pair:cases){
assert(Fields::Command(pair.first," 42 ")==pair.second+" set 42");
assert(Fields::Command(pair.first,"Random")==pair.second+" set Random");
@@ -114,7 +114,7 @@
for(string key:{"automaticEquipmentChancePercent","heliTimer","prisonDistance","stingerChance","mission abandon","","bad"}){
assert(Fields::Command(key,"1").empty());assert(!Fields::Available(key,true,true));
}
- for(string key:{"heliTimerMinutes","reportIntervalSeconds","supportSquadLimit","responseRadiusMetres","convoyCount","antiAirChancePercent","antiPersonnelMineChancePercent"}){
+ for(string key:{"objectGuardCount","heliTimerMinutes","reportIntervalSeconds","supportSquadLimit","responseRadiusMetres","convoyCount","antiAirChancePercent","antiPersonnelMineChancePercent"}){
assert(!Fields::Available(key,false,true));assert(Fields::Available(key,true,false));
}
assert(!Fields::Available("aiIntelChancePercent",true,false));
@@ -122,7 +122,7 @@
for(string key:{"intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile"}) assert(Fields::Available(key,false,false));
}
''')
-print('PASS: actual menu command routing covers all nineteen settings, rejects unsupported keys, gates companions, and matches unique authored widgets')
+print('PASS: actual menu command routing covers all twenty settings, rejects unsupported keys, gates companions, and matches unique authored widgets')
assert all(name in names for name in ('KeepWeaponOn', 'KeepWeaponOff', 'Saved_keepWeaponOnUnconscious'))
click=body(source,'protected void OnButton(')
--- before/tests/test_admin_setting_policy.py
+++ after/tests/test_admin_setting_policy.py
@@ -13,6 +13,10 @@
int main(){
assert(CoreSetting("intel","reveal")=="intelRevealChancePercent");
assert(CoreSetting("mission","radio")=="radioDefenceSeconds");
+ assert(CoreSetting("mission","objectguards")=="objectGuardCount");
+ assert(CoreSetting("hq","objectguards").empty());
+ assert(Valid("objectGuardCount",0)&&Valid("objectGuardCount",8)&&Valid("objectGuardCount",16));
+ assert(!Valid("objectGuardCount",-1)&&!Valid("objectGuardCount",17));
for(std::string section:{"", "hq", "mission", "intel", "unknown"})
for(std::string option:{"", "interval", "carrying", "radio", "reveal", "unknown"}){
bool recognized=(section=="intel"&&option=="reveal")||(section=="mission"&&option=="radio");
--- before/tests/test_start_settings.py
+++ after/tests/test_start_settings.py
@@ -56,6 +56,14 @@
disk["old"]={{"reportIntervalSeconds",10},{"supportSquadLimit",4},{"responseRadiusMetres",2000},{"aiIntelChancePercent",60},{"intelRevealChancePercent",15},{"radioDefenceSeconds",180}};
ERR_Settings settings;assert(settings.Read("old",settings.m_Values)&&settings.Get("startMode")==0);
for(float bad:{-1.f,-2.f,2.f,.5f}){disk["bad"]=disk["old"];disk["bad"]["startMode"]=bad;Values ignored;assert(!settings.Read("bad",ignored));}
+ assert(settings.Get("objectGuardCount")==8);
+ assert(!ERR_Settings::Randomizable("objectGuardCount"));
+ assert(!settings.Set("objectGuardCount",-1)&&!settings.Set("objectGuardCount",17));
+ for(int guards:{0,1,8,16}){
+ assert(settings.Set("objectGuardCount",guards));
+ ERR_Settings savedGuards;assert(savedGuards.Read("settings",savedGuards.m_Values)&&savedGuards.Get("objectGuardCount")==guards);
+ }
+ failSave=true;assert(!settings.Set("objectGuardCount",8)&&settings.Get("objectGuardCount")==16);failSave=false;
assert(settings.Get("hunterSpawnDistanceMetres")==1000);
assert(!ERR_Settings::Randomizable("hunterSpawnDistanceMetres"));
assert(!settings.Set("hunterSpawnDistanceMetres",799)&&!settings.Set("hunterSpawnDistanceMetres",3001));
--- before/tests/test_town_garrisons.py
+++ after/tests/test_town_garrisons.py
@@ -26,6 +26,82 @@
split[0]={699.99f,0,0}; assert(!Safe(origin,&split)); // Crossing boundary before birth rejects it
split[0]={std::numeric_limits<float>::quiet_NaN(),0,0}; assert(!Safe(origin,&split));
assert(!Safe(split[0],&edge)&&!Approached(split[0],&edge));
+ Array<Vec> objectEdge={{1000,0,0}},approaching={{1400,0,0}},outside={{1401,0,0}};
+ assert(Safe(origin,&objectEdge,1000)&&!Safe(origin,&objectEdge,1002));
+ assert(Approached(origin,&approaching,1400)&&!Approached(origin,&outside,1400));
+ objectEdge.push_back({999.9f,0,0});assert(!Safe(origin,&objectEdge,1000));
+
}
''')
print('PASS: town trigger and hard 700m exclusion for all observers, empty/invalid positions and final-check crossings')
+
+# Execute the production guard split and geometric support policy, including
+# unsafe chimney openings, steep faces and invalid trace values.
+guards=body(p,'static int Fixed(')+'\n'+body(p,'static bool Support(')
+guards=guards.replace('ERR_ForceRules.Finite','std::isfinite').replace('Math.AbsFloat','std::fabs')
+run(r'''
+#include <cassert>
+#include <cmath>
+#include <limits>
+'''+guards+r'''
+int main(){
+ for(int count=0;count<=16;++count){
+ int fixed=Fixed(count),response=count-fixed;
+ assert(fixed+response==count&&fixed>=response&&fixed-response<=1);
+ }
+ assert(Fixed(8)==4&&Fixed(1)==1&&Fixed(0)==0);
+ assert(Support(.5f,1.f,0.f));
+ assert(!Support(1.f,1.f,0.f)); // No floor below this footprint corner
+ assert(!Support(-1.f,1.f,0.f)); // Exhausted trace budget
+ assert(!Support(.5f,.84f,0.f)); // Steep/sloped face
+ assert(!Support(.5f,1.f,.21f)); // Edge/hole: corner is on a different floor
+ auto nan=std::numeric_limits<float>::quiet_NaN();
+ assert(!Support(nan,1.f,0.f)&&!Support(.5f,nan,0.f)&&!Support(.5f,1.f,nan));
+}
+''')
+print('PASS: object guard split, hard 1000m exclusion / 1400m activation, footprint support, slopes, holes and invalid traces')
+
+# Fixed sentries use the real admission hook; responders keep normal HQ eligibility.
+admitted=body(p,'override protected void Admitted(').replace('override protected ','').replace('ERR_TownGarrison town','Town* town').replace('town.','town->').replace('town->ObjectSite.Id','town->ObjectSite->Id').replace('town->Hold.SetCompletionRadius','town->Hold->SetCompletionRadius').replace('ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());','HQ* hq = currentHQ;').replace('hq.Dedicate','hq->Dedicate')
+run(r'''
+#include <cassert>
+struct Site{int Id=17;};
+struct Hold{float radius=60;void SetCompletionRadius(float value){radius=value;}};
+struct Town{bool FixedGuard=false;Site*ObjectSite;Hold*Hold;int Group=19;};
+struct HQ{int calls=0;void Dedicate(int group,int site){assert(group==19&&site==17);calls++;}};
+HQ*currentHQ;
+'''.replace('Hold*Hold','struct Hold*Hold')+admitted+r'''
+int main(){
+ HQ hq;currentHQ=&hq;Site site;Hold post;Town response{false,&site,&post};
+ Admitted(&response);assert(hq.calls==0&&post.radius==60);
+ Town fixed{true,&site,&post};Admitted(&fixed);assert(hq.calls==1&&post.radius==2);
+}
+''')
+print('PASS: actual fixed-post admission reserves only stationary guards from HQ retasking and tightens their defend radius')
+
+import re
+structure=body(p,'static bool Structure(').replace('EMapDescriptorType.', 'EMapDescriptorType::')
+labels=sorted(set(re.findall(r'EMapDescriptorType::(\w+)',structure)))
+run(r'''
+#include <cassert>
+#include <string>
+#include <algorithm>
+#include <cctype>
+struct string:std::string{
+ using std::string::string;
+ void ToLower(){std::transform(begin(),end(),begin(),[](unsigned char c){return std::tolower(c);});}
+ bool Contains(const char*s){return find(s)!=npos;}
+};
+namespace EMapDescriptorType{enum {'''+','.join(labels)+r'''};}
+'''+structure+r'''
+int main(){
+ assert(Structure("Prefabs/Structures/Industrial/Houses/Workshop.et",-1));
+ assert(Structure("Prefabs/Structures/Watchtower.et",-1));
+ assert(Structure("Prefabs/Custom/FactoryChimney.et",-1));
+ assert(Structure("UnknownMod/Asset.et",EMapDescriptorType::MDT_TOWER));
+ assert(!Structure("UnknownMod/Asset.et",-1));
+ for(const char*path:{"Prefabs/Props/Industrial/Barrel.et","Prefabs/Structures/BuildingParts/Industrial/Wall.et","Prefabs/Structures/Military/Flags/Flag.et","Prefabs/Structures/Ruins/House_Chimney.et","Prefabs/Vehicles/Military/Truck.et"})
+ assert(!Structure(path,-1));
+}
+''')
+print('PASS: production site classification accepts structures/descriptors and excludes industrial props, fragments, flags, ruins and vehicles')