Improve small-terrain placement and show startup failure causes; record Workshop 0.0.120
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-24 03:03:10 +02:00
parent 253dde90cd
commit 3768ce7ed8
25 changed files with 4058 additions and 51 deletions
@@ -3,6 +3,9 @@
class ERR_CampTerrain
{
protected ref array<vector> m_Airfields = {};
protected string m_Rejection;
string Rejection() { return m_Rejection; }
protected bool Reject(string reason) { m_Rejection = reason; return false; }
protected bool Airport(IEntity entity)
{
if (!entity || !entity.GetPrefabData()) return false;
@@ -27,12 +30,13 @@ class ERR_CampTerrain
}
bool Clear(vector center, float heading, float halfSize, float maximumRange, out float lowest, float clearanceHeight = 4)
{
m_Rejection = "";
lowest = float.MAX; float highest = -float.MAX;
for (int x = -1; x <= 1; x++)
for (int z = -1; z <= 1; z++)
{
vector corner = center + Rotate(Vector(x * halfSize, 0, z * halfSize), heading);
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), corner)) return false;
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), corner)) return Reject("water");
}
// A 2m grid covers the walls, door approach, furniture and all interior slots.
for (float dx = -halfSize; dx <= halfSize; dx += 2)
@@ -41,7 +45,8 @@ class ERR_CampTerrain
vector sample = center + Rotate(Vector(dx, 0, dz), heading);
float height = GetGame().GetWorld().GetSurfaceY(sample[0], sample[2]);
lowest = Math.Min(lowest, height); highest = Math.Max(highest, height);
if (lowest < 1 || highest - lowest > maximumRange) return false;
if (lowest < 1) return Reject("ground below minimum elevation");
if (highest - lowest > maximumRange) return Reject("uneven ground");
}
for (float cx = -halfSize + 1; cx < halfSize; cx += 2)
for (float cz = -halfSize + 1; cz < halfSize; cz += 2)
@@ -53,33 +58,34 @@ class ERR_CampTerrain
// Bounding the rotated cell conservatively also protects the exit corridor.
trace.Mins = "-1.42 0 -1.42"; trace.Maxs = "1.42 0.1 1.42";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return Reject("obstructed footprint");
}
return true;
}
bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight, float airportBuffer = 1500)
{
m_Rejection = "";
bool prison = site.Kind == ERR_LocationKind.PRISON;
if (prison)
{
foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < 1500) return false;
foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < airportBuffer) return Reject("airport separation");
}
SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return false;
if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return Reject("road network unavailable");
BaseRoad road; float distance;
aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
if (road && distance < 12) return false;
if (road && distance < 12) return Reject("road proximity");
if (!Clear(position, heading, 10, 0.6, baseHeight)) return false;
if (ERR_StartSetup.IsCrash(site))
{
vector offset, center; float halfSize, height;
if (!ERR_StartSetup.Geometry(offset, center, halfSize, height)) return false;
if (!ERR_StartSetup.Geometry(offset, center, halfSize, height)) return Reject("crash footprint unavailable");
float wreckFloor;
if (!Clear(position + Rotate(center, heading), heading, halfSize, 0.6, wreckFloor, height)) return false;
if (!Clear(position + Rotate(center, heading), heading, halfSize, 0.6, wreckFloor, height)) return Reject("wreck area: " + m_Rejection);
}
float guardHeight;
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false;
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return false;
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return Reject("guard area: " + m_Rejection);
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return Reject("patrol area: " + m_Rejection);
return true;
}
}
@@ -90,6 +96,38 @@ class ERR_GeneratedSites
protected ref array<ERR_SiteDefinition> m_Pending = {};
protected vector m_Low, m_High;
protected int m_Index, m_Attempts, m_Began;
protected int m_NextDiagnostic;
protected ref map<string, int> m_Rejections = new map<string, int>();
protected void RecordRejection(string reason)
{
if (reason.IsEmpty()) reason = "unspecified terrain clearance";
int count;
m_Rejections.Find(reason, count);
m_Rejections.Set(reason, count + 1);
}
protected string RejectionSummary()
{
string summary;
foreach (string reason, int count : m_Rejections)
{
if (!summary.IsEmpty()) summary += "; ";
summary += reason + "=" + count.ToString();
}
if (summary.IsEmpty()) return "none recorded";
return summary;
}
protected void ReportSearch(ERR_SiteDefinition site, string stage)
{
PrintFormat("Reintroduced [placement search]: stage=%1 site=%2 (%3) attempts=%4 elapsedMs=%5 failed/deferred checks: %6", stage, site.Id, site.Label, m_Attempts, System.GetTickCount() - m_Began, RejectionSummary());
}
protected void ReportProgress(ERR_SiteDefinition site)
{
int now = System.GetTickCount();
if (now - m_Began < 30000 || now < m_NextDiagnostic) return;
ReportSearch(site, "still searching");
m_NextDiagnostic = now + 30000;
}
protected void ClearDiagnostics() { m_Rejections.Clear(); m_NextDiagnostic = 0; }
protected bool m_Started;
protected ERR_PrepareResult m_Result = ERR_PrepareResult.PENDING;
protected string m_Error;
@@ -180,6 +218,7 @@ class ERR_GeneratedSites
{
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
ERR_SiteDefinition site = m_Pending[m_Index];
ReportProgress(site);
bool fallback = m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000;
vector position = site.Position; float heading = site.Heading;
if (!fallback)
@@ -188,22 +227,30 @@ class ERR_GeneratedSites
heading = Math.RandomFloat(0, 360); m_Attempts++;
}
float floor;
if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor) && RescueFits(site, position, heading))
bool fits;
if (!Separated(site, position, fallback)) RecordRejection("site spacing / escape boundary");
else if (!m_Terrain.Site(site, position, heading, floor)) RecordRejection(m_Terrain.Rejection());
else if (!RescueFits(site, position, heading)) RecordRejection("secondary aircraft pad");
else fits = true;
if (fits)
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
PrintFormat("Co10 Escape Reintroduced [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback);
ReportSearch(site, "placed"); ClearDiagnostics();
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
}
else if (fallback)
{
ReportSearch(site, "exhausted; authored fallback rejected");
if (SkipOptional(site))
{
ClearDiagnostics();
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
}
else
{
m_Error = "No safe terrain or valid authored fallback for required site " + site.Id;
m_Error = "No safe terrain or valid authored fallback for required site " + site.Id + ". Checks: " + RejectionSummary();
m_Result = ERR_PrepareResult.FAILED; return m_Result;
}
}
@@ -3,14 +3,14 @@
modded class ERR_CampTerrain
{
static bool s_ERROptionalSiteRefused;
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight, float airportBuffer = 1500)
{
string outcome;
if ((site.Id == "everon-camp-5" || site.Id == "everon-outpost-1") && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "optional-site-skip")
{
s_ERROptionalSiteRefused = true; return false;
}
return super.Site(site, position, heading, baseHeight);
return super.Site(site, position, heading, baseHeight, airportBuffer);
}
}
modded class ERR_GameMode
@@ -35,7 +35,7 @@ class ERR_ObjectiveText
ERR_Phase phase = mode.Phase();
if (phase == ERR_Phase.PREPARING)
{
if (!mode.PreparationError().IsEmpty()) return "The mission could not start. An administrator can use /esc status for the cause. Spawning is disabled until the configuration is corrected and the scenario restarted.";
if (!mode.PreparationError().IsEmpty()) return "Reason: " + mode.PreparationError() + "\n\nAn administrator must correct the problem and restart the scenario before players can spawn.";
return "Preparing mission sites and equipment. You will spawn when the mission is ready.";
}
if (phase == ERR_Phase.PRISON)
@@ -10,10 +10,20 @@ class ERR_TerrainSites : ERR_GeneratedSites
protected ref ERR_ReconAirports m_Airports = new ERR_ReconAirports();
protected bool m_AirportsReady;
protected int m_GeometryRejected, m_NavigationRejected, m_PadRejected;
protected string m_NavigationReason;
// Keep the same work slice; difficult terrain gets more slices, not larger stalls.
static int SearchAttempts() { return 48000; }
static int SearchMilliseconds() { return 120000; }
static float PrisonAirportBuffer(vector low, vector high)
{
// The old 1.5km tactical separation could exclude an entire small island.
// Physical ground, scenery and navigation checks still apply independently.
return Math.Clamp(Math.Min(high[0] - low[0], high[2] - low[2]) * 0.15, 200, 1500);
}
static bool Bounds(vector low, vector high)
{
float width = high[0] - low[0], depth = high[2] - low[2];
return width >= 2000 && depth >= 2000 && width <= 100000 && depth <= 100000;
return width >= 1000 && depth >= 1000 && width <= 100000 && depth <= 100000;
}
static float Spacing(vector low, vector high)
{
@@ -23,7 +33,7 @@ class ERR_TerrainSites : ERR_GeneratedSites
{
if (m_Started || !Replication.IsServer() || !definition || !definition.Valid() || !GetGame().GetWorldEntity()) return false;
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!Bounds(m_Low, m_High)) { m_Error = "Terrain bounds must span 2-100 km on each axis."; return false; }
if (!Bounds(m_Low, m_High)) { m_Error = "Terrain bounds must span 1-100 km on each axis."; return false; }
AIWorld world = GetGame().GetAIWorld();
if (!world || !world.GetNavmeshWorldComponent("Soldiers")) { m_Error = "Terrain requires its own Soldiers navigation mesh."; return false; }
m_Started = true; m_Definition = definition; definition.TerrainGenerated = true;
@@ -62,19 +72,28 @@ class ERR_TerrainSites : ERR_GeneratedSites
}
protected bool Navigable(vector position)
{
m_NavigationReason = "";
NavmeshWorldComponent mesh = GetGame().GetAIWorld().GetNavmeshWorldComponent("Soldiers");
if (!mesh) return false;
if (!mesh.IsTileLoaded(position)) { m_WaitingNav = mesh.LoadTileIn(position); return false; }
if (!mesh) { m_NavigationReason = "Soldiers navmesh unavailable"; return false; }
if (!mesh.IsTileLoaded(position))
{
m_WaitingNav = mesh.LoadTileIn(position);
m_NavigationReason = "navmesh tile unavailable";
if (m_WaitingNav) m_NavigationReason = "waiting for navmesh tile";
return false;
}
vector reachable;
return mesh.GetReachablePoint(position, 10, reachable);
if (mesh.GetReachablePoint(position, 10, reachable)) return true;
m_NavigationReason = "no reachable AI ground";
return false;
}
protected bool Survey(ERR_SiteDefinition site, vector position, float heading, out float floor)
{
if (!m_Terrain.Site(site, position, heading, floor)) { m_GeometryRejected++; return false; }
if (!m_Terrain.Site(site, position, heading, floor, PrisonAirportBuffer(m_Low, m_High))) { m_GeometryRejected++; RecordRejection(m_Terrain.Rejection()); return false; }
vector ground = Vector(position[0], floor, position[2]);
if (!Navigable(ground)) { m_NavigationRejected++; return false; }
if (site.Guarded && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.GuardOffset, heading)))) { m_NavigationRejected++; return false; }
if (site.ResponseCount > 0 && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.ResponseOffset, heading)))) { m_NavigationRejected++; return false; }
if (!Navigable(ground)) { m_NavigationRejected++; RecordRejection(m_NavigationReason); return false; }
if (site.Guarded && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.GuardOffset, heading)))) { m_NavigationRejected++; RecordRejection("guard area: " + m_NavigationReason); return false; }
if (site.ResponseCount > 0 && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.ResponseOffset, heading)))) { m_NavigationRejected++; RecordRejection("patrol area: " + m_NavigationReason); return false; }
if (site.Kind != ERR_LocationKind.EXTRACTION) return true;
float padFloor;
if (m_Terrain.Clear(ground, heading, 14, 0.8, padFloor))
@@ -82,18 +101,34 @@ class ERR_TerrainSites : ERR_GeneratedSites
bool primaryClear;
if (site.RequireAirfield) primaryClear = ERR_AirfieldPlacement.Clear(ground);
else primaryClear = ERR_AirfieldPlacement.ClearGround(ground);
if (primaryClear && ERR_RescueLanding.Fits(ground)) return true;
if (!primaryClear) RecordRejection("primary aircraft clearance");
else if (!ERR_RescueLanding.Fits(ground)) RecordRejection("secondary aircraft pad");
else return true;
}
else RecordRejection("landing zone: " + m_Terrain.Rejection());
m_PadRejected++; return false;
}
protected vector SamplePosition(ERR_SiteDefinition site)
{
if (site.Kind != ERR_LocationKind.EXTRACTION || m_Airports.m_Positions.IsEmpty())
if (site.Kind != ERR_LocationKind.EXTRACTION || !site.RequireAirfield || m_Airports.m_Positions.IsEmpty())
return Vector(Math.RandomFloat(m_Low[0] + 60, m_High[0] - 60), 0, Math.RandomFloat(m_Low[2] + 60, m_High[2] - 60));
vector centre = m_Airports.m_Positions[Math.RandomInt(0, m_Airports.m_Positions.Count())];
float angle = Math.RandomFloat(0, Math.PI2), radius = Math.RandomFloat(0, 450);
return centre + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius);
}
protected void ExpandExtractionSearch(ERR_SiteDefinition site)
{
if (site.Kind != ERR_LocationKind.EXTRACTION || !site.RequireAirfield) return;
if (m_Attempts < 12000 && System.GetTickCount() - m_Began < 30000) return;
// An airport is a preferred starting area, not proof that a usable pad exists.
// Use the remaining budget to find a clear helicopter landing zone elsewhere.
// Do not reset the total deadline or carry an airport candidate into this pass.
site.RequireAirfield = false;
site.Label = "Extraction landing zone";
m_HaveCandidate = false;
ReportSearch(site, "airport search exhausted; expanding search");
PrintFormat("Reintroduced [terrain]: %1 airport search exhausted; surveying off-airport landing zones with full aircraft clearance", site.Id);
}
protected bool PrepareAirports()
{
if (m_AirportsReady) return true;
@@ -135,16 +170,20 @@ class ERR_TerrainSites : ERR_GeneratedSites
{
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
ERR_SiteDefinition site = m_Pending[m_Index];
if (m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000)
ReportProgress(site);
ExpandExtractionSearch(site);
if (m_Attempts >= SearchAttempts() || System.GetTickCount() - m_Began >= SearchMilliseconds())
{
PrintFormat("Reintroduced [terrain survey exhausted]: id=%1 attempts=%2 rejected geometry=%3 navigation=%4 aircraft=%5", site.Id, m_Attempts, m_GeometryRejected, m_NavigationRejected, m_PadRejected);
ReportSearch(site, "exhausted");
if (!SkipOptional(site))
{
m_Error = "Terrain has no safe navigable placement for " + site.Label + ". Check this map's Soldiers navmesh, terrain clearance and size; no foreign-map fallback was used.";
m_Error = "Terrain has no safe navigable placement for " + site.Label + " (" + site.Id + "). Checks: " + RejectionSummary();
m_Result = ERR_PrepareResult.FAILED; return m_Result;
}
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount(); m_HaveCandidate = false;
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
ClearDiagnostics();
}
else
{
@@ -157,11 +196,14 @@ class ERR_TerrainSites : ERR_GeneratedSites
vector position = m_Candidate;
float heading = m_CandidateHeading, floor;
m_WaitingNav = false;
if (Separated(site, position, false) && Survey(site, position, heading, floor))
bool separated = Separated(site, position, false);
if (!separated) RecordRejection("site spacing / escape boundary");
if (separated && Survey(site, position, heading, floor))
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
PrintFormat("Reintroduced [terrain site]: id=%1 position=%2 heading=%3 attempts=%4", site.Id, position, heading, m_Attempts);
ReportSearch(site, "placed"); ClearDiagnostics();
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
}
@@ -6,8 +6,8 @@ FrameWidgetClass {
OffsetTop 0
SizeX 900
OffsetRight -900
SizeY 240
OffsetBottom -240
SizeY 520
OffsetBottom -520
Alignment 0.5 0.5
}
"Z Order" 10