Files
EscapistsReintroduced/docs/evidence/roadblock-mining-2026-10-07/source/ERR_Roadblocks.c
T
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

229 lines
12 KiB
C

// Optional finite roadside checkpoints. Planned before player admission, using
// this terrain's road network and navmesh; never replenish casualties in-round.
class ERR_RoadblockRules
{
static bool Road(float width, float length, float heightDifference)
{
return ERR_ForceRules.Finite(width) && ERR_ForceRules.Finite(length) && ERR_ForceRules.Finite(heightDifference)
&& width >= 4 && width <= 16 && length >= 40 && Math.AbsFloat(heightDifference) <= 2;
}
static bool Spaced(vector point, array<vector> used, vector prison, float escapeRadius)
{
if (!ERR_ForceRules.FiniteVector(point) || !ERR_ForceRules.FiniteVector(prison) || !used || !ERR_ForceRules.Finite(escapeRadius)) return false;
float distance = vector.DistanceXZ(point, prison);
if (distance < 700 || distance + 200 >= escapeRadius) return false;
foreach (vector other : used)
if (!ERR_ForceRules.FiniteVector(other) || vector.DistanceXZ(point, other) < 600) return false;
return true;
}
}
class ERR_Roadblocks
{
protected ERR_GameMode m_Mode;
protected ref ERR_RoadblockMining m_Mining;
protected ref array<vector> m_Positions = {};
protected ref array<SCR_AIGroup> m_Groups = {};
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;
protected int m_Target, m_Attempts, m_Started, m_CandidateStarted, m_Candidates, m_Planned;
protected vector m_Low, m_High, m_Centre, m_Forward, m_Right;
protected float m_Shoulder;
protected ref array<ResourceName> m_Roster = {};
protected ref array<ref Resource> m_Loaded = {};
static const ResourceName COVER = "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et";
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); }
string Status()
{
if (!m_Begun) return "Roadblocks: awaiting terrain preparation.";
return string.Format("Roadblocks: %1/%2 placed this round; next-round setting %3. Four guards per post, no replacements.", m_Groups.Count(), m_Target, m_Mode.Settings().Get("roadblockCount"));
}
protected ERR_PrepareResult Finish(string reason)
{
m_Ready = true; m_Loaded.Clear();
PrintFormat("Reintroduced [roadblocks]: placed=%1 requested=%2 attempts=%3 roadCandidates=%4 clearPlans=%5; %6", m_Groups.Count(), m_Target, m_Attempts, m_Candidates, m_Planned, reason);
ERR_HQ.Event("ROADBLOCKS", Status() + " " + reason);
return ERR_PrepareResult.READY;
}
protected bool LoadRoster()
{
Resource profile = Resource.Load(m_Mode.FactionSelection().Choice("enemy").Group);
IEntitySource source;
if (profile && profile.IsValid()) source = profile.GetResource().ToEntitySource();
if (!source || !source.Get("m_aUnitPrefabSlots", m_Roster) || m_Roster.IsEmpty()) return false;
// Preload before physical creation. A failed prefab never leaves half a post.
array<ResourceName> resources = {COVER, "{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Resources.DefendWaypoint()};
foreach (ResourceName actor : m_Roster) resources.Insert(actor);
foreach (ResourceName prefab : resources)
{
Resource loaded = Resource.Load(prefab);
if (!loaded || !loaded.IsValid()) return false;
m_Loaded.Insert(loaded);
}
return true;
}
protected bool Candidate(ERR_ScenarioDefinition definition)
{
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager()) return false;
vector seed = Vector(Math.RandomFloat(m_Low[0], m_High[0]), 0, Math.RandomFloat(m_Low[2], m_High[2]));
array<BaseRoad> roads = {};
world.GetRoadNetworkManager().GetRoadsInAABB(seed - "600 5000 600", seed + "600 5000 600", roads);
if (roads.IsEmpty() || roads.Count() > 512) return false;
BaseRoad road = roads.GetRandomElement();
if (!road) return false;
array<vector> points = {}; road.GetPoints(points);
if (points.Count() < 2 || points.Count() > 2048) return false;
float length;
for (int i = 0; i < points.Count(); i++)
{
if (!ERR_ForceRules.FiniteVector(points[i])) return false;
if (i > 0) length += vector.DistanceXZ(points[i - 1], points[i]);
}
if (!ERR_RoadblockRules.Road(road.GetWidth(), length, 0)) return false;
float distance = Math.RandomFloat(20, length - 20);
vector centre, before, after, beforeDirection, afterDirection;
if (!ERR_ConvoyRoadPlan.Sample(points, distance, centre, m_Forward)
|| !ERR_ConvoyRoadPlan.Sample(points, distance - 15, before, beforeDirection)
|| !ERR_ConvoyRoadPlan.Sample(points, distance + 15, after, afterDirection)) return false;
if (vector.Dot(m_Forward, beforeDirection) < 0.95 || vector.Dot(m_Forward, afterDirection) < 0.95) return false;
m_Centre = ERR_Actors.Ground(centre);
if (!ERR_RoadblockRules.Road(road.GetWidth(), length, m_Centre[1] - centre[1])) return false;
ERR_SiteDefinition prison = definition.Find("everon-prison-1");
if (!prison || !ERR_RoadblockRules.Spaced(m_Centre, m_Positions, prison.Position, m_Mode.EscapeRadius())) return false;
foreach (ERR_SiteDefinition site : definition.Sites)
if (vector.DistanceXZ(site.Position, m_Centre) < 150) return false;
if (!m_Settlements.OutsideSettlements(m_Centre) || !ERR_HQRoadIntercept.Standable(m_Centre)) return false;
// Flatten so cover/guard offsets keep their metre distances on sloped roads.
m_Forward[1] = 0;
if (m_Forward.Length() < 0.1) return false;
m_Forward.Normalize();
m_Right = Vector(m_Forward[2], 0, -m_Forward[0]);
// Keep all physical cover outside the travelled road. Even a narrow road has
// a >=6m centre gap between these compact sandbag positions.
m_Shoulder = Math.Max(road.GetWidth() * 0.5 + 1.5, 4);
m_Candidates++; m_CandidateStarted = System.GetTickCount();
return true;
}
protected bool PlayersClear()
{
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
foreach (int id : ids)
{
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
if (actor && vector.DistanceXZ(actor.GetOrigin(), m_Centre) < 700) return false;
}
return true;
}
protected bool CoverClear(vector point)
{
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) return false;
for (int x = -1; x <= 1; x++)
for (int z = -1; z <= 1; z++)
{
vector sample = ERR_Actors.Ground(point + Vector(x * 1.5, 0, z * 1.5));
if (Math.AbsFloat(sample[1] - point[1]) > 0.3) return false;
}
TraceBox trace = new TraceBox(); trace.Start = point + "0 0.15 0"; trace.End = trace.Start + "0 0.01 0";
trace.Mins = "-1.5 0 -1.5"; trace.Maxs = "1.5 2 1.5"; trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
return GetGame().GetWorld().TraceMove(trace, null) == 1;
}
protected void Rollback(array<IEntity> spawned, SCR_AIGroup group)
{
foreach (IEntity entity : spawned) ERR_Actors.DeleteSpawned(entity);
ERR_Actors.DeleteSpawned(group);
}
protected bool SpawnPost(array<vector> positions, array<vector> covers)
{
if (!PlayersClear() || m_Mode.Forces().Count() >= 250) return false;
SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", m_Centre));
if (!group) return false;
group.SetFaction(m_Mode.EnemyFaction()); group.SetSpawnImmediately(false); group.SetDeleteWhenEmpty(false); group.SetPermanentLOD(0);
array<IEntity> spawned = {};
for (int i = 0; i < positions.Count(); i++)
{
if (!PlayersClear()) { Rollback(spawned, group); return false; }
IEntity actor = ERR_Actors.Spawn(m_Roster[i % m_Roster.Count()], positions[i]);
if (!actor) { Rollback(spawned, group); return false; }
spawned.Insert(actor);
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !group.AddAIEntityToGroup(actor)) { Rollback(spawned, group); return false; }
}
foreach (vector coverPosition : covers)
{
IEntity cover = ERR_Actors.Spawn(COVER, coverPosition, m_Forward.VectorToAngles());
if (!cover) { Rollback(spawned, group); return false; }
spawned.Insert(cover);
}
AIWaypoint hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), positions[0]));
if (!hold) { Rollback(spawned, group); return false; }
spawned.Insert(hold); hold.SetCompletionRadius(25); group.AddWaypoint(hold);
string id = "roadblock-" + m_Groups.Count().ToString();
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, 4, null, group, GetGame().GetWorld().GetWorldTime()))
{ Rollback(spawned, group); return false; }
foreach (IEntity ownedEntity : spawned) m_Mode.Own(ownedEntity);
m_Mode.Own(group); m_Groups.Insert(group); m_Positions.Insert(m_Centre);
m_Mode.RegisterGroup(group); group.ActivateAI();
m_Mining.Register(group, hold, m_Right);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 guards=4 roadPassage=open HQ=registered", id, m_Centre);
return true;
}
// 0: reject, 1: awaiting native tile, 2: ready.
protected int Plan(out array<vector> positions, out array<vector> covers)
{
positions.Clear(); covers.Clear();
NavmeshWorldComponent mesh = GetGame().GetAIWorld().GetNavmeshWorldComponent("Soldiers");
if (!mesh || !PlayersClear()) return 0;
map<int, vector> reserved = new map<int, vector>();
for (int side = -1; side <= 1; side += 2)
{
vector cover = ERR_Actors.Ground(m_Centre + m_Right * (side * m_Shoulder));
if (!CoverClear(cover)) return 0;
covers.Insert(cover);
for (int row = -1; row <= 1; row += 2)
{
vector point = ERR_Actors.Ground(m_Centre + m_Right * (side * (m_Shoulder + 3)) + m_Forward * (row * 3));
if (!mesh.IsTileLoaded(point)) { mesh.LoadTileIn(point); return 1; }
vector reachable;
if (!mesh.GetReachablePoint(point, 1, reachable) || vector.DistanceXZ(point, reachable) > 1 || !m_Placement.Clear(point, reserved)) return 0;
reserved.Set(positions.Count(), point); positions.Insert(point);
}
}
return 2;
}
ERR_PrepareResult Prepare(ERR_ScenarioDefinition definition)
{
if (!Replication.IsServer() || m_Stopped || !definition) return ERR_PrepareResult.FAILED;
if (m_Ready) return ERR_PrepareResult.READY;
if (!m_Begun)
{
m_Begun = true; m_Started = System.GetTickCount(); m_Target = m_Mode.Settings().Get("roadblockCount");
if (m_Target == 0) return Finish("disabled");
#ifdef WORKBENCH
// The legacy convoy/hq-memory fixtures use the original 60-body allowance and six-squad layout.
string fixtureEnabled, fixtureOutcome;
if (System.GetCLIParam("errSmoke", fixtureEnabled) && fixtureEnabled == "1" && System.GetCLIParam("errSmokeOutcome", fixtureOutcome) && (fixtureOutcome == "convoy" || fixtureOutcome == "hq-memory"))
return Finish("disabled for the legacy body-budget fixture");
#endif
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!ERR_TerrainSites.Bounds(m_Low, m_High) || !LoadRoster()) return Finish("terrain or faction resources unavailable; optional roadblocks skipped");
}
if (m_Groups.Count() >= m_Target) return Finish("requested count reached");
if (System.GetTickCount() - m_Started >= 12000 || m_Attempts >= 300) return Finish("bounded search complete; unsuitable locations skipped");
// Amortize geometry work: at most eight cheap road samples and one detailed
// placement per preparation call. Never depend on finding every requested post.
for (int sample = 0; sample < 8 && !m_HaveCandidate && m_Attempts < 300; sample++)
{ m_Attempts++; m_HaveCandidate = Candidate(definition); }
if (!m_HaveCandidate) return ERR_PrepareResult.PENDING;
array<vector> positions = {}, covers = {};
int plan = Plan(positions, covers);
if (plan == 1 && System.GetTickCount() - m_CandidateStarted < 1500) return ERR_PrepareResult.PENDING;
if (plan == 2) { m_Planned++; SpawnPost(positions, covers); }
m_HaveCandidate = false;
return ERR_PrepareResult.PENDING;
}
void Stop() { m_Stopped = true; m_Mining.Stop(); m_Loaded.Clear(); }
}