// 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 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 m_Positions = {}; protected ref array 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 m_Roster = {}; protected ref array 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 units, array 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 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 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 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 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 spawned, SCR_AIGroup group) { foreach (IEntity entity : spawned) ERR_Actors.DeleteSpawned(entity); ERR_Actors.DeleteSpawned(group); } protected bool SpawnPost(array positions, array 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 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 positions, out array covers) { positions.Clear(); covers.Clear(); NavmeshWorldComponent mesh = GetGame().GetAIWorld().GetNavmeshWorldComponent("Soldiers"); if (!mesh || !PlayersClear()) return 0; map reserved = new map(); 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 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(); } }