Add mission customization, HQ support and aggression-driven AI

Integrate faction and random settings menus, generated objectives, scouts, convoys, air support, mortar stations and body-intel recovery. Add identified-body aggression, finite hunter UAZ deployment, dedicated objective defence and maximum AI skill with separate reduced aim accuracy. Include policy tests, native probes, evidence and documentation.
This commit is contained in:
2026-09-23 15:07:14 +02:00
parent 04bd19327b
commit 1b1a8b5173
242 changed files with 12153 additions and 875 deletions
@@ -38,9 +38,27 @@ class ERR_Actors
params.TransformMode = ETransformMode.WORLD;
Math3D.AnglesToMatrix(angles, params.Transform);
params.Transform[3] = position;
return GetGame().SpawnEntityPrefab(resource, GetGame().GetWorld(), params);
IEntity entity = GetGame().SpawnEntityPrefab(resource, GetGame().GetWorld(), params);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.FactionSelection() && mode.FactionSelection().Ready() && entity)
{
SCR_AIGroup group = SCR_AIGroup.Cast(entity);
if (group && (prefab.Contains("Prefabs/ERR/ConvoyCrew.et") || prefab.Contains("Prefabs/ERR/MortarCrew.et"))) group.SetFaction(mode.EnemyFaction());
if (prefab == mode.Factions().PrisonerPrefab) SetFaction(entity, mode.PlayerFaction());
}
return entity;
}
static bool SetFaction(IEntity entity, Faction faction)
{
if (!Replication.IsServer() || !entity || !faction) return false;
// Characters derive directly from the native base, not SCR_FactionAffiliationComponent.
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(entity.FindComponent(FactionAffiliationComponent));
if (!affiliation) return false;
affiliation.SetAffiliatedFaction(faction);
return affiliation.GetAffiliatedFaction() == faction;
}
static void DeleteSpawned(IEntity entity)
{
if (!Replication.IsServer() || !entity || Player(entity)) return;
@@ -8,12 +8,12 @@ class ERR_AdminMenuFields
{
static array<string> Keys()
{
return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres"};
return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount"};
}
static bool Available(string key, bool hq, bool intel)
{
if (key == "aiIntelChancePercent") return intel;
if (key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
if (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.
@@ -30,6 +30,7 @@ class ERR_AdminMenuFields
}
static string Display(string key, string value)
{
if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt());
if (key == "startTimeMinutes") return ERR_TimeService.Clock(value.ToInt());
if (key == "startDateCode") return ERR_EnvironmentService.Date(value.ToInt());
if (key == "weatherProfile") return ERR_EnvironmentService.ProfileName(value.ToInt());
@@ -48,6 +49,7 @@ class ERR_AdminMenuFields
else if (key == "reportIntervalSeconds") prefix = "hq interval";
else if (key == "supportSquadLimit") prefix = "hq squads";
else if (key == "responseRadiusMetres") prefix = "hq radius";
else if (key == "convoyCount") prefix = "hq convoy count";
value = value.Trim();
if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return "";
return prefix + " set " + value;
@@ -96,8 +98,13 @@ class ERR_AdminMenu : ChimeraMenuBase
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
m_Mode = ERR_GameMode.Cast(GetGame().GetGameMode());
if (!m_Controller) { Close(); return; }
array<string> names = {"Close", "OpenHQ", "BackSettings", "OpenJournal", "BackHQ", "PauseJournal", "LatestJournal", "IntelSummary", "RerollIntel"};
foreach (string key : ERR_AdminMenuFields.Keys()) names.Insert("Apply_" + key);
array<string> names = {"Close", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "LatestJournal", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus"};
names.Insert("RandomDay_startTimeMinutes");
foreach (string key : ERR_AdminMenuFields.Keys())
{
names.Insert("Apply_" + key);
names.Insert("Random_" + key);
}
foreach (string name : names)
{
Widget widget = GetRootWidget().FindAnyWidget(name);
@@ -177,7 +184,7 @@ class ERR_AdminMenu : ChimeraMenuBase
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget());
if (edit && edit.IsInWriteMode()) return;
}
if (m_Page > 0) ShowPage(m_Page - 1);
if (m_Page != 0) ShowPage(0);
else Close();
}
protected void SetText(string name, string value)
@@ -185,28 +192,35 @@ class ERR_AdminMenu : ChimeraMenuBase
TextWidget widget = TextWidget.Cast(GetRootWidget().FindAnyWidget(name));
if (widget) widget.SetText(value);
}
// Stable page order is also used by the shared navigation bar and native probe.
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus"}; }
protected void ShowPage(int page)
{
array<string> pages = Pages();
array<string> navigation = Navigation();
if (page < 0 || page >= pages.Count()) return;
m_Page = page;
GetRootWidget().FindAnyWidget("SettingsPage").SetVisible(page == 0);
GetRootWidget().FindAnyWidget("HQPage").SetVisible(page == 1);
GetRootWidget().FindAnyWidget("JournalPage").SetVisible(page == 2);
if (page == 0) SetText("Title", "Mission settings");
if (page == 1) SetText("Title", "HQ and mission status");
if (page == 2) { SetText("Title", "HQ activity journal"); m_LogPoll = 1; }
for (int i = 0; i < pages.Count(); i++)
{
GetRootWidget().FindAnyWidget(pages[i]).SetVisible(i == page);
GetRootWidget().FindAnyWidget("Selected_" + navigation[i]).SetVisible(i == page);
}
if (page == 2) m_LogPoll = 1;
}
protected void UpdateActions()
{
foreach (SCR_ButtonBaseComponent button : m_Buttons)
{
string name = button.GetRootWidget().GetName();
if (name.IndexOf("Apply_") == 0)
if (name.IndexOf("Apply_") == 0 || name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0)
{
string key = name.Substring(6, name.Length() - 6);
int prefix = name.IndexOf("_") + 1;
string key = name.Substring(prefix, name.Length() - prefix);
button.SetEnabled(m_Ready && m_AvailableFields.Contains(key) && ERR_AdminMenuFields.Available(key, m_HQ, m_Intel));
}
else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel);
else if (name == "OpenJournal" || name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ);
else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ);
}
}
void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
@@ -221,6 +235,12 @@ class ERR_AdminMenu : ChimeraMenuBase
int divider = entry.IndexOf("=");
if (divider <= 0) continue;
string key = entry.Substring(0, divider);
if (key.IndexOf("resolved_") == 0)
{
string resolvedKey = key.Substring(9, key.Length() - 9);
if (ERR_AdminMenuFields.Keys().Contains(resolvedKey)) SetText("Saved_" + resolvedKey, "Saved: Random (using " + entry.Substring(divider + 1, entry.Length() - divider - 1) + ")");
continue;
}
if (!ERR_AdminMenuFields.Keys().Contains(key)) continue;
string value = ERR_AdminMenuFields.Display(key, entry.Substring(divider + 1, entry.Length() - divider - 1));
m_AvailableFields.Insert(key);
@@ -290,9 +310,8 @@ class ERR_AdminMenu : ChimeraMenuBase
if (!button || !IsFocused()) return;
string name = button.GetRootWidget().GetName();
if (name == "Close") { Close(); return; }
if (name == "OpenHQ" || name == "BackHQ") { ShowPage(1); return; }
if (name == "BackSettings") { ShowPage(0); return; }
if (name == "OpenJournal") { if (m_Ready && m_HQ) ShowPage(2); return; }
int page = Navigation().Find(name);
if (page >= 0) { ShowPage(page); return; }
if (name == "PauseJournal")
{
m_LogPaused = !m_LogPaused;
@@ -319,6 +338,21 @@ class ERR_AdminMenu : ChimeraMenuBase
if (pause) pause.SetText("Pause live");
return;
}
if (name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0)
{
int prefix = name.IndexOf("_") + 1;
string key = name.Substring(prefix, name.Length() - prefix);
if (!m_AvailableFields.Contains(key) || !ERR_AdminMenuFields.Available(key, m_HQ, m_Intel)) return;
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget());
if (edit)
{
string choice = "Random";
if (name.IndexOf("RandomDay_") == 0) choice = "Random day";
edit.SetValue(choice);
SetText("Feedback", choice + " selected. Press Apply to save.");
}
return;
}
string command;
if (name == "IntelSummary" && m_Intel) command = "intel show";
if (name == "RerollIntel" && m_Intel) command = "intel reroll";
@@ -78,8 +78,15 @@ class ERR_AdminPlayerTools
args = args.Trim(); args.ToLower();
bool godCommand = args == "help god" || args == "god" || args == "ungod" || args.StartsWith("god ");
bool itemCommand = args == "help cheat" || args == "cheat" || args.StartsWith("cheat ");
if (!godCommand && !itemCommand) return false;
bool killCommand = args == "killais" || args == "help killais";
if (!godCommand && !itemCommand && !killCommand) return false;
if (args == "help killais") { replies.Insert("/esc killais: kill AI characters within 1000 m of your living character. Players and vehicles are preserved."); return true; }
IEntity actor = CurrentBody(mode, controller);
if (killCommand && actor)
{
replies.Insert(string.Format("Killed %1 AI characters within 1000 m.", ERR_DebugAI.KillNearby(mode, actor)));
return true;
}
if (!actor)
{
Stop();
@@ -127,8 +134,14 @@ class ERR_AdminPlayerTools
}
string item;
if (args.Length() > 6) item = args.Substring(6, args.Length() - 6);
if (item == "intel")
{
if (ERR_DebugIntel.SpawnSix(mode, actor)) replies.Insert("Spawned 6 intel documents on the ground nearby. Pick them up to recover intel normally.");
else replies.Insert("Could not place 6 intel documents. Stand on clear ground outside a vehicle, with room nearby.");
return true;
}
ResourceName prefab = ItemPrefab(item);
if (prefab.IsEmpty()) { replies.Insert("Use /esc cheat map | /esc cheat helmet | /esc cheat uniform (vanilla paper map, PASGT helmet or BDU jacket)."); return true; }
if (prefab.IsEmpty()) { replies.Insert("Use /esc cheat map | /esc cheat helmet | /esc cheat uniform | /esc cheat intel (6 documents on nearby ground; other items: vanilla paper map, PASGT helmet or BDU jacket)."); return true; }
if (m_Grants.TimedOut) { replies.Insert("An inventory insertion is unresolved; further item grants are blocked for this character."); return true; }
if (m_Grants.Operation) { replies.Insert("An item grant is already pending; wait for its result."); return true; }
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(actor.FindComponent(SCR_InventoryStorageManagerComponent));
@@ -8,6 +8,15 @@ class ERR_CommandArguments
if (section == "intel" && option == "reveal") return "intelRevealChancePercent";
return "";
}
static bool Selection(string key, string text, out int value)
{
if (text == "random" && ERR_Settings.Randomizable(key)) { value = -1; return true; }
if ((text == "random-day" || text == "random day") && key == "startTimeMinutes") { value = -2; return true; }
if (key == "startTimeMinutes") return ERR_TimeService.ParseClock(text, value);
if (key == "startDateCode") return ERR_EnvironmentService.ParseDate(text, value);
if (key == "weatherProfile") return ERR_EnvironmentService.ParseProfile(text, value);
return Integer(text, value);
}
static bool Integer(string text, out int value)
{
if (text.IsEmpty() || text.Length() > 6) return false;
@@ -79,35 +88,36 @@ class ERR_AdminService
if (parts.Count() == 3 && parts[2] == "show")
{
if (mode.Time()) replies.Insert(mode.Time().Status());
replies.Insert("Next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression.");
replies.Insert("Next round: " + ERR_AdminMenuFields.Display("startTimeMinutes", mode.Settings().Get("startTimeMinutes").ToString()) + ", " + ERR_Settings.SelectionText(mode.Settings().Get("timeMultiplier")) + " progression.");
return true;
}
int value;
bool valid;
if (parts.Count() == 4 && parts[2] == "set")
{
if (key == "startTimeMinutes") valid = ERR_TimeService.ParseClock(parts[3], value);
else valid = ERR_CommandArguments.Integer(parts[3], value);
valid = ERR_CommandArguments.Selection(key, parts[3], value);
}
if (parts.Count() == 5 && parts[2] == "set" && parts[3] == "random" && parts[4] == "day")
valid = ERR_CommandArguments.Selection(key, "random day", value);
if (valid && mode.Settings().Set(key, value))
replies.Insert("Saved for next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression. Current round clock unchanged.");
else replies.Insert("Invalid time or settings could not be saved. Use mission time set HH:MM (00:00-23:59) or mission speed set N (1-24). Previous setting retained.");
replies.Insert("Saved for next round: " + ERR_AdminMenuFields.Display("startTimeMinutes", mode.Settings().Get("startTimeMinutes").ToString()) + ", " + ERR_Settings.SelectionText(mode.Settings().Get("timeMultiplier")) + " progression. Current round clock unchanged.");
else replies.Insert("Invalid time or settings could not be saved. Use mission time set HH:MM (00:00-23:59)|random|random-day or mission speed set N (1-24)|random. Previous setting retained.");
return true;
}
protected string SettingHelp(string key)
{
if (key == "intelRevealChancePercent")
return "Use /esc intel reveal set N (0-100). This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged.";
return "Use /esc intel reveal set N (0-100) or random. This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged.";
return "";
}
protected void SettingCommand(ERR_Settings settings, string key, array<string> parts, array<string> replies)
{
if (parts.Count() == 3 && parts[2] == "show") { replies.Insert(key + "=" + settings.Get(key).ToString()); return; }
if (parts.Count() == 3 && parts[2] == "show") { replies.Insert(key + "=" + ERR_Settings.SelectionText(settings.Get(key))); return; }
string help = SettingHelp(key);
if (parts.Count() == 3 && parts[2] == "help" && !help.IsEmpty()) { replies.Insert(help); return; }
int value;
if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Integer(parts[3], value) && settings.Set(key, value))
replies.Insert(key + "=" + value.ToString() + " saved");
if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Selection(key, parts[3], value) && settings.Set(key, value))
replies.Insert(key + "=" + ERR_Settings.SelectionText(value) + " saved");
else
{
string failure = "Invalid value or settings could not be saved; previous setting retained.";
@@ -121,9 +131,9 @@ class ERR_AdminService
if (provider.Help(topic, replies)) return;
if (topic == "time" || topic == "mission")
{
replies.Insert("/esc mission radio show|set N | mission abandon");
replies.Insert("/esc time show|set HH:MM | mission speed show|set N; saved changes apply next round.");
replies.Insert("/esc mission date show|set YYYY-MM-DD|authored | mission weather show|set authored|clear|overcast|rain");
replies.Insert("/esc mission radio show|set N|random | mission abandon");
replies.Insert("/esc time show|set HH:MM|random|random-day | mission speed show|set N|random; saved changes apply next round.");
replies.Insert("/esc mission date show|set YYYY-MM-DD|authored|random | mission weather show|set authored|clear|overcast|rain|random");
return;
}
if (topic == "intel")
@@ -137,8 +147,8 @@ class ERR_AdminService
replies.Insert("This command topic is unavailable in the loaded mission: " + topic + ".");
return;
}
replies.Insert("/esc menu | status | settings | help mission|time|intel|hq|cheat|god");
replies.Insert("/esc mission abandon | mission radio show|set N | time show|set HH:MM");
replies.Insert("/esc menu | status | settings | help mission|time|intel|hq|cheat|god|killais");
replies.Insert("/esc mission abandon | mission radio show|set N|random | time show|set HH:MM|random|random-day");
replies.Insert("/esc mission date|weather|speed show|set VALUE | intel reveal show|set N|help");
replies.Insert("/esc spectate next|previous; optional systems add their own command topics.");
}
@@ -93,6 +93,11 @@ class ERR_AdminSession
array<string> status = {};
if (!m_Mode.Settings()) { status.Insert("Mission settings are not ready."); m_Controller.ERR_AdminDeliverSnapshot(m_Token, true, values, status); return; }
foreach (string key : ERR_Settings.Keys()) values.Insert(key + "=" + m_Mode.Settings().Get(key).ToString());
foreach (string key : ERR_AdminMenuFields.Keys())
{
if (key == "convoyCount" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile") continue;
if (m_Mode.Settings().Get(key) == -1) values.Insert("resolved_" + key + "=" + m_Mode.Settings().Runtime(key).ToString());
}
m_Mode.Admin().Capabilities(values);
m_Mode.Admin().Execute(m_Mode, true, "status", status);
m_Controller.ERR_AdminDeliverSnapshot(m_Token, true, values, status);
@@ -0,0 +1,32 @@
// Scenario authors choose a pad on their island's airfield. Never relocate an
// obstructed aircraft into nearby woods: refuse preparation before spawning it.
class ERR_AirfieldPlacement
{
protected bool m_NearAirport;
protected bool Airport(IEntity entity)
{
if (!entity || !entity.GetPrefabData()) return true;
string path = entity.GetPrefabData().GetPrefabName(); path.ToLower();
if (path.Contains("/runway") || path.Contains("/lightrunway_") || path.Contains("/aircontroltower") || path.Contains("/airportcontroltower") || path.Contains("/locations/eden/airport.et")) m_NearAirport = true;
return !m_NearAirport;
}
static bool Clear(vector position)
{
if (!Replication.IsServer() || position[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) return false;
ERR_AirfieldPlacement survey = new ERR_AirfieldPlacement();
GetGame().GetWorld().QueryEntitiesBySphere(position, 600, survey.Airport, null, EQueryEntitiesFlags.ALL);
if (!survey.m_NearAirport) return false;
for (int edge = 0; edge < 4; edge++)
{
float side = edge * Math.PI / 2;
vector sample = position + Vector(Math.Cos(side) * 14, 0, Math.Sin(side) * 14);
if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(sample) - position[1]) > 0.8) return false;
}
if (!SCR_WorldTools.TraceCilinderUtil(position + "0 2.5 0", 40, 4, TraceFlags.ENTS)) return false;
TraceBox clearance = new TraceBox();
clearance.Start = position + "0 3 0"; clearance.End = position + "0 60 0";
clearance.Mins = "-40 -1 -40"; clearance.Maxs = "40 1 40";
clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
return GetGame().GetWorld().TraceMove(clearance, null) >= 1;
}
}
@@ -0,0 +1,103 @@
// Modder-facing registry. New packs/factions and their menu rows need data only.
[BaseContainerProps()]
class ERR_ContentOption
{
[Attribute()] string Id;
[Attribute()] string Label;
[Attribute("", UIWidgets.EditBox, "Parent pack ID; empty for a pack")]
string Parent;
[Attribute("1", UIWidgets.CheckBox)] bool DefaultEnabled;
[Attribute("", UIWidgets.EditBox, "Native faction key; empty for a pack")]
string NativeFactionKey;
[Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")]
ResourceName GuardGroup;
[Attribute("", UIWidgets.EditBox, "Exact resource GUIDs for optional equipment owned by this pack")]
ref array<string> EquipmentGuids;
}
[BaseContainerProps()]
class ERR_ContentDefinition
{
[Attribute()] ref array<ref ERR_ContentOption> Options;
ERR_ContentOption Find(string id)
{
if (!Options) return null;
foreach (ERR_ContentOption option : Options)
if (option && option.Id == id) return option;
return null;
}
bool Valid()
{
if (!Options || Options.IsEmpty() || Options.Count() > 64) return false;
set<string> ids = new set<string>();
set<string> factions = new set<string>();
set<ResourceName> groups = new set<ResourceName>();
set<string> resources = new set<string>();
foreach (ERR_ContentOption option : Options)
{
if (!option || option.Id.IsEmpty() || option.Id.Length() > 48 || option.Label.IsEmpty() || ids.Contains(option.Id)) return false;
for (int i = 0; i < option.Id.Length(); i++)
if ("abcdefghijklmnopqrstuvwxyz0123456789_".IndexOf(option.Id.Substring(i, 1)) < 0) return false;
ids.Insert(option.Id);
if (!option.Parent.IsEmpty())
{
ERR_ContentOption parent = Find(option.Parent);
if (!parent || !parent.Parent.IsEmpty() || !parent.NativeFactionKey.IsEmpty()) return false;
}
if (!option.NativeFactionKey.IsEmpty())
{
if (option.Parent.IsEmpty() || option.GuardGroup.IsEmpty() || factions.Contains(option.NativeFactionKey) || groups.Contains(option.GuardGroup)) return false;
factions.Insert(option.NativeFactionKey);
groups.Insert(option.GuardGroup);
}
else if (!option.GuardGroup.IsEmpty()) return false;
if (option.EquipmentGuids)
foreach (string guid : option.EquipmentGuids)
{
if (guid.Length() != 16 || resources.Contains(guid)) return false;
for (int j = 0; j < guid.Length(); j++)
if ("0123456789ABCDEF".IndexOf(guid.Substring(j, 1)) < 0) return false;
resources.Insert(guid);
}
}
return true;
}
}
class ERR_ContentRegistry
{
protected static ref ERR_ContentDefinition s_Definition;
static ERR_ContentDefinition Definition()
{
if (!s_Definition) s_Definition = ERR_ContentDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{64BF38F9F92CE016}Configs/ERR/Content/Registry.conf"));
return s_Definition;
}
static array<string> Ids()
{
array<string> ids = {};
ERR_ContentDefinition definition = Definition();
if (definition && definition.Valid())
foreach (ERR_ContentOption option : definition.Options) ids.Insert(option.Id);
return ids;
}
static string Key(string id) { return "content_" + id; }
static string Label(string id)
{
ERR_ContentOption option = Definition().Find(id);
if (option) return option.Label;
return id;
}
static string State(bool enabled)
{
if (enabled) return "Enabled";
return "Disabled";
}
static string ResourcePack(ResourceName prefab)
{
foreach (ERR_ContentOption option : Definition().Options)
if (option.EquipmentGuids)
foreach (string guid : option.EquipmentGuids)
if (prefab.Contains("{" + guid + "}")) return option.Id;
return "";
}
}
@@ -0,0 +1,118 @@
modded class ERR_AdminMenu
{
protected ref map<string, Widget> m_ModRows = new map<string, Widget>();
protected ref map<string, int> m_ModSaved = new map<string, int>();
protected ref map<string, int> m_ModActive = new map<string, int>();
static void OpenMods()
{
Open();
ERR_AdminMenu menu = GetOpenMenu();
if (menu) menu.ShowPage(3);
}
override void OnMenuOpen()
{
super.OnMenuOpen();
Widget list = GetRootWidget().FindAnyWidget("ModList");
foreach (string pack : ERR_ContentRegistry.Ids())
{
Widget row = GetGame().GetWorkspace().CreateWidgets("{F092D45656A8E02B}UI/layouts/ERR/ContentRow.layout", list);
if (!row) { Close(); return; }
m_ModRows.Set(pack, row);
TextWidget label = TextWidget.Cast(row.FindAnyWidget("PackLabel"));
if (label) label.SetText(ERR_ContentRegistry.Label(pack));
SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row);
SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row);
if (!enable || !disable) { Close(); return; }
enable.m_OnClicked.Insert(OnModButton); disable.m_OnClicked.Insert(OnModButton);
}
UpdateActions();
}
override void OnMenuClose()
{
foreach (string pack, Widget row : m_ModRows)
{
SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row);
SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row);
if (enable) enable.m_OnClicked.Remove(OnModButton);
if (disable) disable.m_OnClicked.Remove(OnModButton);
}
m_ModRows.Clear(); m_ModSaved.Clear(); m_ModActive.Clear();
super.OnMenuClose();
}
protected override void UpdateActions()
{
super.UpdateActions();
foreach (string pack, Widget row : m_ModRows)
{
bool ready = m_Ready && m_ModSaved.Contains(pack) && m_ModActive.Contains(pack);
SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row);
SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row);
if (enable) enable.SetEnabled(ready && m_ModSaved.Get(pack) == 0);
if (disable) disable.SetEnabled(ready && m_ModSaved.Get(pack) == 1);
TextWidget state = TextWidget.Cast(row.FindAnyWidget("PackState"));
if (!state) continue;
if (!ready) state.SetText("Waiting for server settings...");
else state.SetText("Current round: " + ERR_ContentRegistry.State(m_ModActive.Get(pack) == 1) + " | Saved preference: " + ERR_ContentRegistry.State(m_ModSaved.Get(pack) == 1));
}
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
m_ModSaved.Clear(); m_ModActive.Clear();
if (authorized && values)
foreach (string pack : ERR_ContentRegistry.Ids())
foreach (string entry : values)
{
if (entry == ERR_ContentRegistry.Key(pack) + "=0") m_ModSaved.Set(pack, 0);
if (entry == ERR_ContentRegistry.Key(pack) + "=1") m_ModSaved.Set(pack, 1);
if (entry == "activeMod_" + pack + "=0") m_ModActive.Set(pack, 0);
if (entry == "activeMod_" + pack + "=1") m_ModActive.Set(pack, 1);
}
super.ReceiveSnapshot(authorized, values, status);
}
protected void OnModButton(SCR_ButtonBaseComponent button)
{
if (!button || !IsFocused() || !m_Controller || !m_Ready || m_Age > 6 || m_ClickDelay > 0) return;
foreach (string pack, Widget row : m_ModRows)
{
string action;
if (button.GetRootWidget() == row.FindAnyWidget("Enable")) action = "enable";
if (button.GetRootWidget() == row.FindAnyWidget("Disable")) action = "disable";
if (action.IsEmpty() || !m_ModSaved.Contains(pack) || !m_ModActive.Contains(pack)) continue;
m_ClickDelay = 0.3;
SetText("Feedback", "Saving selection on the server...");
m_Controller.ERR_AdminSubmit("mods " + pack + " " + action, true);
return;
}
}
}
// Add one mission-specific item to the existing Esc menu. The authenticated
// snapshot and command handlers still enforce access on the server.
modded class PauseMenuUI
{
protected SCR_ButtonTextComponent m_ERRModsButton;
override void OnMenuOpen()
{
super.OnMenuOpen();
PlayerController controller = GetGame().GetPlayerController();
if (!ERR_GameMode.Get() || !controller || !SCR_Global.IsAdmin(controller.GetPlayerId())) return;
Widget settings = GetRootWidget().FindAnyWidget("Settings");
if (!settings) return;
Widget widget = GetGame().GetWorkspace().CreateWidgets("{F0351A624C87DE90}UI/layouts/ERR/PauseModsButton.layout", settings.GetParent());
if (!widget) return;
m_ERRModsButton = SCR_ButtonTextComponent.Cast(widget.FindHandler(SCR_ButtonTextComponent));
if (m_ERRModsButton) m_ERRModsButton.m_OnClicked.Insert(ERR_OpenMods);
}
protected void ERR_OpenMods()
{
// Unpause local simulation before requesting an authenticated server snapshot.
Close();
ERR_AdminMenu.Open();
}
override void OnMenuClose()
{
if (m_ERRModsButton) m_ERRModsButton.m_OnClicked.Remove(ERR_OpenMods);
m_ERRModsButton = null;
super.OnMenuClose();
}
}
@@ -0,0 +1,136 @@
class ERR_ContentPolicy
{
protected bool m_FactionsPrepared;
protected int m_GuardOffset;
protected ref map<string, bool> m_Active = new map<string, bool>();
void ERR_ContentPolicy(ERR_ContentSettings settings)
{
foreach (string pack : ERR_ContentRegistry.Ids())
m_Active.Set(pack, settings && settings.Enabled(pack));
int count;
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (definition && definition.Valid())
foreach (ERR_ContentOption option : definition.Options)
if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id)) count++;
if (count > 0) m_GuardOffset = Math.RandomInt(0, count);
}
bool Enabled(string pack)
{
if (pack.IsEmpty()) return true; // Base game / owned mission content.
if (!m_Active.Contains(pack) || !m_Active.Get(pack)) return false;
ERR_ContentOption option = ERR_ContentRegistry.Definition().Find(pack);
return option && (option.Parent.IsEmpty() || (m_Active.Contains(option.Parent) && m_Active.Get(option.Parent)));
}
bool Allows(ResourceName prefab) { return Enabled(ERR_ContentRegistry.ResourcePack(prefab)); }
bool Enemy(Faction faction)
{
if (!faction) return false;
foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options)
if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id) && option.NativeFactionKey == faction.GetFactionKey()) return true;
return false;
}
ResourceName GuardGroup(int index)
{
array<ResourceName> groups = {};
foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options)
if (!option.NativeFactionKey.IsEmpty() && Enabled(option.Id)) groups.Insert(option.GuardGroup);
if (groups.IsEmpty()) return "";
return groups[(index + m_GuardOffset) % groups.Count()];
}
string GuardFaction(ResourceName prefab)
{
foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options)
if (option.GuardGroup == prefab && Enabled(option.Id)) return option.NativeFactionKey;
return "";
}
bool PrepareFactions(ERR_GameMode mode)
{
if (m_FactionsPrepared) return true;
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (!definition || !definition.Valid()) return false;
SCR_FactionManager manager = SCR_FactionManager.Cast(GetGame().GetFactionManager());
if (!manager) return false;
array<SCR_Faction> factions = {};
SCR_Faction player = SCR_Faction.Cast(mode.PlayerFaction());
SCR_Faction primary = SCR_Faction.Cast(mode.EnemyFaction());
if (!player || !primary) return false;
factions.Insert(player); factions.Insert(primary);
foreach (ERR_ContentOption option : definition.Options)
{
if (option.NativeFactionKey.IsEmpty() || !Enabled(option.Id)) continue;
SCR_Faction faction = SCR_Faction.Cast(manager.GetFactionByKey(option.NativeFactionKey));
Resource group = Resource.Load(option.GuardGroup);
if (!faction || !group || !group.IsValid()) return false;
if (factions.Contains(faction)) continue;
factions.Insert(faction);
}
// The manager mirrors both directions and persists relationships for JIP.
// Call both orders as an inherited one-way friendship may exist.
foreach (SCR_Faction a : factions)
{
manager.SetFactionsFriendly(a, a, -1, false);
foreach (SCR_Faction b : factions)
{
if (a == b) continue;
manager.SetFactionsHostile(a, b, -1, false);
manager.SetFactionsHostile(b, a, -1, false);
if (!a.IsFactionEnemy(b) || !b.IsFactionEnemy(a)) return false;
}
}
SCR_FactionManager.RequestUpdateAllTargetsFactions();
m_FactionsPrepared = true;
return true;
}
void Snapshot(array<string> values)
{
foreach (string pack : ERR_ContentRegistry.Ids())
{
int enabled = 0;
if (Enabled(pack)) enabled = 1;
values.Insert("activeMod_" + pack + "=" + enabled.ToString());
}
}
}
// Commands inherit the existing authenticated controller/session boundary.
class ERR_ContentAdminProvider : ERR_AdminProvider
{
protected ERR_ContentPolicy m_Policy;
void ERR_ContentAdminProvider(ERR_ContentPolicy policy) { m_Policy = policy; }
override string Id() { return "mods"; }
override void Capabilities(array<string> values)
{
m_Policy.Snapshot(values);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.ContentSettings()) mode.ContentSettings().Snapshot(values);
}
override bool Help(string topic, array<string> replies)
{
if (topic != "mods") return false;
replies.Insert("Use /esc mods show, or /esc mods <id> enable|disable. Saved selections apply after mission restart. A disabled pack also disables its factions. Workshop dependencies remain installed.");
return true;
}
override bool Execute(ERR_GameMode mode, string arguments, array<string> replies)
{
array<string> parts = {};
arguments.Split(" ", parts, true);
if (parts.IsEmpty() || parts[0] != "mods") return false;
if (arguments == "mods show")
{
foreach (string pack : ERR_ContentRegistry.Ids())
replies.Insert(ERR_ContentRegistry.Label(pack) + ": current round " + ERR_ContentRegistry.State(m_Policy.Enabled(pack)) + "; saved preference " + ERR_ContentRegistry.State(mode.ContentSettings().Enabled(pack)) + ".");
return true;
}
if (parts.Count() == 3 && ERR_ContentRegistry.Ids().Contains(parts[1]) && (parts[2] == "enable" || parts[2] == "disable"))
{
int value = 0;
if (parts[2] == "enable") value = 1;
if (mode.ContentSettings().Set(parts[1], value))
replies.Insert(ERR_ContentRegistry.Label(parts[1]) + ": " + ERR_ContentRegistry.State(value == 1) + " after mission restart. Current round unchanged.");
else replies.Insert("Could not save mod selection. Previous setting retained.");
return true;
}
Help("mods", replies);
return true;
}
}
@@ -0,0 +1,86 @@
// Separate, config-driven preferences. Missing newly installed options use their
// authored defaults. Failed writes preserve both previous state and backup.
class ERR_ContentSettings
{
static const string PATH = "$profile:EscapistsContent.json";
protected string m_Path;
protected bool m_ReadOnly;
protected ref map<string, int> m_Values = new map<string, int>();
void ERR_ContentSettings(string path = PATH)
{
m_Path = path;
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (!definition || !definition.Valid()) { m_ReadOnly = true; return; }
foreach (ERR_ContentOption option : definition.Options)
{
int value = 0;
if (option.DefaultEnabled) value = 1;
m_Values.Set(option.Id, value);
}
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
map<string, int> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded;
else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Escapists: invalid content preferences; authored defaults active, existing files preserved", LogLevel.ERROR);
}
}
bool Enabled(string id) { return m_Values.Contains(id) && m_Values.Get(id) == 1; }
protected bool Read(string path, out map<string, int> values)
{
JsonLoadContext json = new JsonLoadContext();
if (!json.LoadFromFile(path)) return false;
values = new map<string, int>();
foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options)
{
int fallback = 0;
if (option.DefaultEnabled) fallback = 1;
if (!json.DoesKeyExist(option.Id)) { values.Set(option.Id, fallback); continue; }
float number;
if (!json.ReadValue(option.Id, number) || (number != 0 && number != 1)) return false;
values.Set(option.Id, Math.Round(number));
}
return true;
}
protected bool Matches(map<string, int> values, string changed, int value)
{
if (!values) return false;
foreach (string id, int original : m_Values)
{
int expected = original;
if (id == changed) expected = value;
if (!values.Contains(id) || values.Get(id) != expected) return false;
}
return true;
}
bool Set(string id, int value)
{
if (!Replication.IsServer() || m_ReadOnly || !m_Values.Contains(id) || (value != 0 && value != 1)) return false;
JsonSaveContext json = new JsonSaveContext();
json.EnableTypeDiscriminator(false);
foreach (string option, int original : m_Values)
{
int saved = original;
if (option == id) saved = value;
json.WriteValue(option, saved);
}
map<string, int> checked;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Matches(checked, id, value)) return false;
map<string, int> previous;
bool hadPrevious = Read(m_Path, previous);
if (hadPrevious && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false;
if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked) || !Matches(checked, id, value))
{
if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
else if (!hadPrevious) FileIO.DeleteFile(m_Path);
return false;
}
m_Values = checked;
return true;
}
void Snapshot(array<string> values)
{
foreach (string id, int saved : m_Values) values.Insert(ERR_ContentRegistry.Key(id) + "=" + saved.ToString());
}
}
@@ -0,0 +1,43 @@
// Invoked only through the authenticated, current-body admin tool boundary.
class ERR_DebugAI
{
static int KillNearby(ERR_GameMode mode, IEntity center)
{
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !center || !GetGame().GetAIWorld()) return 0;
array<ref ERR_PlayerLife> lives = {}; mode.Players().Participants(lives);
array<IEntity> protectedBodies = {};
foreach (ERR_PlayerLife life : lives) if (life.Actor) protectedBodies.Insert(life.Actor);
array<AIAgent> agents = {}; GetGame().GetAIWorld().GetAIAgents(agents);
array<IEntity> targets = {};
foreach (AIAgent agent : agents)
{
if (!agent) continue;
SCR_AIGroup group = SCR_AIGroup.Cast(agent);
if (group)
{
array<AIAgent> members = {}; group.GetAgents(members);
foreach (AIAgent member : members) if (member) Add(member.GetControlledEntity(), center, protectedBodies, targets);
}
Add(agent.GetControlledEntity(), center, protectedBodies, targets);
}
int killed;
foreach (IEntity target : targets)
{
// Recheck ownership immediately before damage; possession can change.
if (!Eligible(target, center, protectedBodies)) continue;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(target.FindComponent(SCR_CharacterDamageManagerComponent));
if (!damage) continue;
damage.Kill(Instigator.CreateInstigator(center));
if (damage.IsDestroyed()) killed++;
}
return killed;
}
static bool Eligible(IEntity target, IEntity center, array<IEntity> protectedBodies)
{
return target && ChimeraCharacter.Cast(target) && target != center && ERR_Actors.Alive(target) && !ERR_Actors.Player(target) && !protectedBodies.Contains(target) && vector.Distance(target.GetOrigin(), center.GetOrigin()) <= 1000;
}
protected static void Add(IEntity target, IEntity center, array<IEntity> protectedBodies, array<IEntity> targets)
{
if (Eligible(target, center, protectedBodies) && !targets.Contains(target)) targets.Insert(target);
}
}
@@ -0,0 +1,30 @@
class ERR_DebugIntel
{
static bool SpawnSix(ERR_GameMode mode, IEntity actor)
{
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !ERR_Actors.CanFight(actor) || !mode.Players().CurrentActor(actor)) return false;
ERR_SpawnPlacement placement = new ERR_SpawnPlacement();
map<int, vector> positions = new map<int, vector>();
// Survey all six spots first, including solid obstructions and the path from
// the player. This also refuses airborne/seated players and deep water.
for (int i = 0; i < 6; i++)
{
vector position;
if (!placement.Near(actor, positions, position)) return false;
positions.Set(i, position);
}
array<IEntity> spawned = {};
for (int j = 0; j < 6; j++)
{
IEntity document = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), positions.Get(j));
if (!document)
{
foreach (IEntity partial : spawned) ERR_Actors.DeleteSpawned(partial);
return false;
}
spawned.Insert(document);
}
foreach (IEntity intel : spawned) mode.Own(intel);
return true;
}
}
@@ -14,6 +14,38 @@ class ERR_DiscoveryMapRules
// that boundary so native edit/drag cannot move a discovered objective.
class ERR_DiscoveryMarker : SCR_MapMarkerBase
{
protected string m_Icon = "supplies";
static string Icon(ERR_LocationKind kind, string id)
{
if (kind == ERR_LocationKind.EXTRACTION) return "helicopters";
if (kind == ERR_LocationKind.RADIO) return "radio";
if (kind == ERR_LocationKind.MORTAR) return "artilleryFireMission";
if (kind == ERR_LocationKind.OUTPOST) return "building";
if (id.Contains("medical")) return "medical";
return "supplies";
}
void SetLocationIcon(ERR_LocationKind kind, string id, int knowledge = 2)
{
m_Icon = "mark-question";
if (knowledge >= 2) m_Icon = Icon(kind, id);
ApplyIcon();
}
protected void ApplyIcon()
{
SCR_MapMarkerWidgetComponent widget = GetMarkerComponent();
if (!GetRootWidget() || !widget) return;
if (m_Icon == "mark-question")
{
widget.SetImage("{E23427CAC80DA8B7}UI/Textures/Icons/icons_mapMarkersUI.imageset", m_Icon);
widget.SetGlowImage("{67B3A6DC2D712B52}UI/Textures/Icons/icons_mapMarkersUI-glow.imageset", m_Icon);
}
else
{
widget.SetImage("{3262679C50EF4F01}UI/Textures/Icons/icons_wrapperUI.imageset", m_Icon);
widget.SetGlowImage("{00FE3DBDFD15227B}UI/Textures/Icons/icons_wrapperUI-glow.imageset", m_Icon);
}
widget.SetColor(Color.FromSRGBA(35, 45, 50, 255));
}
void ERR_DiscoveryMarker() { SetCanBeRemovedByOwner(false); }
void SetDiscoveryPosition(vector position, int knowledge)
{
@@ -25,6 +57,7 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
// Text is scenario-authored, never user input. Avoid delayed UGC callbacks
// overwriting a newer discovery label after the location becomes identified.
super.OnCreateMarker(true);
ApplyIcon();
}
override void OnDelete()
{
@@ -73,17 +106,26 @@ modded class ERR_LocationComponent
}
protected void ERR_UpdateMarker()
{
if (!GetGame().GetPlayerController() || Knowledge() <= 0 || (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel)) return;
if (!GetGame().GetPlayerController()) return;
if (Kind() == ERR_LocationKind.PRISON || Knowledge() < 1)
{
SCR_MapMarkerManagerComponent oldManager = SCR_MapMarkerManagerComponent.GetInstance();
if (m_ERRMarker && oldManager) oldManager.ERR_RemoveDiscoveryMarker(m_ERRMarker);
else if (m_ERRMarker) m_ERRMarker.OnDelete();
m_ERRMarker = null; m_ERRShown = 0; m_ERRShownLabel = "";
return;
}
if (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel) return;
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return;
string label = "Search area (500 m square)";
string label = "Unknown location (500 m area)";
if (Knowledge() == 2)
{
if (!Label().IsEmpty()) label = Label();
else if (Kind() == ERR_LocationKind.RADIO) label = "Radio station";
else if (Kind() == ERR_LocationKind.EXTRACTION) label = "Initial helicopter pickup site";
else if (Kind() == ERR_LocationKind.DEPOT) label = "Supply cache";
else if (Kind() == ERR_LocationKind.PRISON) label = "Prison";
else if (Kind() == ERR_LocationKind.MORTAR) label = "Mortar site";
else label = Id();
}
if (!m_ERRMarker)
@@ -92,10 +134,12 @@ modded class ERR_LocationComponent
m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge());
m_ERRMarker.SetMarkerConfigID(0);
m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge());
m_ERRMarker.SetCustomText(label);
manager.InsertStaticMarker(m_ERRMarker, true);
}
m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge());
m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge());
m_ERRMarker.SetCustomText(label);
// Reactivate a previously culled rough marker if the exact point moves
// into the open map's view. Native update performs normal visibility checks.
@@ -57,18 +57,24 @@ class ERR_DiscoveryService
m_Mode.Locations(locations);
array<ERR_LocationComponent> eligible = {};
ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent));
string sealedTarget = m_Mode.Evidence().Target(folder);
foreach (ERR_LocationComponent location : locations)
{
if (!location || location.Knowledge() >= 2) continue;
if (!sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue;
if (document)
{
if (location.Id() == document.TargetId()) eligible.Insert(location);
}
else if (location.Kind() != ERR_LocationKind.PRISON && location.Kind() != ERR_LocationKind.EXTRACTION) eligible.Insert(location);
}
if (eligible.IsEmpty()) continue;
if (eligible.IsEmpty())
{
if (!document && MarkSpent(folder)) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
continue;
}
ERR_LocationComponent chosen = eligible.GetRandomElement();
if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Get("intelRevealChancePercent"))) continue;
if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Runtime("intelRevealChancePercent"))) continue;
m_Spent.Insert(folder);
PrintFormat("Reintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge());
}
@@ -66,12 +66,40 @@ class ERR_EnvironmentService
if (state && state.GetStateName() == name) return true;
return false;
}
static int DateFromOrdinal(int year, int ordinal)
{
if (year < 1900 || year > 2050 || ordinal < 0) return -1;
for (int month = 1; month <= 12; month++)
{
int days = 31;
if (month == 4 || month == 6 || month == 9 || month == 11) days = 30;
if (month == 2)
{
days = 28;
if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) days = 29;
}
if (ordinal < days) return (year - 1900) * 10000 + month * 100 + ordinal + 1;
ordinal -= days;
}
return -1;
}
static int RandomDate(int year)
{
int days = 365;
if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) days = 366;
return DateFromOrdinal(year, Math.RandomInt(0, days));
}
bool Start(ERR_GameMode mode)
{
if (!Replication.IsServer() || !mode || !mode.Settings()) return false;
ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld());
if (!world) return false;
return StartOn(world.GetTimeAndWeatherManager(), mode.Settings().Get("startDateCode"), mode.Settings().Get("weatherProfile"));
if (m_Started) return !m_Stopped;
TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager();
if (!manager) return false;
int dateCode = mode.Settings().Get("startDateCode");
if (dateCode == -1) dateCode = RandomDate(manager.GetYear());
return StartOn(manager, dateCode, mode.Settings().Runtime("weatherProfile"));
}
protected bool StartOn(TimeAndWeatherManagerEntity manager, int dateCode, int profile)
{
@@ -144,7 +172,7 @@ class ERR_EnvironmentCommands
if (parts.Count() == 3 && parts[2] == "show")
{
if (environment) replies.Insert(environment.Status());
replies.Insert("Next round: date " + ERR_EnvironmentService.Date(settings.Get("startDateCode")) + ", weather " + ERR_EnvironmentService.ProfileName(settings.Get("weatherProfile")) + ".");
replies.Insert("Next round: date " + ERR_AdminMenuFields.Display("startDateCode", settings.Get("startDateCode").ToString()) + ", weather " + ERR_AdminMenuFields.Display("weatherProfile", settings.Get("weatherProfile").ToString()) + ".");
return true;
}
int value;
@@ -153,12 +181,11 @@ class ERR_EnvironmentCommands
if (parts[1] == "weather") key = "weatherProfile";
if (parts.Count() == 4 && parts[2] == "set")
{
if (key == "startDateCode") valid = ERR_EnvironmentService.ParseDate(parts[3], value);
else valid = ERR_EnvironmentService.ParseProfile(parts[3], value);
valid = ERR_CommandArguments.Selection(key, parts[3], value);
}
if (valid && settings.Set(key, value))
replies.Insert("Saved for next round: date " + ERR_EnvironmentService.Date(settings.Get("startDateCode")) + ", weather " + ERR_EnvironmentService.ProfileName(settings.Get("weatherProfile")) + ". Current round unchanged.");
else replies.Insert("Invalid environment or settings could not be saved. Use mission date set YYYY-MM-DD (1900-2050) or authored; mission weather set authored|clear|overcast|rain. Previous setting retained.");
replies.Insert("Saved for next round: date " + ERR_AdminMenuFields.Display("startDateCode", settings.Get("startDateCode").ToString()) + ", weather " + ERR_AdminMenuFields.Display("weatherProfile", settings.Get("weatherProfile").ToString()) + ". Current round unchanged.");
else replies.Insert("Invalid environment or settings could not be saved. Use mission date set YYYY-MM-DD (1900-2050) or authored|random; mission weather set authored|clear|overcast|rain|random. Previous setting retained.");
return true;
}
}
@@ -0,0 +1,96 @@
// Uses only authenticated server snapshots for eligibility and saved role choices.
modded class ERR_AdminMenu
{
protected ref array<string> m_FactionIds = {};
protected ref array<SCR_ButtonTextComponent> m_FactionButtons = {};
protected ref map<string, string> m_FactionSaved = new map<string, string>();
protected ref map<string, string> m_FactionActive = new map<string, string>();
protected bool m_FactionFieldsInitialized;
override void OnMenuOpen()
{
super.OnMenuOpen();
array<string> names = {"FactionsApply", "FactionsRandomAll"};
foreach (string name : names)
{
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget());
if (!button) { Close(); return; }
button.m_OnClicked.Insert(OnFactionButton); m_FactionButtons.Insert(button);
}
UpdateActions();
}
override void OnMenuClose()
{
foreach (SCR_ButtonTextComponent button : m_FactionButtons) if (button) button.m_OnClicked.Remove(OnFactionButton);
m_FactionButtons.Clear(); m_FactionIds.Clear(); m_FactionSaved.Clear(); m_FactionActive.Clear(); m_FactionFieldsInitialized = false;
super.OnMenuClose();
}
protected override void UpdateActions()
{
super.UpdateActions();
bool ready = m_Ready && m_FactionSaved.Count() == 3 && m_FactionActive.Count() == 3;
foreach (SCR_ButtonTextComponent button : m_FactionButtons)
if (button) button.SetEnabled(ready);
foreach (string role : ERR_FactionChoices.Roles())
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget());
if (combo) combo.SetEnabled(ready);
if (ready) SetText("FactionState_" + role, "Current: " + ERR_FactionChoices.Label(m_FactionActive.Get(role)) + " | Saved: " + ERR_FactionChoices.Label(m_FactionSaved.Get(role)));
}
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
m_FactionSaved.Clear(); m_FactionActive.Clear();
array<string> available = {"random"};
if (authorized && values)
foreach (string value : values)
{
array<string> pair = {}; value.Split("=", pair, true);
if (pair.Count() != 2) continue;
if (pair[0] == "factionAvailable" && ERR_FactionChoices.Find(pair[1]) && !available.Contains(pair[1])) available.Insert(pair[1]);
foreach (string role : ERR_FactionChoices.Roles())
{
if (pair[0] == "factionSaved_" + role) m_FactionSaved.Set(role, pair[1]);
if (pair[0] == "factionActive_" + role) m_FactionActive.Set(role, pair[1]);
}
}
bool changed = available.Count() != m_FactionIds.Count();
if (!changed) for (int i = 0; i < available.Count(); i++) if (available[i] != m_FactionIds[i]) changed = true;
if (authorized && m_FactionSaved.Count() == 3 && (changed || !m_FactionFieldsInitialized))
{
foreach (string role : ERR_FactionChoices.Roles())
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget());
if (!combo) continue;
string draft = m_FactionSaved.Get(role);
int previous = combo.GetCurrentIndex();
if (m_FactionFieldsInitialized && previous >= 0 && previous < m_FactionIds.Count()) draft = m_FactionIds[previous];
combo.ClearAll();
foreach (string id : available) combo.AddItem(ERR_FactionChoices.Label(id));
combo.SetCurrentItem(Math.Max(0, available.Find(draft)), false, false, false);
}
m_FactionIds.Copy(available); m_FactionFieldsInitialized = true;
}
super.ReceiveSnapshot(authorized, values, status);
}
protected void OnFactionButton(SCR_ButtonBaseComponent button)
{
if (!button) return;
string name = button.GetRootWidget().GetName();
if (!m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0 || !m_FactionFieldsInitialized) return;
if (name == "FactionsRandomAll")
{
foreach (string role : ERR_FactionChoices.Roles()) SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()).SetCurrentItem(0);
return;
}
if (name != "FactionsApply") return;
string command = "factions set";
foreach (string role : ERR_FactionChoices.Roles())
{
int index = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()).GetCurrentIndex();
if (index < 0 || index >= m_FactionIds.Count()) return;
command += " " + m_FactionIds[index];
}
m_ClickDelay = 0.3; SetText("Feedback", "Saving faction selections...");
m_Controller.ERR_AdminSubmit(command, true);
}
}
@@ -0,0 +1,268 @@
// Saved role choices are stable registry IDs, never menu indexes. Random resolves
// once per round on the server, without replacement across the three roles.
class ERR_FactionChoice
{
string Id;
string Label;
string Key;
ResourceName Group;
void ERR_FactionChoice(string id, string label, string key, ResourceName group)
{ Id = id; Label = label; Key = key; Group = group; }
}
class ERR_FactionChoices
{
static array<string> Roles() { return {"escapists", "enemy", "independent"}; }
static array<ref ERR_FactionChoice> All()
{
array<ref ERR_FactionChoice> choices = {};
choices.Insert(new ERR_FactionChoice("us", "US Army", "US", "{84E5BBAB25EA23E5}Prefabs/Groups/BLUFOR/Group_US_FireTeam.et"));
choices.Insert(new ERR_FactionChoice("ussr", "Soviet Army", "USSR", "{D5A4D0D3AE1A3E26}Prefabs/ERR/Patrol.et"));
choices.Insert(new ERR_FactionChoice("fia", "FIA", "FIA", "{5BEA04939D148B1D}Prefabs/Groups/INDFOR/Group_FIA_FireTeam.et"));
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (definition && definition.Valid())
foreach (ERR_ContentOption option : definition.Options)
{
if (option.NativeFactionKey.IsEmpty()) continue;
bool duplicate;
foreach (ERR_FactionChoice existing : choices) if (existing.Id == option.Id || existing.Key == option.NativeFactionKey) duplicate = true;
if (!duplicate) choices.Insert(new ERR_FactionChoice(option.Id, option.Label, option.NativeFactionKey, option.GuardGroup));
}
return choices;
}
static ERR_FactionChoice Find(string id)
{
foreach (ERR_FactionChoice choice : All()) if (choice.Id == id) return choice;
return null;
}
static string Label(string id)
{
if (id == "random") return "Random";
ERR_FactionChoice choice = Find(id);
if (choice) return choice.Label;
return "Unavailable (" + id + ")";
}
static bool Enabled(string id, ERR_ContentSettings settings)
{
if (id == "us" || id == "ussr" || id == "fia") return true;
ERR_ContentOption option = ERR_ContentRegistry.Definition().Find(id);
return option && settings && settings.Enabled(id) && (option.Parent.IsEmpty() || settings.Enabled(option.Parent));
}
static bool Resolve(array<string> available, map<string, string> saved, map<string, string> resolved)
{
resolved.Clear();
if (available.Count() < 3 || !Distinct(saved)) return false;
// Reserve every explicit choice first, so Random cannot steal a later role.
foreach (string fixedRole : Roles())
{
string fixedId = saved.Get(fixedRole);
if (fixedId == "random" || !available.Contains(fixedId)) continue;
resolved.Set(fixedRole, fixedId); available.RemoveItem(fixedId);
}
foreach (string randomRole : Roles())
{
if (resolved.Contains(randomRole)) continue;
if (available.IsEmpty()) return false;
int index = Math.RandomInt(0, available.Count());
resolved.Set(randomRole, available[index]); available.Remove(index);
}
return true;
}
static bool Distinct(map<string, string> selections)
{
array<string> fixed = {};
foreach (string role : Roles())
{
if (!selections.Contains(role)) return false;
string id = selections.Get(role);
if (id == "random") continue;
if (!Find(id) || fixed.Contains(id)) return false;
fixed.Insert(id);
}
return true;
}
}
class ERR_FactionSelection
{
static const string PATH = "$profile:EscapistsFactions.json";
protected string m_Path;
protected bool m_ReadOnly;
protected bool m_Ready;
protected ref map<string, string> m_Saved = new map<string, string>();
protected ref map<string, string> m_Resolved = new map<string, string>();
void ERR_FactionSelection(string path = PATH)
{
m_Path = path;
m_Saved.Set("escapists", "us"); m_Saved.Set("enemy", "ussr"); m_Saved.Set("independent", "fia");
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
map<string, string> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Saved = loaded;
else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak"))
{ m_ReadOnly = true; Print("Escapists: invalid faction selections; defaults active and files preserved", LogLevel.ERROR); }
}
protected bool Read(string path, out map<string, string> values)
{
JsonLoadContext json = new JsonLoadContext();
if (!json.LoadFromFile(path)) return false;
values = new map<string, string>();
foreach (string role : ERR_FactionChoices.Roles())
{
string id;
if (!json.ReadValue(role, id) || (id != "random" && !ERR_FactionChoices.Find(id))) return false;
values.Set(role, id);
}
return ERR_FactionChoices.Distinct(values);
}
protected bool Same(map<string, string> a, map<string, string> b)
{
if (!a || !b) return false;
foreach (string role : ERR_FactionChoices.Roles()) if (!a.Contains(role) || !b.Contains(role) || a.Get(role) != b.Get(role)) return false;
return true;
}
string Saved(string role) { return m_Saved.Get(role); }
string Active(string role) { return m_Resolved.Get(role); }
bool Ready() { return m_Ready; }
ERR_FactionChoice Choice(string role) { return ERR_FactionChoices.Find(Active(role)); }
ResourceName Soldier()
{
ERR_FactionChoice enemy = Choice("enemy");
if (!enemy) return "";
Resource resource = Resource.Load(enemy.Group);
if (!resource || !resource.IsValid()) return "";
IEntitySource source = resource.GetResource().ToEntitySource();
array<ResourceName> members = {};
if (!source || !source.Get("m_aUnitPrefabSlots", members) || members.IsEmpty()) return "";
return members[0];
}
bool Save(array<string> ids, ERR_ContentSettings content)
{
if (!Replication.IsServer() || m_ReadOnly || !ids || ids.Count() != 3) return false;
map<string, string> values = new map<string, string>();
array<string> roles = ERR_FactionChoices.Roles();
for (int i = 0; i < roles.Count(); i++)
{
if (ids[i] != "random" && !ERR_FactionChoices.Enabled(ids[i], content)) return false;
values.Set(roles[i], ids[i]);
}
if (!ERR_FactionChoices.Distinct(values)) return false;
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
foreach (string role : roles) json.WriteValue(role, values.Get(role));
map<string, string> checked;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Same(values, checked)) return false;
map<string, string> previous;
bool hadPrevious = Read(m_Path, previous);
if (hadPrevious && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false;
if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked) || !Same(values, checked))
{
if (hadPrevious) FileIO.CopyFile(m_Path + ".bak", m_Path);
else FileIO.DeleteFile(m_Path);
return false;
}
m_Saved = checked; return true;
}
bool Prepare(ERR_GameMode mode)
{
if (m_Ready) return true;
if (!Replication.IsServer() || !mode || !mode.Factions()) return false;
m_Resolved.Clear();
array<string> available = {};
foreach (ERR_FactionChoice option : ERR_FactionChoices.All())
{
if (!ERR_FactionChoices.Enabled(option.Id, mode.ContentSettings())) continue;
if (!GetGame().GetFactionManager().GetFactionByKey(option.Key)) continue;
Resource resource = Resource.Load(option.Group);
if (resource && resource.IsValid()) available.Insert(option.Id);
}
if (available.Count() < 3) return false;
if (!ERR_FactionChoices.Resolve(available, m_Saved, m_Resolved)) return false;
mode.Factions().PlayerFactionKey = Choice("escapists").Key;
mode.Factions().EnemyFactionKey = Choice("enemy").Key;
mode.Factions().GuardGroupPrefab = Choice("enemy").Group;
SCR_FactionManager manager = SCR_FactionManager.Cast(GetGame().GetFactionManager());
if (!manager) return false;
foreach (string a : ERR_FactionChoices.Roles())
foreach (string b : ERR_FactionChoices.Roles())
{
SCR_Faction first = SCR_Faction.Cast(manager.GetFactionByKey(Choice(a).Key));
SCR_Faction second = SCR_Faction.Cast(manager.GetFactionByKey(Choice(b).Key));
if (a == b) manager.SetFactionsFriendly(first, second, -1, false);
else manager.SetFactionsHostile(first, second, -1, false);
}
SCR_Faction player = SCR_Faction.Cast(manager.GetFactionByKey(Choice("escapists").Key));
player.SetIsPlayable(true);
if (!player.IsPlayable()) return false;
m_Ready = true;
mode.PublishFactionRoles(Choice("escapists").Key, Choice("enemy").Key);
PrintFormat("Escapists factions: players=%1 enemy=%2 independent=%3", Choice("escapists").Key, Choice("enemy").Key, Choice("independent").Key);
return true;
}
void Snapshot(ERR_ContentSettings content, array<string> values)
{
foreach (string role : ERR_FactionChoices.Roles())
{
values.Insert("factionSaved_" + role + "=" + Saved(role));
values.Insert("factionActive_" + role + "=" + Active(role));
}
foreach (ERR_FactionChoice option : ERR_FactionChoices.All())
if (ERR_FactionChoices.Enabled(option.Id, content)) values.Insert("factionAvailable=" + option.Id);
}
}
class ERR_FactionAdminProvider : ERR_AdminProvider
{
override string Id() { return "factions"; }
override void Capabilities(array<string> values)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.FactionSelection()) mode.FactionSelection().Snapshot(mode.ContentSettings(), values);
}
override bool Execute(ERR_GameMode mode, string arguments, array<string> replies)
{
array<string> parts = {}; arguments.Split(" ", parts, true);
if (parts.IsEmpty() || parts[0] != "factions") return false;
if (parts.Count() == 5 && parts[1] == "set")
{
array<string> ids = {parts[2], parts[3], parts[4]};
if (mode.FactionSelection().Save(ids, mode.ContentSettings())) replies.Insert("Faction selections saved for next round. Current factions unchanged.");
else replies.Insert("Could not save factions. Choose three distinct enabled factions, or Random. Previous selections retained.");
return true;
}
foreach (string role : ERR_FactionChoices.Roles()) replies.Insert(role + ": " + ERR_FactionChoices.Label(mode.FactionSelection().Active(role)) + "; next round: " + ERR_FactionChoices.Label(mode.FactionSelection().Saved(role)));
replies.Insert("Use the Factions menu, or /esc factions set <escapists> <enemy> <independent>; each accepts a faction ID or random.");
return true;
}
}
// Only the live mission policy follows role selections. Isolated policy probes
// and registry tooling keep the generic content eligibility behavior.
modded class ERR_ContentPolicy
{
protected ERR_FactionSelection Roles()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.Content() == this && mode.FactionSelection() && mode.FactionSelection().Ready()) return mode.FactionSelection();
return null;
}
override bool Enemy(Faction faction)
{
ERR_FactionSelection roles = Roles();
if (roles) return faction && faction.GetFactionKey() == roles.Choice("independent").Key;
return super.Enemy(faction);
}
override ResourceName GuardGroup(int index)
{
ERR_FactionSelection roles = Roles();
if (!roles) return super.GuardGroup(index);
if (index % 2 == 0) return roles.Choice("independent").Group;
return roles.Choice("enemy").Group;
}
override string GuardFaction(ResourceName prefab)
{
ERR_FactionSelection roles = Roles();
if (roles)
{
if (prefab == roles.Choice("independent").Group) return roles.Choice("independent").Key;
if (prefab == roles.Choice("enemy").Group) return roles.Choice("enemy").Key;
return "";
}
return super.GuardFaction(prefab);
}
}
@@ -104,9 +104,9 @@ class ERR_ForceLedger
if (group && FindGroup(group)) return null;
// Retired source IDs also occupy the finite pool: re-registering a removed
// formation cannot replace its casualties with a fresh full-strength unit.
if (m_Records.Count() >= 128 || m_Sources.Count() >= 128)
if (m_Records.Count() >= 256 || m_Sources.Count() >= 256)
{
if (!m_CapLogged) Print("Reintroduced [ledger]: 128-formation registration cap reached.");
if (!m_CapLogged) Print("Reintroduced [ledger]: 256-formation registration cap reached.");
m_CapLogged = true;
return null;
}
@@ -3,6 +3,8 @@ class ERR_GameMode : SCR_BaseGameMode
{
[RplProp(), Attribute("6090 0 7160", UIWidgets.Coords, "Prison position; configured from authored scenario")]
protected vector m_PrisonPosition;
[RplProp()] protected float m_PrisonHeading;
float PrisonHeading() { return m_PrisonHeading; }
[Attribute("6090 0 7160", UIWidgets.Coords, "Centre of extraction boundary")]
protected vector m_EscapeCenter;
[RplProp(), Attribute("9000", UIWidgets.EditBox, "Helicopter must cross this radius to escape")]
@@ -14,7 +16,18 @@ class ERR_GameMode : SCR_BaseGameMode
[RplProp()]
protected string m_PreparationError;
protected ref ERR_FactionCatalog m_Factions;
protected ref ERR_FactionSelection m_FactionSelection;
[RplProp()] protected string m_SelectedPlayerFaction;
[RplProp()] protected string m_SelectedEnemyFaction;
ERR_FactionSelection FactionSelection() { return m_FactionSelection; }
void PublishFactionRoles(string player, string enemy)
{
if (!Replication.IsServer()) return;
m_SelectedPlayerFaction = player; m_SelectedEnemyFaction = enemy; Replication.BumpMe();
}
protected ref ERR_Settings m_Settings;
protected ref ERR_ContentPolicy m_Content;
protected ref ERR_ContentSettings m_ContentSettings;
protected ref ERR_TimeService m_Time = new ERR_TimeService();
protected ref ERR_EnvironmentService m_Environment = new ERR_EnvironmentService();
protected ref ERR_DiscoveryService m_Discovery;
@@ -60,6 +73,12 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_PlayerRoster Players() { return m_Roster; }
ERR_AdminService Admin() { return m_Admin; }
ERR_Settings Settings() { return m_Settings; }
ERR_ContentPolicy Content() { return m_Content; }
ERR_ContentSettings ContentSettings() { return m_ContentSettings; }
bool IsMissionEnemy(Faction faction)
{
return faction && (faction == EnemyFaction() || (m_Content && m_Content.Enemy(faction)));
}
ERR_DiscoveryService Discovery() { return m_Discovery; }
ERR_TimeService Time() { return m_Time; }
ERR_EnvironmentService Environment() { return m_Environment; }
@@ -112,11 +131,13 @@ class ERR_GameMode : SCR_BaseGameMode
Faction PlayerFaction()
{
if (!m_Factions) return null;
if (!m_SelectedPlayerFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedPlayerFaction);
return m_Factions.PlayerFaction();
}
Faction EnemyFaction()
{
if (!m_Factions) return null;
if (!m_SelectedEnemyFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedEnemyFaction);
return m_Factions.EnemyFaction();
}
string PreparationError() { return m_PreparationError; }
@@ -164,10 +185,11 @@ class ERR_GameMode : SCR_BaseGameMode
bool Active() { return !m_Stopped && !m_NativeEndHandled && m_Phase >= ERR_Phase.PRISON && !ERR_MissionRules.Terminal(m_Phase); }
protected ERR_CommandService CreateCommandService() { return null; }
void ConfigureStart(vector position)
void ConfigureStart(vector position, float heading = 0)
{
if (!Replication.IsServer() || m_Phase != ERR_Phase.PREPARING) return;
m_PrisonPosition = position;
m_PrisonHeading = heading;
m_EscapeCenter = position;
Replication.BumpMe();
}
@@ -226,6 +248,13 @@ class ERR_GameMode : SCR_BaseGameMode
}
}
if (m_Command) m_Command.RegisterGroup(group);
ERR_ForceRecord auditRecord = m_Forces.FindGroup(group);
string auditSource = "mission-owned hostile faction";
if (auditRecord) auditSource = auditRecord.SourceId;
else if (Sites() && Sites().FindGroup(group)) auditSource = Sites().FindGroup(group).Definition.Id;
string auditFaction;
if (group.GetFaction()) auditFaction = group.GetFaction().GetFactionKey();
PrintFormat("Reintroduced [SPAWN GROUP]: source=%1 faction=%2 group=%3 position=%4 living=%5", auditSource, auditFaction, group, group.GetOrigin(), ForceLiving(group));
m_OnGroup.Invoke(group);
}
@@ -358,6 +387,11 @@ class ERR_GameMode : SCR_BaseGameMode
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true);
if (!Replication.IsServer()) return;
m_Settings = new ERR_Settings(SettingsPath());
m_ContentSettings = new ERR_ContentSettings();
m_Content = new ERR_ContentPolicy(m_ContentSettings);
m_FactionSelection = new ERR_FactionSelection();
m_Admin.Register(new ERR_ContentAdminProvider(m_Content));
m_Admin.Register(new ERR_FactionAdminProvider());
OnStop().Insert(m_Environment.Stop);
m_Recovery = new ERR_RecoveryService(this);
m_Recovery.OnChanged().Insert(RecoveryChanged);
@@ -405,7 +439,9 @@ class ERR_GameMode : SCR_BaseGameMode
protected void StartScenario()
{
if (m_Stopped || m_NativeEndHandled || m_Phase != ERR_Phase.PREPARING || !m_PreparationError.IsEmpty()) return;
if (!m_FactionSelection || !m_FactionSelection.Prepare(this)) { FailPreparation("Three enabled faction profiles are required for the mission roles."); return; }
if (!m_Factions || !m_Factions.WorldReady()) { FailPreparation("The configured player and enemy factions or required assets are missing, invalid or not hostile."); return; }
if (!m_Content || !m_Content.PrepareFactions(this)) { FailPreparation("The content registry or enabled faction profiles are invalid. Check faction keys, guard groups and the mission faction manager."); return; }
// Scenario content is created through a separate authoring component.
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(FindComponent(ERR_ScenarioComponent));
ERR_PrepareResult prepared = ERR_PrepareResult.FAILED;
@@ -0,0 +1,148 @@
// Clear-ground sampling adapted from the owner's improvised-prison extension (see migration provenance).
// Searches create no entities and finish before any site starts spawning.
class ERR_CampTerrain
{
protected ref array<vector> m_Airfields = {};
protected bool Airport(IEntity entity)
{
if (!entity || !entity.GetPrefabData()) return false;
string path = entity.GetPrefabData().GetPrefabName(); path.ToLower();
return path.Contains("prefabs/structures/") && (path.Contains("/runway") || path.Contains("/lightrunway_") || path.Contains("/aircontroltower") || path.Contains("/airportcontroltower") || path.Contains("/controltower"));
}
protected bool AddAirport(IEntity entity) { m_Airfields.Insert(entity.GetOrigin()); return true; }
void Collect(vector low, vector high)
{
GetGame().GetWorld().QueryEntitiesByAABB(Vector(low[0], -1000, low[2]), Vector(high[0], 5000, high[2]), AddAirport, Airport, EQueryEntitiesFlags.ALL);
}
static vector Rotate(vector offset, float heading)
{
vector rotation[3]; Math3D.AnglesToMatrix(Vector(heading, 0, 0), rotation);
return rotation[0] * offset[0] + rotation[1] * offset[1] + rotation[2] * offset[2];
}
static vector Local(vector offset, float heading)
{
vector rotation[3]; Math3D.AnglesToMatrix(Vector(heading, 0, 0), rotation);
return Vector(vector.Dot(offset, rotation[0]), offset[1], vector.Dot(offset, rotation[2]));
}
bool Clear(vector center, float heading, float halfSize, float maximumRange, out float lowest)
{
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;
}
// A 2m grid covers the walls, door approach, furniture and all interior slots.
for (float dx = -halfSize; dx <= halfSize; dx += 2)
for (float dz = -halfSize; dz <= halfSize; dz += 2)
{
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;
}
for (float cx = -halfSize + 1; cx < halfSize; cx += 2)
for (float cz = -halfSize + 1; cz < halfSize; cz += 2)
{
vector cell = center + Rotate(Vector(cx, 0, cz), heading);
float ground = GetGame().GetWorld().GetSurfaceY(cell[0], cell[2]);
TraceBox trace = new TraceBox();
trace.Start = Vector(cell[0], ground + 0.12, cell[2]); trace.End = trace.Start + "0 4 0";
// 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;
}
return true;
}
bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
{
bool prison = site.Kind == ERR_LocationKind.PRISON;
if (prison)
{
foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < 1500) return false;
}
SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return false;
BaseRoad road; float distance;
aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
if (road && distance < 12) return false;
if (!Clear(position, heading, 10, 0.6, baseHeight)) return false;
float guardHeight;
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false;
return true;
}
}
class ERR_GeneratedSites
{
protected ERR_ScenarioDefinition m_Definition;
protected ref ERR_CampTerrain m_Terrain = new ERR_CampTerrain();
protected ref array<ERR_SiteDefinition> m_Pending = {};
protected vector m_Low, m_High;
protected int m_Index, m_Attempts, m_Began;
protected bool m_Started;
protected ERR_PrepareResult m_Result = ERR_PrepareResult.PENDING;
protected string m_Error;
string Error() { return m_Error; }
bool Begin(ERR_ScenarioDefinition definition)
{
if (m_Started || !Replication.IsServer() || !definition || !definition.Valid() || !GetGame().GetWorldEntity()) return false;
m_Started = true; m_Definition = definition;
// Prison resolves first, irrespective of authored array order.
foreach (ERR_SiteDefinition prison : definition.Sites) if (prison.RandomClear && prison.Kind == ERR_LocationKind.PRISON) m_Pending.Insert(prison);
foreach (ERR_SiteDefinition radio : definition.Sites) if (radio.RandomClear && radio.Kind == ERR_LocationKind.RADIO) m_Pending.Insert(radio);
foreach (ERR_SiteDefinition mortar : definition.Sites) if (mortar.RandomClear && mortar.Kind == ERR_LocationKind.MORTAR) m_Pending.Insert(mortar);
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!m_Pending.IsEmpty()) m_Terrain.Collect(m_Low, m_High);
m_Began = System.GetTickCount();
return true;
}
protected bool Separated(ERR_SiteDefinition candidate, vector position, bool fallback)
{
foreach (ERR_SiteDefinition other : m_Definition.Sites)
{
if (other == candidate) continue;
if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.RADIO && other.RandomClear) continue;
float minimum = 300;
if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.MORTAR) minimum = 1000;
if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.PRISON) minimum = 500;
if (candidate.Kind == ERR_LocationKind.RADIO && other.Kind == ERR_LocationKind.PRISON) minimum = 1000;
if (vector.DistanceXZ(position, other.Position) < minimum) return false;
if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.EXTRACTION && vector.DistanceXZ(position, other.Position) + 100 >= ERR_GameMode.Get().EscapeRadius()) return false;
}
return true;
}
ERR_PrepareResult Tick()
{
if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
if (!m_Started || !Replication.IsServer()) return ERR_PrepareResult.FAILED;
int tick = System.GetTickCount();
while (m_Index < m_Pending.Count())
{
ERR_SiteDefinition site = m_Pending[m_Index];
bool fallback = m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000;
vector position = site.Position; float heading = site.Heading;
if (!fallback)
{
position = Vector(Math.RandomFloat(m_Low[0] + 50, m_High[0] - 50), 0, Math.RandomFloat(m_Low[2] + 50, m_High[2] - 50));
heading = Math.RandomFloat(0, 360); m_Attempts++;
}
float floor;
if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor))
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
PrintFormat("Escapists [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback);
m_Index++; m_Attempts = 0;
}
else if (fallback)
{
m_Error = "No safe terrain or valid authored fallback for " + site.Id;
m_Result = ERR_PrepareResult.FAILED; return m_Result;
}
if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING;
}
m_Result = ERR_PrepareResult.READY; return m_Result;
}
}
@@ -1,4 +1,4 @@
enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT }
enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT, MORTAR }
[ComponentEditorProps(category: "Reintroduced", description: "Server-owned location with replicated discovery state")]
class ERR_LocationComponentClass : ScriptComponentClass {}
@@ -1,9 +1,47 @@
#ifdef WORKBENCH
modded class PauseMenuUI
{
static bool ERR_ProbeModsEntry()
{
return s_Instance && s_Instance.m_ERRModsButton;
}
static void ERR_ProbeOpenMods() { if (s_Instance) s_Instance.ERR_OpenMods(); }
}
// Explicit local fixture: real dialog, widgets and owner RPCs; no mouse/remote claim.
modded class ERR_AdminMenu
{
bool ERR_ProbeReady() { return m_Ready && m_AvailableFields.Count() == 10; }
void ERR_ProbePage(int page) { ShowPage(page); }
bool ERR_ProbeReady() { return m_Ready && m_AvailableFields.Count() == ERR_AdminMenuFields.Keys().Count(); }
void ERR_ProbePage(int page)
{
OnButton(SCR_ButtonTextComponent.GetButtonText(Navigation()[page], GetRootWidget()));
}
bool ERR_ProbeNavigation(int expected)
{
GetRootWidget().Update();
if (m_Page != expected) return false;
for (int i = 0; i < Pages().Count(); i++)
{
Widget panel = GetRootWidget().FindAnyWidget(Pages()[i]);
Widget tab = GetRootWidget().FindAnyWidget(Navigation()[i]);
Widget selected = GetRootWidget().FindAnyWidget("Selected_" + Navigation()[i]);
if (!panel || !tab || !selected || panel.IsVisible() != (i == expected) || !tab.IsVisible() || selected.IsVisible() != (i == expected)) return false;
float x, y, width, height, px, py;
tab.GetScreenPos(x, y); tab.GetScreenSize(width, height); panel.GetScreenPos(px, py);
if (width <= 1 || height <= 1 || (i == expected && y + height > py))
{ PrintFormat("ERR ADMIN NAV BOUNDS: page=%1 tab=%2 bounds=%3,%4,%5,%6 panelY=%7", expected, i, x, y, width, height, py); return false; }
}
return true;
}
void ERR_ProbeFieldPage(string key)
{
Widget widget = GetRootWidget().FindAnyWidget(key);
while (widget)
{
int page = Pages().Find(widget.GetName());
if (page >= 0) { ERR_ProbePage(page); return; }
widget = widget.GetParent();
}
}
string ERR_ProbeDraft() { return SCR_EditBoxComponent.GetEditBoxComponent("radioDefenceSeconds", GetRootWidget()).GetValue(); }
void ERR_ProbeEdit(string text) { SCR_EditBoxComponent.GetEditBoxComponent("radioDefenceSeconds", GetRootWidget()).SetValue(text); }
void ERR_ProbeApply()
@@ -11,8 +49,71 @@ modded class ERR_AdminMenu
Widget widget = GetRootWidget().FindAnyWidget("Apply_radioDefenceSeconds");
OnButton(SCR_ButtonBaseComponent.Cast(widget.FindHandler(SCR_ButtonBaseComponent)));
}
bool ERR_ProbeRandomChoices()
{
foreach (string key : ERR_AdminMenuFields.Keys())
{
ERR_ProbeFieldPage(key);
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Random_" + key, GetRootWidget());
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget());
bool available = ERR_AdminMenuFields.Available(key, m_HQ, m_Intel);
if (!button || !edit || button.IsEnabled() != available) return false;
if (!available) continue;
string previous = edit.GetValue();
OnButton(button);
if (edit.GetValue() != "Random") return false;
edit.SetValue(previous);
}
ERR_ProbeFieldPage("startTimeMinutes");
SCR_EditBoxComponent time = SCR_EditBoxComponent.GetEditBoxComponent("startTimeMinutes", GetRootWidget());
string previousTime = time.GetValue();
SCR_ButtonTextComponent day = SCR_ButtonTextComponent.GetButtonText("RandomDay_startTimeMinutes", GetRootWidget());
if (!day || !day.IsEnabled()) return false;
OnButton(day);
bool selected = time.GetValue() == "Random day";
time.SetValue(previousTime);
ERR_ProbePage(0);
return selected;
}
bool ERR_ProbeFactionRandomAll()
{
if (!m_FactionFieldsInitialized || m_FactionIds.Count() < 4) return false;
ERR_ProbePage(4);
if (m_Page != 4) return false;
OnFactionButton(SCR_ButtonTextComponent.GetButtonText("FactionsRandomAll", GetRootWidget()));
foreach (string role : ERR_FactionChoices.Roles())
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget());
if (!combo || !combo.IsEnabled() || combo.GetCurrentIndex() != 0) return false;
}
return true;
}
void ERR_ProbeFactionApply() { OnFactionButton(SCR_ButtonTextComponent.GetButtonText("FactionsApply", GetRootWidget())); }
bool ERR_ProbeHQ() { return m_HQ; }
bool ERR_ProbeIntel() { return m_Intel; }
bool ERR_ProbeMods()
{
GetRootWidget().Update();
PrintFormat("ERR MOD MENU: page=%1 rows=%2 saved=%3 active=%4", m_Page, m_ModRows.Count(), m_ModSaved.Count(), m_ModActive.Count());
if (m_Page != 3 || m_ModRows.Count() != ERR_ContentRegistry.Ids().Count()) return false;
foreach (string pack, Widget row : m_ModRows)
{
TextWidget text = TextWidget.Cast(row.FindAnyWidget("PackState"));
if (text) PrintFormat("ERR MOD ROW: id=%1 state=%2", pack, text.GetText());
if (!text || !text.GetText().Contains("Saved preference:") || !m_ModSaved.Contains(pack) || !m_ModActive.Contains(pack)) return false;
float width, height; row.GetScreenSize(width, height);
PrintFormat("ERR MOD BOUNDS: id=%1 width=%2 height=%3", pack, width, height);
if (width < 100 || height < 2) return false;
}
return true;
}
void ERR_ProbeMod(bool enabled)
{
Widget row = m_ModRows.Get("rhs");
string name = "Disable";
if (enabled) name = "Enable";
OnModButton(SCR_ButtonTextComponent.GetButtonText(name, row));
}
int ERR_ProbeLogCursor() { return m_LogCursor; }
bool ERR_ProbeJournalVisible()
{
@@ -39,10 +140,14 @@ class ERR_NativeAdminMenuProbe
protected DamageManagerComponent m_Damage;
protected bool m_PreviousDamage;
protected int m_Stage;
protected int m_CapturePage;
protected float m_Deadline;
protected float m_Wait;
protected int m_RadioSeconds;
protected bool m_Failed;
protected bool m_RhsSaved;
protected ref array<string> m_FactionSaved = {};
protected ref array<string> m_FactionActive = {};
void ERR_NativeAdminMenuProbe(ERR_GameMode mode) { m_Mode = mode; }
protected int Fail(string reason)
{
@@ -67,8 +172,12 @@ class ERR_NativeAdminMenuProbe
m_Damage = DamageManagerComponent.Cast(m_Controller.GetControlledEntity().FindComponent(DamageManagerComponent));
m_PreviousDamage = m_Damage.IsDamageHandlingEnabled();
m_RadioSeconds = m_Mode.Settings().Get("radioDefenceSeconds");
m_Deadline = now + 45000;
m_Controller.ERR_AdminSubmit("menu");
m_Deadline = now + 90000;
m_RhsSaved = m_Mode.ContentSettings().Enabled("rhs");
GetGame().OpenPauseMenu(false, true);
if (!PauseMenuUI.ERR_ProbeModsEntry()) return Fail("Esc menu missing Escapists Reintroduced entry");
PauseMenuUI.ERR_ProbeOpenMods();
Pass("native Esc menu entry opens mission administration");
m_Stage = 1; return 0;
}
if (m_Stage < 6 && !menu) return Fail("dialog unexpectedly closed");
@@ -78,6 +187,32 @@ class ERR_NativeAdminMenuProbe
array<string> capabilities = {}; m_Mode.Admin().Capabilities(capabilities);
if (menu.ERR_ProbeHQ() != capabilities.Contains("supports=hq") || menu.ERR_ProbeIntel() != capabilities.Contains("supports=intel")) return Fail("capability snapshot mismatch");
Pass("real owner RPC opens menu and returns current settings/capabilities");
System.MakeScreenshot("$logs:err-admin-open");
if (!menu.ERR_ProbeNavigation(0)) return Fail("Esc entry did not open Mission page with shared navigation");
menu.ERR_ProbePage(3);
if (!menu.ERR_ProbeMods()) return Fail("mod rows missing state or collapsed");
Print("ERR MOD CAPTURE READY");
menu.ERR_ProbePage(0);
Print("ERR ADMIN CAPTURE PAGE: 0");
m_Wait = now + 3000; m_Stage = 30; return 0;
}
if (m_Stage == 30)
{
if (!menu || !menu.ERR_ProbeNavigation(m_CapturePage)) return Fail("page or top navigation bounds invalid");
System.MakeScreenshot("$logs:err-admin-page-" + m_CapturePage.ToString());
m_CapturePage++;
if (m_CapturePage < ERR_AdminMenu.Pages().Count())
{ menu.ERR_ProbePage(m_CapturePage); Print("ERR ADMIN CAPTURE PAGE: " + m_CapturePage.ToString()); }
else { Pass("all eight pages reached through shared top navigation with one visible page"); m_Stage = 13; }
m_Wait = now + 3000;
return 0;
}
if (m_Stage == 13)
{
if (!menu) return Fail("mods dialog closed during visibility check");
menu.ERR_ProbePage(0);
if (!menu.ERR_ProbeRandomChoices()) return Fail("random choice controls missing, incorrectly gated or did not select draft");
Pass("all Random buttons and Random day select editable drafts");
menu.ERR_ProbeEdit("47");
m_Controller.ERR_AdminRequestSnapshot();
m_Wait = now + 3000; m_Stage = 2; return 0;
@@ -103,6 +238,51 @@ class ERR_NativeAdminMenuProbe
if (m_Mode.Settings().Get("radioDefenceSeconds") != m_RadioSeconds) return Fail("test setting restoration failed");
Pass("test setting restored without resetting menu drafts");
System.MakeScreenshot("$logs:err-admin-hq");
menu.ERR_ProbePage(3);
menu.ERR_ProbeMod(!m_RhsSaved);
m_Wait = now + 2000; m_Stage = 11; return 0;
}
if (m_Stage == 11)
{
if (!menu || m_Mode.ContentSettings().Enabled("rhs") == m_RhsSaved || m_Mode.Content().Enabled("rhs") != m_RhsSaved || !menu.ERR_ProbeMods()) return Fail("mod button failed to save independently of active round");
Pass("native mod toggle persists through authenticated owner RPC without changing active content");
menu.ERR_ProbeMod(m_RhsSaved);
m_Wait = now + 2000; m_Stage = 12; return 0;
}
if (m_Stage == 12)
{
if (!menu || m_Mode.ContentSettings().Enabled("rhs") != m_RhsSaved) return Fail("mod preference restoration failed");
Pass("native mod toggle restores saved preference");
foreach (string role : ERR_FactionChoices.Roles())
{
m_FactionSaved.Insert(m_Mode.FactionSelection().Saved(role));
m_FactionActive.Insert(m_Mode.FactionSelection().Active(role));
}
if (!menu.ERR_ProbeFactionRandomAll()) return Fail("faction selectors or Random all failed");
Pass("three faction selectors and Random all create distinct-role drafts");
m_Wait = now + 3000; m_Stage = 20; return 0;
}
if (m_Stage == 20)
{
if (!menu || !menu.ERR_ProbeFactionRandomAll()) return Fail("snapshot lost faction drafts");
menu.ERR_ProbeFactionApply();
m_Wait = now + 1500; m_Stage = 21; return 0;
}
if (m_Stage == 21)
{
array<string> roles = ERR_FactionChoices.Roles();
for (int i = 0; i < roles.Count(); i++)
if (m_Mode.FactionSelection().Saved(roles[i]) != "random" || m_Mode.FactionSelection().Active(roles[i]) != m_FactionActive[i]) return Fail("faction Apply failed or changed current round");
Pass("faction Apply persists all three through authenticated owner RPC without changing active round");
m_Controller.ERR_AdminSubmit("factions set " + m_FactionSaved[0] + " " + m_FactionSaved[1] + " " + m_FactionSaved[2], true);
m_Wait = now + 1500; m_Stage = 22; return 0;
}
if (m_Stage == 22)
{
array<string> restoredRoles = ERR_FactionChoices.Roles();
for (int j = 0; j < restoredRoles.Count(); j++)
if (m_Mode.FactionSelection().Saved(restoredRoles[j]) != m_FactionSaved[j]) return Fail("faction restoration failed");
Pass("saved faction choices restored");
if (menu.ERR_ProbeHQ()) menu.ERR_ProbePage(2);
m_Wait = now + 3000; m_Stage = 5; return 0;
}
@@ -52,6 +52,57 @@ class ERR_NativeAdminSettingsProbe
adapter.Probe(settings, "mission radio show", replies);
return Check(replies.Count() == 1 && replies[0] == "radioDefenceSeconds=" + initial.Get("radioDefenceSeconds").ToString(), "existing radio alias and response remain intact");
}
protected static bool RandomChoices(string path, ERR_GameMode mode)
{
ERR_Settings settings = new ERR_Settings(path);
foreach (string key : ERR_AdminMenuFields.Keys())
{
int parsed;
if (!Check(ERR_CommandArguments.Selection(key, "random", parsed) && parsed == -1 && settings.Set(key, parsed), "random selection saved: " + key)) return false;
ERR_Settings reload = new ERR_Settings(path);
if (!Check(reload.Get(key) == -1 && ERR_AdminMenuFields.Display(key, "-1") == "Random", "random mode survives reload and displays by name: " + key)) return false;
if (key == "startDateCode") continue;
int rolled = settings.Runtime(key);
if (!Check(ERR_Settings.Valid(key, rolled), "random resolves within concrete bounds: " + key)) return false;
for (int i = 0; i < 100; i++)
if (settings.Runtime(key) != rolled) return Check(false, "repeated reads rerolled " + key);
if (!Check(settings.Set(key, -1) && settings.Runtime(key) == rolled, "saving same mode preserves roll: " + key)) return false;
}
int radio = settings.Runtime("radioDefenceSeconds");
if (!Check(settings.Set("intelRevealChancePercent", 15) && settings.Runtime("radioDefenceSeconds") == radio, "unrelated save does not reroll another option")) return false;
if (!Check(settings.Set("radioDefenceSeconds", 45) && settings.Runtime("radioDefenceSeconds") == 45 && settings.Set("radioDefenceSeconds", -1) && ERR_Settings.Valid("radioDefenceSeconds", settings.Runtime("radioDefenceSeconds")), "fixed value replaces random and random can be selected again")) return false;
if (!Check(!settings.Set("unknown", -1) && !settings.Set("weatherProfile", -2) && !settings.Set("startTimeMinutes", -3), "unknown keys and invalid modes rejected")) return false;
int day;
if (!Check(ERR_CommandArguments.Selection("startTimeMinutes", "random day", day) && day == -2 && settings.Set("startTimeMinutes", day), "Random day parses and saves")) return false;
ERR_Settings reloadedDay = new ERR_Settings(path);
if (!Check(reloadedDay.Get("startTimeMinutes") == -2 && ERR_AdminMenuFields.Display("startTimeMinutes", "-2") == "Random day" && !ERR_CommandArguments.Selection("weatherProfile", "random day", day), "Random day reloads and is restricted to time")) return false;
ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld());
TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager();
float sunrise, sunset;
int first, end;
if (!Check(manager.GetSunriseHour(sunrise) && manager.GetSunsetHour(sunset) && ERR_TimeService.DaylightBounds(sunrise, sunset, first, end), "native island sunrise and sunset define Random day")) return false;
if (mode.Settings().Get("startTimeMinutes") == -2)
{
int started = mode.Time().StartMinutes();
if (!Check(started / 60.0 > sunrise && started / 60.0 < sunset, "actual round starts within native daylight interval")) return false;
}
if (!Check(ERR_Settings.Valid("timeMultiplier", mode.Time().Multiplier()) && ERR_Settings.Valid("startDateCode", mode.Environment().CurrentDateCode()), "actual startup uses concrete speed and valid date")) return false;
for (int sample = 0; sample < 100; sample++)
{
int minute = Math.RandomInt(first, end);
int dateCode = ERR_EnvironmentService.RandomDate(manager.GetYear());
if (minute / 60.0 <= sunrise || minute / 60.0 >= sunset || !ERR_Settings.Valid("startTimeMinutes", minute)) return Check(false, "daylight roll outside sun bounds");
if (!ERR_Settings.Valid("startDateCode", dateCode) || 1900 + dateCode / 10000 != manager.GetYear()) return Check(false, "random date outside authored year");
}
array<string> replies = {};
ERR_NativeAdminSettingsAdapter adapter = new ERR_NativeAdminSettingsAdapter();
adapter.Probe(settings, "intel reveal set random", replies);
if (!Check(settings.Get("intelRevealChancePercent") == -1 && replies[0].Contains("Random"), "numeric random passes real command handler")) return false;
replies.Clear();
ERR_EnvironmentCommands.Execute(settings, mode.Environment(), {"mission", "weather", "set", "random"}, replies);
if (!Check(settings.Get("weatherProfile") == -1 && replies[0].Contains("Random"), "environment random passes real command handler")) return false;
return Check(true, "100 valid daylight and calendar samples; all random modes retained");
}
static bool Run(ERR_GameMode mode)
{
if (!Check(Replication.IsServer() && mode && mode.Admin() && mode.Settings(), "native Core command service and settings ready")) return false;
@@ -65,9 +116,20 @@ class ERR_NativeAdminSettingsProbe
if (!Check(replies.Count() == 1 && replies[0].Contains("0-100") && mode.Settings().Get("intelRevealChancePercent") == original, "actual dispatch rejects out-of-range value with guidance")) return false;
mode.Admin().Execute(mode, true, "intel reveal help", replies);
if (!Check(replies.Count() == 1 && replies[0].Contains("second document"), "actual Core help describes physical document progression")) return false;
int savedTime = mode.Settings().Get("startTimeMinutes");
int activeTime = mode.Time().StartMinutes();
mode.Admin().Execute(mode, true, "mission time set random day", replies);
bool dayAccepted = mode.Settings().Get("startTimeMinutes") == -2 && mode.Time().StartMinutes() == activeTime && replies.Count() == 1 && replies[0].Contains("Random day");
bool timeRestored = mode.Settings().Set("startTimeMinutes", savedTime);
if (!Check(dayAccepted && timeRestored, "Random day command saves next-round mode without jumping active clock; restored")) return false;
mode.Admin().Execute(mode, true, "mission time set random", replies);
bool randomAccepted = mode.Settings().Get("startTimeMinutes") == -1 && mode.Time().StartMinutes() == activeTime && replies.Count() == 1 && replies[0].Contains("Random");
timeRestored = mode.Settings().Set("startTimeMinutes", savedTime);
if (!Check(randomAccepted && timeRestored, "Random time command preserves active clock; restored")) return false;
string path = "$profile:erri-native-admin-settings-probe.json";
if (!Check(!FileIO.FileExists(path) && !FileIO.FileExists(path + ".bak") && !FileIO.FileExists(path + ".tmp"), "disposable fixture filenames are unused")) return false;
bool result = Storage(path);
if (result) result = RandomChoices(path, mode);
FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp");
if (!result) return false;
return Check(mode.Settings().Get("intelRevealChancePercent") == original && !FileIO.FileExists(path) && !FileIO.FileExists(path + ".bak") && !FileIO.FileExists(path + ".tmp"), "fixture removed its files and never changed actual mission settings");
@@ -0,0 +1,121 @@
#ifdef WORKBENCH
modded class ERR_ObjectiveDefence
{
bool ERR_ProbeMedical()
{
foreach (ERR_ObjectiveDefenceEntry entry : m_Entries)
{
if (entry.Site.Definition.Id != "everon-medical-1") continue;
if (!entry.Spawned || !entry.Admitted || !entry.Group || !entry.Hold) return false;
return entry.Group.IsExpandComplete() && m_Mode.ForceLiving(entry.Group) == entry.Group.m_aUnitPrefabSlots.Count()
&& vector.Distance(entry.Hold.GetOrigin(), entry.Site.Location.Position()) < 0.1
&& m_HQ.ERR_ProbeDedicated(entry.Group);
}
return false;
}
}
modded class ERR_HunterService
{
ERR_HunterVehicle ERR_ProbeFirst()
{
if (m_Hunters.IsEmpty()) return null;
ERR_HunterVehicle first = m_Hunters[0];
return first;
}
}
modded class ERR_HQ
{
void ERR_ProbeBody(int slot, float now)
{
ERR_HQReport report = new ERR_HQReport();
report.TravelMode = "body"; report.MessageId = 100000 + slot;
report.CasualtySourceId = "fixture-identified"; report.CasualtyStrength = 4; report.CasualtySlot = slot;
report.ObservedAt = now; report.Position = "5000 50 5000";
// Delivered intake boundary; policy tests independently cover route/replay rules.
Accept(report, now);
}
bool ERR_ProbeDedicated(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.ReservedForSupport && !unit.Eligible(m_Mode);
return false;
}
bool ERR_ProbeMedicalDefence() { return m_ObjectiveDefence.ERR_ProbeMedical(); }
}
modded class ERR_GameMode
{
protected int m_ERRAggressionStage;
protected int m_ERRAggressionDeadline;
protected vector m_ERRHunterStart;
protected bool m_ERRHunterOrder;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "aggression") return;
m_ERRAggressionDeadline = System.GetTickCount() + 270000;
GetGame().GetCallqueue().CallLater(ERR_AggressionTick, 1000, true);
}
protected bool ERR_AggressionCheck(bool ok, string text)
{
if (ok) { Print("ERR SMOKE PASS: aggression " + text); return true; }
Print("ERR SMOKE FAIL: aggression " + text, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_AggressionTick); return false;
}
protected void ERR_AggressionTick()
{
if (System.GetTickCount() >= m_ERRAggressionDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_AggressionCheck(false, "native startup/movement deadline"); return; }
if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
DamageManagerComponent damage = DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(false);
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return;
float now = GetWorld().GetWorldTime();
if (m_ERRAggressionStage == 0)
{
if (!ERR_AggressionCheck(UnlockPrison(player), "native prison release")) return;
player.SetOrigin(ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + "300 0 0"));
for (int i = 0; i < 3; i++) hq.ERR_ProbeBody(i, now);
hq.ERR_ProbeBody(2, now);
if (!ERR_AggressionCheck(hq.Watch().IdentifiedBodies() == 3 && hq.Watch().Aggression() == 1 && hq.Watch().HunterTarget() == 1, "three distinct delivered bodies unlock one UAZ; duplicate ignored")) return;
if (!ERR_AggressionCheck(ERR_HQWatchRules.Aggression(90) == 30 && ERR_HQWatchRules.Hunters(30) == 15 && ERR_HQWatchRules.Hunters(1000) == 15, "maximum 30 aggression / 15 UAZ allowance")) return;
for (int j = 0; j < Sites().Count(); j++)
{
ERR_SiteRuntime site = Sites().At(j);
if (site.Group && site.Group.GetFaction() == EnemyFaction() && ERR_ObjectiveDefence.Objective(site.Definition.Kind))
if (!ERR_AggressionCheck(hq.ERR_ProbeDedicated(site.Group), "objective guard permanently excluded from HQ reassignment: " + site.Definition.Id)) return;
}
m_ERRAggressionStage = 1;
}
ERR_HunterVehicle hunter = hq.Hunters().ERR_ProbeFirst();
if (!hunter || !hunter.Registered) return;
if (m_ERRAggressionStage == 1)
{
if (!ERR_AggressionCheck(hunter.CrewValid(this) && hunter.Members.Count() == 2, "physical UAZ with native seated driver and gunner")) return;
m_ERRHunterStart = hunter.Entity.GetOrigin(); m_ERRAggressionStage = 2;
}
if (m_ERRAggressionStage == 2)
{
foreach (IEntity soldier : hunter.Members)
{
SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(soldier.FindComponent(SCR_AICombatComponent));
if (!combat || combat.GetAISkill() != EAISkill.CYLON || !combat.ERR_EnhancedEnemy()) return;
}
if (hunter.Waypoint) m_ERRHunterOrder = true;
if (!m_ERRHunterOrder || vector.DistanceXZ(m_ERRHunterStart, hunter.Entity.GetOrigin()) < 30) return;
if (!hq.ERR_ProbeMedicalDefence()) return;
if (!ERR_AggressionCheck(true, "nearby medical stash gained complete dedicated squad and defend waypoint at terrain height")) return;
if (!ERR_AggressionCheck(true, "HQ radio delivered road order; UAZ drove over 30m; maximum scoped crew skill active")) return;
if (!ERR_AggressionCheck(hq.Hunters().Deployed() == 1, "bounded allowance does not spawn duplicate UAZs")) return;
hq.Hunters().Stop();
if (!ERR_AggressionCheck(hunter.Released && !hunter.Waypoint && hunter.Entity && hunter.Members.Count() == 2, "stop removes orders and preserves physical vehicle/crew")) return;
foreach (IEntity survivor : hunter.Members)
{
SCR_AICombatComponent survivingCombat = SCR_AICombatComponent.Cast(survivor.FindComponent(SCR_AICombatComponent));
if (!ERR_AggressionCheck(survivingCombat && survivingCombat.ERR_EnhancedEnemy(), "released driving task preserves surviving crew AI profile")) return;
}
GetGame().GetCallqueue().Remove(ERR_AggressionTick);
Print("ERR SMOKE COMPLETE: aggression, physical UAZ, native movement, dedicated objectives and scoped skill");
}
}
}
#endif
@@ -0,0 +1,205 @@
#ifdef WORKBENCH
modded class ERR_DiscoveryMarker
{
string ERR_ProbeIcon() { return m_Icon; }
}
modded class ERR_LocationComponent
{
bool ERR_ProbeLocationIcon()
{
ERR_UpdateMarker();
if (Kind() == ERR_LocationKind.PRISON) return !m_ERRMarker;
if (!m_ERRMarker || m_ERRMarker.GetMarkerOwnerID() != -1) return false;
string expected = ERR_DiscoveryMarker.Icon(Kind(), Id());
if (Knowledge() == 1) expected = "mark-question";
int point[2]; m_ERRMarker.GetWorldPos(point);
return m_ERRMarker.ERR_ProbeIcon() == expected && point[0] == ERR_DiscoveryMapRules.Coordinate(Position()[0], Knowledge()) && point[1] == ERR_DiscoveryMapRules.Coordinate(Position()[2], Knowledge());
}
}
modded class ERR_GameMode
{
protected bool ERR_RadioRoof(IEntity tent, vector position, IEntity exclude = null)
{
TraceParam roof = new TraceParam();
roof.Start = position; roof.End = position + "0 5 0";
roof.Flags = TraceFlags.ENTS; roof.Exclude = exclude;
return GetWorld().TraceMove(roof, null) < 1 && roof.TraceEnt == tent;
}
protected bool ERR_TestRadioCamp()
{
ERR_SiteRuntime radio = Sites().Find("everon-radio-1");
IEntity tent;
foreach (IEntity prop : radio.Props)
if (prop && prop.GetPrefabData().GetPrefabName().Contains("/TentUSSR_01_base.et")) tent = prop;
if (!ERR_AirfieldCheck(tent && ERR_RadioRoof(tent, radio.Location.Position() + "0 0.2 0", radio.Location.GetOwner()), "radio physically underneath tent roof")) return false;
ERR_SpawnPlacement placement = new ERR_SpawnPlacement();
bool accessible;
for (int step = 0; step < 32; step++)
{
float angle = step * Math.PI / 16;
vector offset = Vector(Math.Cos(angle) * 1.4, 0, Math.Sin(angle) * 1.4);
vector position = ERR_Actors.Ground(radio.Location.Position() + ERR_CampTerrain.Rotate(offset, radio.Definition.Heading));
if (!placement.Clear(position, null) || !ERR_RadioRoof(tent, position + "0 1.7 0")) continue;
TraceParam sight = new TraceParam();
sight.Start = position + "0 1.3 0"; sight.End = radio.Location.Position() + "0 0.3 0";
sight.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
float fraction = GetWorld().TraceMove(sight, null);
if (fraction < 1 && sight.TraceEnt != radio.Location.GetOwner()) continue;
accessible = true; break;
}
if (!ERR_AirfieldCheck(accessible, "standing room inside tent within radio interaction distance and line of sight")) return false;
array<AIAgent> guards = {}; radio.Group.GetAgents(guards);
if (!ERR_AirfieldCheck(guards.Count() == radio.InitialGuardCount && guards.Count() > 0, "radio always has its complete physical guard squad")) return false;
foreach (AIAgent guard : guards)
if (!ERR_AirfieldCheck(guard && ERR_Actors.CanFight(guard.GetControlledEntity()), "radio guard alive and conscious")) return false;
if (!ERR_AirfieldCheck(ERR_HQ.Get().ERR_ProbeRadioReservation(radio.Group), "HQ permanently refuses radio squad reassignment and courier reservation")) return false;
array<AIWaypoint> waypoints = {}; radio.Group.GetWaypoints(waypoints);
bool defending;
foreach (AIWaypoint waypoint : waypoints)
if (SCR_DefendWaypoint.Cast(waypoint) && vector.DistanceXZ(waypoint.GetOrigin(), radio.Location.Position()) < 0.1 && waypoint.GetCompletionRadius() == 20) defending = true;
return ERR_AirfieldCheck(defending, "permanent native defend order centred on radio with 20m radius");
}
protected int m_ERRAirfieldStage;
protected bool m_ERRAirfieldOpeningChecked;
protected int m_ERRMortarFolders;
protected IEntity m_ERRMortarFolder;
protected ref ERR_AdminItemCallback m_ERRMortarPickup;
protected bool ERR_MortarIntelReady()
{
array<ref ERR_MortarBattery> batteries = {}; ERR_MortarSystem.Get().Batteries(batteries);
if (batteries.Count() != 4) { ERR_AirfieldCheck(false, "exactly four authored mortar batteries"); return false; }
foreach (ERR_MortarBattery battery : batteries)
{
if (!battery.SpawnFailure.IsEmpty()) { ERR_AirfieldCheck(false, "mortar preparation: " + battery.SpawnFailure); return false; }
if (!battery.StartupActivated) return false;
}
if (m_ERRMortarFolder)
{
if (!m_ERRMortarPickup.Finished) return false;
if (!m_ERRMortarPickup.Succeeded) { ERR_AirfieldCheck(false, "native intel inventory insertion"); return false; }
if (!Discovery().IsSpent(m_ERRMortarFolder)) return false;
m_ERRMortarFolder = null;
}
int known;
for (int i = 1; i <= 4; i++)
if (Sites().Find("everon-mortar-" + i).Location.Knowledge() == 2) known++;
if (known == 4) return true;
if (m_ERRMortarFolders >= 8) { ERR_AirfieldCheck(false, "all four mortar locations discoverable within eight folders"); return false; }
// Exclude already-known non-mortar destinations, then exercise actual normal
// untargeted folder recovery. No direct mortar Discover call is made here.
array<ERR_LocationComponent> locations = {}; Locations(locations);
foreach (ERR_LocationComponent location : locations)
if (location.Kind() != ERR_LocationKind.MORTAR) location.Discover(0, 100);
IEntity player = GetGame().GetPlayerController().GetControlledEntity();
m_ERRMortarFolder = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), player.GetOrigin()); Own(m_ERRMortarFolder);
m_ERRMortarPickup = new ERR_AdminItemCallback();
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(player.FindComponent(SCR_InventoryStorageManagerComponent));
if (!m_ERRMortarFolder || !inventory.TryInsertItem(m_ERRMortarFolder, EStoragePurpose.PURPOSE_DEPOSIT, m_ERRMortarPickup))
ERR_AirfieldCheck(false, "physical intel pickup accepted");
m_ERRMortarFolders++;
return false;
}
protected int m_ERRAirfieldDeadline;
protected float m_ERRAirfieldWait;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "airfield-markers") return;
m_ERRAirfieldDeadline = System.GetTickCount() + 200000;
GetGame().GetCallqueue().CallLater(ERR_AirfieldTick, 500, true);
}
protected bool ERR_AirfieldCheck(bool value, string text)
{
if (value) { Print("ERR SMOKE PASS: airfield " + text); return true; }
Print("ERR SMOKE FAIL: airfield " + text, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_AirfieldTick); return false;
}
protected void ERR_AirfieldTick()
{
if (System.GetTickCount() >= m_ERRAirfieldDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_AirfieldCheck(false, "preparation or deadline failure"); return; }
if ((Phase() != ERR_Phase.PRISON && Phase() != ERR_Phase.RADIO_SEARCH) || !GetGame().GetPlayerController() || !GetGame().GetPlayerController().GetControlledEntity()) return;
if (GetWorld().GetWorldTime() < m_ERRAirfieldWait) return;
if (m_ERRAirfieldStage == 0)
{
if (!m_ERRAirfieldOpeningChecked)
{
if (!ERR_TestRadioCamp()) return;
if (!ERR_AirfieldCheck(ERR_NativeScoutProbe.Run(), "ten hill scouts: complete finite loadouts, full ammo packs, physical radios and reserved observation posts")) return;
ERR_SiteRuntime prison = Sites().Find("everon-prison-1");
array<AIAgent> guards = {}; prison.Group.GetAgents(guards);
int radios; array<vector> guardPositions = {};
foreach (AIAgent guard : guards)
{
IEntity actor = guard.GetControlledEntity();
float distance = vector.DistanceXZ(actor.GetOrigin(), prison.Location.Position());
if (!ERR_AirfieldCheck(distance >= 11.5 && distance <= 24.5 && !guard.IsAIActivated() && ERR_Actors.CanFight(actor), "guard alive, frozen and outside prison at random perimeter position")) return;
foreach (vector previous : guardPositions)
if (!ERR_AirfieldCheck(vector.DistanceXZ(previous, actor.GetOrigin()) > 3, "guards occupy separate perimeter positions")) return;
guardPositions.Insert(actor.GetOrigin());
if (ERR_HQRadio.Capability(actor) == 2) radios++;
}
if (!ERR_AirfieldCheck(radios > 0, "perimeter patrol retains physical large radio")) return;
for (int mortarId = 1; mortarId <= 4; mortarId++)
{
ERR_LocationComponent hidden = Sites().Find("everon-mortar-" + mortarId).Location;
if (!ERR_AirfieldCheck(hidden.Knowledge() == 0, "mortar initially undiscovered")) return;
}
ERR_LocationComponent partial = Sites().Find("everon-mortar-1").Location;
partial.Discover(99, 0);
if (!ERR_AirfieldCheck(partial.Knowledge() == 1 && partial.ERR_ProbeLocationIcon(), "partial mortar intel displays question mark at coarse location")) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity();
if (!ERR_AirfieldCheck(UnlockPrison(player), "normal prison unlock starts HQ and mortar activity")) return;
// Park only the fixture actor beside the unguarded medical stash so nearby
// prison combat does not interrupt the inventory/UI observation sequence.
ERR_SpawnPlacement placement = new ERR_SpawnPlacement(); bool parked;
for (int spot = 0; spot < 8; spot++)
{
vector position = ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + Vector(5 + spot * 2, 0, 5));
if (!placement.Clear(position, null, -1, player)) continue;
vector transform[4]; player.GetTransform(transform); transform[3] = position;
ChimeraCharacter.Cast(player).Teleport(transform); parked = true; break;
}
if (!ERR_AirfieldCheck(parked, "fixture actor parked on checked terrain for mortar intel observation")) return;
m_ERRAirfieldOpeningChecked = true;
}
if (!ERR_MortarIntelReady()) return;
if (!ERR_AirfieldCheck(!Sites().Find("everon-supply-2"), "guarded car-and-crate site absent")) return;
ERR_SiteRuntime extraction = Sites().Find("everon-extraction-1");
if (!ERR_AirfieldCheck(extraction.Definition.RequireAirfield && vector.DistanceXZ(ExtractionVehicle().GetOrigin(), "4800.953 0 11487.412") < 3 && ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "production aircraft settled intact on validated Everon airfield pad")) return;
if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ERR_Actors.Ground("7012 0 7500")), "old off-airfield helicopter position rejected")) return;
if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ExtractionVehicle().GetOrigin()), "occupied airfield pad rejected")) return;
if (!ERR_AirfieldCheck(Sites().Find("everon-medical-1").Definition.RevealOnEscape && Sites().Find("everon-supply-1").Definition.RevealOnEscape, "two reachable intel sites retained")) return;
if (!ERR_AirfieldCheck(true, "four stocked operational mortar batteries discovered through physical intel")) return;
array<ERR_LocationComponent> locations = {}; Locations(locations);
foreach (ERR_LocationComponent location : locations)
{
location.Discover(0, 100);
if (!ERR_AirfieldCheck(location.Knowledge() == 2 && location.ERR_ProbeLocationIcon(), "identified " + location.Id() + " has correct map visibility and icon")) return;
}
GetGame().GetMenuManager().OpenMenu(ChimeraMenuPreset.MapMenu);
m_ERRAirfieldStage = 1; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 3000; return;
}
SCR_MapEntity mapEntity = SCR_MapEntity.GetMapInstance();
if (m_ERRAirfieldStage == 1)
{
if (!ERR_AirfieldCheck(mapEntity && mapEntity.IsOpen(), "native fullscreen map open")) return;
mapEntity.ZoomPanSmooth(0.08, 5800, 9000, 0.1);
m_ERRAirfieldStage = 2; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 3000; return;
}
if (m_ERRAirfieldStage == 2)
{
System.MakeScreenshot("$logs:err-airfield-map-icons");
Print("ERR AIRFIELD CAPTURE READY");
m_ERRAirfieldStage = 3; m_ERRAirfieldWait = GetWorld().GetWorldTime() + 15000; return;
}
GetGame().GetMenuManager().CloseMenuByPreset(ChimeraMenuPreset.MapMenu);
GetGame().GetCallqueue().Remove(ERR_AirfieldTick);
// Runner tears down the test world. Avoid mixing native map close with a
// same-frame task-list teardown, which has a vanilla delayed-refresh race.
Print("ERR SMOKE COMPLETE: airfield, four mortar intel sites, hidden prison and random perimeter guards");
}
}
#endif
@@ -54,7 +54,7 @@ modded class ERR_GameMode
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase;
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing")) return;
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "aggression" || nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing" || nativeEndCase == "generated-sites" || nativeEndCase == "mortar-station" || nativeEndCase == "opening-tools" || nativeEndCase == "airfield-markers" || nativeEndCase == "air-logistics" || nativeEndCase == "air-assault" || nativeEndCase == "air-recon")) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
string outcome;
@@ -565,6 +565,11 @@ modded class ERR_GameMode
if (m_ERRExtractionCase == "prisoner-kit")
{
int contentMenu = ERR_ProbeAdminMenu(now);
if (contentMenu < 0) { ERR_BaseCheck(false, "content admin menu"); return; }
if (contentMenu == 0) return;
if (!ERR_NativeAdminSettingsProbe.Run(this)) { ERR_BaseCheck(false, "random settings storage and commands"); return; }
if (!ERR_NativeContentProbe.Run(this)) { ERR_BaseCheck(false, "content registry and backpack"); return; }
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRPrisonerProbe = new ERR_NativePrisonerProbe(this, m_ERRBasePlayer);
m_ERRBaseStep = 700;
@@ -0,0 +1,71 @@
#ifdef WORKBENCH
class ERR_NativeContentProbe
{
protected static bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: content " + detail); return true; }
Print("ERR SMOKE FAIL: content " + detail, LogLevel.ERROR);
return false;
}
static bool Run(ERR_GameMode mode)
{
if (!ERR_NativeFactionSelectionProbe.Run(mode)) return false;
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (!Check(definition && definition.Valid() && definition.Options.Count() == 4, "native pack and three faction definitions load")) return false;
ERR_SiteRuntime prison = mode.Sites().Find("everon-prison-1");
if (!Check(prison && prison.Container && prison.Container.FindComponent(BaseLoadoutClothComponent), "prison supplies use a wearable ground backpack")) return false;
BaseInventoryStorageComponent storage = BaseInventoryStorageComponent.Cast(prison.Container.FindComponent(BaseInventoryStorageComponent));
array<IEntity> items = {};
if (storage) storage.GetAll(items);
array<IEntity> unique = {};
int pistols, magazines, maps;
foreach (IEntity item : items)
{
if (!item || unique.Contains(item)) continue;
unique.Insert(item);
if (!Check(ERR_SpawnSceneInventory.InStorage(item), "bag item has native visible storage ownership")) return false;
ResourceName prefab = item.GetPrefabData().GetPrefabName();
PrintFormat("ERR CONTENT BAG: item=%1 prefab=%2", item, prefab);
if (prefab.Contains("Handgun_PM.et")) pistols++;
else if (prefab.Contains("Magazine_9x18_PM_8rnd_Ball.et")) magazines++;
else maps++;
}
if (!Check(unique.Count() == 14 && pistols == 5 && magazines == 8 && maps == 1, "backpack physically contains five pistols, eight magazines and map before admission")) return false;
array<string> siteIds = {"everon-supply-1", "everon-radio-1", "everon-supply-2"};
array<Faction> factions = {mode.PlayerFaction(), mode.EnemyFaction()};
int poolIndex;
for (int i = 0; i < siteIds.Count(); i++)
{
ERR_SiteRuntime site = mode.Sites().Find(siteIds[i]);
ResourceName expected;
if (site && site.Definition.AllowFactionPool) expected = mode.Content().GuardGroup(poolIndex++);
if (expected.IsEmpty()) expected = mode.Factions().GuardGroupPrefab;
if (!Check(site && site.Group && site.Group.GetPrefabData().GetPrefabName() == expected && mode.IsMissionEnemy(site.Group.GetFaction()), "selected physical faction group at " + siteIds[i])) return false;
if (!factions.Contains(site.Group.GetFaction())) factions.Insert(site.Group.GetFaction());
}
foreach (Faction a : factions)
foreach (Faction b : factions)
if (a != b && !Check(a.IsFactionEnemy(b) && b.IsFactionEnemy(a), "mutual hostility " + a.GetFactionKey() + " / " + b.GetFactionKey())) return false;
array<string> replies = {};
bool original = mode.ContentSettings().Enabled("rhs");
mode.Admin().Execute(mode, false, "mods rhs disable", replies);
if (!Check(replies.Count() == 1 && replies[0] == "Administrator access required." && mode.ContentSettings().Enabled("rhs") == original, "unauthorized mod command rejected")) return false;
string path = "$profile:erri-content-probe.json";
if (!Check(!FileIO.FileExists(path), "disposable preference path unused")) return false;
ERR_ContentSettings settings = new ERR_ContentSettings(path);
ERR_ContentPolicy active = new ERR_ContentPolicy(settings);
if (!Check(active.Enabled("rhs") && active.Enabled("rhs_usmc") && !active.Enabled("unknown"), "new options default on; unknown IDs denied")) return false;
if (!Check(settings.Set("rhs", 0) && active.Enabled("rhs_usmc"), "saving pack disable leaves active round unchanged")) return false;
ERR_ContentSettings reloaded = new ERR_ContentSettings(path);
ERR_ContentPolicy disabled = new ERR_ContentPolicy(reloaded);
if (!Check(!disabled.Enabled("rhs") && !disabled.Enabled("rhs_usmc") && !disabled.Enabled("rhs_afrf") && !disabled.Enabled("rhs_ion") && disabled.GuardGroup(0).IsEmpty(), "reload excludes disabled pack and every child faction")) return false;
if (!Check(!disabled.Allows("{A1D5833FF10C6503}Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1M2.et") && disabled.Allows("{C850A33226B8F9C1}Prefabs/Weapons/Attachments/Optics/Optic_PSO1/Optic_PSO1.et"), "disabled RHS optics keep vanilla alternatives")) return false;
if (!Check(reloaded.Set("rhs", 1) && reloaded.Set("rhs_usmc", 0), "pack and individual faction preferences save independently")) return false;
ERR_ContentPolicy selected = new ERR_ContentPolicy(reloaded);
if (!Check(selected.Enabled("rhs") && !selected.Enabled("rhs_usmc") && selected.Enabled("rhs_afrf") && selected.Enabled("rhs_ion"), "individual faction disable preserves sibling factions")) return false;
if (!Check(!reloaded.Set("unknown", 1) && !reloaded.Set("rhs", 2), "unknown IDs and malformed flags rejected")) return false;
FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp");
return Check(!FileIO.FileExists(path) && mode.ContentSettings().Enabled("rhs") == original, "disposable preferences cleaned; live preferences unchanged");
}
}
#endif
@@ -13,7 +13,9 @@ class ERR_NativeContractsProbe
if (!Check(mode.Factions() && mode.Factions().WorldReady() && mode.PreparationError().IsEmpty(), "catalog resolves hostile vanilla factions and successful preparation")) return false;
if (!Check(SCR_FactionManager.SGetFaction(player) == mode.PlayerFaction() && player.GetPrefabData().GetPrefabName() == mode.Factions().PrisonerPrefab, "physical prisoner matches injected faction and resource")) return false;
ERR_SiteRuntime site = mode.Sites().Find("everon-supply-1");
if (!Check(site && site.Group && site.Group.GetFaction() == mode.EnemyFaction() && site.Group.GetPrefabData().GetPrefabName() == mode.Factions().GuardGroupPrefab, "physical guard squad comes from injected catalog")) return false;
ResourceName expectedGroup = mode.Content().GuardGroup(0);
if (expectedGroup.IsEmpty()) expectedGroup = mode.Factions().GuardGroupPrefab;
if (!Check(site && site.Group && mode.IsMissionEnemy(site.Group.GetFaction()) && site.Group.GetPrefabData().GetPrefabName() == expectedGroup, "physical guard squad comes from enabled catalog")) return false;
ERR_FactionCatalog isolated = ERR_FactionCatalog.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BE50B97B8E2B36E6}Configs/ERR/VanillaFactions.conf"));
if (!isolated) return false;
string enemyKey = isolated.EnemyFactionKey;
@@ -9,7 +9,7 @@ class ERR_NativeDefinitionProbe
}
static bool Run()
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
if (!Check(definition && definition.Valid(), "authored configuration resolves")) return false;
ERR_SiteDefinition cache = definition.Find("everon-supply-1");
if (!cache) return false;
@@ -53,7 +53,7 @@ class ERR_NativeFactionCatalogProbe
if (!Check(manager != null, "native faction manager exists")) return false;
SCR_Faction us = SCR_Faction.Cast(manager.GetFactionByKey("US"));
SCR_Faction ussr = SCR_Faction.Cast(manager.GetFactionByKey("USSR"));
if (!Check(us && ussr && mode.PlayerFaction() == us && mode.EnemyFaction() == ussr, "mission retains US and USSR faction identities")) return false;
if (!Check(us && ussr && mode.PlayerFaction().GetFactionKey() == mode.FactionSelection().Choice("escapists").Key && mode.EnemyFaction().GetFactionKey() == mode.FactionSelection().Choice("enemy").Key, "mission uses selected faction identities")) return false;
if (!Check(us.IsFactionEnemy(ussr) && ussr.IsFactionEnemy(us) && !us.IsFactionEnemy(us) && !ussr.IsFactionEnemy(ussr), "mutual enemy and same-faction relations preserved")) return false;
if (!Check(us.GetFactionRadioEncryptionKey() == "chickenNuggets" && ussr.GetFactionRadioEncryptionKey() == "coldBorscht" && us.GetFactionRadioFrequency() == 48000 && ussr.GetFactionRadioFrequency() == 42000, "native radio encryption and frequencies preserved")) return false;
if (!Check(us.GetCallsignInfo() && ussr.GetCallsignInfo(), "native faction callsign configurations resolve")) return false;
@@ -0,0 +1,53 @@
#ifdef WORKBENCH
class ERR_NativeFactionSelectionProbe
{
protected static bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: factions " + detail); return true; }
Print("ERR SMOKE FAIL: factions " + detail, LogLevel.ERROR); return false;
}
static bool Run(ERR_GameMode mode)
{
ERR_FactionSelection live = mode.FactionSelection();
if (!Check(live && live.Ready() && mode.PlayerFaction() != mode.EnemyFaction(), "server resolves distinct roles before preparation")) return false;
Faction independent = GetGame().GetFactionManager().GetFactionByKey(live.Choice("independent").Key);
if (!Check(independent && independent != mode.PlayerFaction() && independent != mode.EnemyFaction() && mode.IsMissionEnemy(independent) && !mode.IsMissionEnemy(mode.PlayerFaction()), "independent is hostile while player faction is excluded")) return false;
IEntity player = GetGame().GetPlayerController().GetControlledEntity();
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(player.FindComponent(FactionAffiliationComponent));
if (!Check(affiliation && affiliation.GetAffiliatedFaction() == mode.PlayerFaction(), "admitted prisoner uses selected player faction")) return false;
map<string, string> saved = new map<string, string>();
map<string, string> resolved = new map<string, string>();
saved.Set("escapists", "random"); saved.Set("enemy", "us"); saved.Set("independent", "fia");
for (int i = 0; i < 30; i++)
{
array<string> pool = {"us", "ussr", "fia"};
if (!ERR_FactionChoices.Resolve(pool, saved, resolved) || resolved.Get("escapists") != "ussr" || resolved.Get("enemy") != "us" || resolved.Get("independent") != "fia") return Check(false, "Random stole a later explicit selection");
}
if (!Check(true, "Random reserves later explicit roles first")) return false;
saved.Set("enemy", "random"); saved.Set("independent", "random");
for (int j = 0; j < 60; j++)
{
array<string> all = {"us", "ussr", "fia", "rhs_afrf", "rhs_usmc", "rhs_ion"};
if (!ERR_FactionChoices.Resolve(all, saved, resolved) || !ERR_FactionChoices.Distinct(resolved)) return Check(false, "Random all duplicated a faction");
}
if (!Check(true, "Random all chooses three different factions")) return false;
saved.Set("enemy", "rhs_afrf");
array<string> basePool = {"us", "ussr", "fia"};
if (!Check(ERR_FactionChoices.Resolve(basePool, saved, resolved) && resolved.Get("enemy") != "rhs_afrf", "disabled choice falls back to eligible random faction")) return false;
array<string> replies = {};
string previous = live.Saved("escapists");
mode.Admin().Execute(mode, false, "factions set random random random", replies);
if (!Check(replies.Count() == 1 && replies[0] == "Administrator access required." && live.Saved("escapists") == previous, "unauthorized role changes rejected")) return false;
string path = "$profile:erri-faction-probe.json";
ERR_FactionSelection settings = new ERR_FactionSelection(path);
array<string> valid = {"random", "ussr", "fia"};
array<string> duplicate = {"us", "us", "fia"};
array<string> unknown = {"unknown", "ussr", "fia"};
if (!Check(settings.Save(valid, mode.ContentSettings()) && !settings.Save(duplicate, mode.ContentSettings()) && !settings.Save(unknown, mode.ContentSettings()), "atomic persistence rejects duplicate and unknown selections")) return false;
ERR_FactionSelection reloaded = new ERR_FactionSelection(path);
if (!Check(reloaded.Saved("escapists") == "random" && reloaded.Saved("enemy") == "ussr" && reloaded.Saved("independent") == "fia", "saved roles survive reload and rejected changes")) return false;
FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp");
return true;
}
}
#endif
@@ -0,0 +1,138 @@
#ifdef WORKBENCH
modded class ERR_LocationComponent
{
bool ERR_ProbeGeneratedMarker(bool expected)
{
ERR_UpdateMarker();
if (!expected) return m_ERRMarker == null;
if (!m_ERRMarker) return false;
int point[2]; m_ERRMarker.GetWorldPos(point);
return point[0] == ERR_DiscoveryMapRules.Coordinate(Position()[0], Knowledge()) && point[1] == ERR_DiscoveryMapRules.Coordinate(Position()[2], Knowledge());
}
}
modded class ERR_GameMode
{
protected int m_ERRGeneratedStage;
protected float m_ERRGeneratedWait;
protected int m_ERRGeneratedDeadline;
protected IEntity m_ERRGeneratedPlayer, m_ERRGeneratedNear, m_ERRGeneratedFar, m_ERRGeneratedSeated;
protected Vehicle m_ERRGeneratedVehicle;
protected ERR_LocationComponent m_ERRGeneratedRadio;
protected ref array<IEntity> m_ERRGeneratedTeam = {};
protected ref map<int, vector> m_ERRGeneratedReservations = new map<int, vector>();
protected bool m_ERRGeneratedTeamChecked;
protected int ERR_GeneratedTeamSlots(IEntity player)
{
if (m_ERRGeneratedTeamChecked) return 1;
ERR_SpawnPlacement placement = new ERR_SpawnPlacement();
if (m_ERRGeneratedTeam.IsEmpty() && !ERR_GeneratedCheck(!placement.Clear(player.GetOrigin(), null), "physical player blocks occupied slot without a reservation")) return -1;
if (m_ERRGeneratedTeam.IsEmpty() && !ERR_GeneratedCheck(!placement.Clear(ERR_Actors.Ground(PrisonPosition() + ERR_CampTerrain.Rotate("0 0 6", PrisonHeading())), null), "rotated physical wall blocks spawn volume")) return -1;
m_ERRGeneratedReservations.Set(0, player.GetOrigin());
if (m_ERRGeneratedTeam.Count() < 11)
{
vector position;
if (!ERR_GeneratedCheck(placement.Prison(PrisonPosition(), m_ERRGeneratedReservations, position, PrisonHeading()), "rotated prison clear slot for member " + (m_ERRGeneratedTeam.Count() + 2))) return -1;
IEntity actor = ERR_Actors.Spawn(Factions().PrisonerPrefab, position);
if (!ERR_GeneratedCheck(actor != null, "additional physical prisoner created")) return -1;
Own(actor); m_ERRGeneratedTeam.Insert(actor); m_ERRGeneratedReservations.Set(m_ERRGeneratedTeam.Count(), position);
DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
return 0;
}
foreach (IEntity prisoner : m_ERRGeneratedTeam) ERR_Actors.DeleteSpawned(prisoner);
m_ERRGeneratedTeam.Clear(); m_ERRGeneratedReservations.Clear(); m_ERRGeneratedTeamChecked = true;
return 1;
}
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "generated-sites") return;
m_ERRGeneratedDeadline = System.GetTickCount() + 120000;
GetGame().GetCallqueue().CallLater(ERR_GeneratedProbeTick, 500, true);
}
protected bool ERR_GeneratedCheck(bool value, string detail)
{
if (value) { Print("ERR SMOKE PASS: generated " + detail); return true; }
Print("ERR SMOKE FAIL: generated " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_GeneratedProbeTick); return false;
}
protected void ERR_GeneratedProbeTick()
{
if (System.GetTickCount() > m_ERRGeneratedDeadline || !PreparationError().IsEmpty()) { ERR_GeneratedCheck(false, "deadline or preparation failure: " + PreparationError()); return; }
if (!Active() || GetGame().GetWorld().GetWorldTime() < m_ERRGeneratedWait) return;
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller || !controller.GetControlledEntity()) return;
if (m_ERRGeneratedStage == 0)
{
m_ERRGeneratedPlayer = controller.GetControlledEntity();
if (ERR_GeneratedTeamSlots(m_ERRGeneratedPlayer) != 1) return;
ERR_SiteRuntime prison = Sites().Find("everon-prison-1"); ERR_SiteRuntime radio = Sites().Find("everon-radio-1");
if (!ERR_GeneratedCheck(prison && radio && prison.Definition.PlacementResolved && radio.Definition.PlacementResolved, "both generated locations committed before admission")) return;
if (!ERR_GeneratedCheck(vector.DistanceXZ(prison.Definition.Position, radio.Definition.Position) >= 1000 && vector.DistanceXZ(prison.Definition.Position, "6090 0 7160") > 1, "fresh island prison and separated radio")) return;
vector local = ERR_CampTerrain.Local(m_ERRGeneratedPlayer.GetOrigin() - PrisonPosition(), PrisonHeading());
if (!ERR_GeneratedCheck(Math.AbsFloat(local[0]) <= 5.5 && Math.AbsFloat(local[2]) <= 5.5 && PrisonEscape().Registered(), "player inside rotated prison with intact registered gate")) return;
BaseInventoryStorageComponent bag = BaseInventoryStorageComponent.Cast(prison.Container.FindComponent(BaseInventoryStorageComponent));
array<IEntity> contents = {}; if (bag) bag.GetAll(contents);
if (!ERR_GeneratedCheck(contents.Count() == 14 && radio.Group.GetFaction() == EnemyFaction(), "starter backpack ready and camp guards use enemy faction")) return;
m_ERRGeneratedRadio = radio.Location;
if (!ERR_GeneratedCheck(radio.Props.Count() == 5, "radio composition has five independently owned scenery objects")) return;
if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(false), "undiscovered radio location hidden")) return;
m_ERRGeneratedRadio.Discover(99, 0);
if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.Knowledge() == 1 && m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(true), "partial intel creates approximate question marker")) return;
m_ERRGeneratedRadio.Discover(99, 0);
if (!ERR_GeneratedCheck(m_ERRGeneratedRadio.Knowledge() == 2 && m_ERRGeneratedRadio.ERR_ProbeGeneratedMarker(true), "further intel reveals actual generated coordinates")) return;
m_ERRGeneratedNear = ERR_Actors.Spawn(Factions().PrisonerPrefab, ERR_Actors.Ground(PrisonPosition() + "0 0 15")); Own(m_ERRGeneratedNear);
vector far;
for (int radius = 1200; radius <= 6000 && far == vector.Zero; radius += 300)
for (int i = 0; i < 36; i++)
{
vector candidate = ERR_Actors.Ground(PrisonPosition() + ERR_CampTerrain.Rotate(Vector(radius, 0, 0), i * 10));
if (candidate[1] > 1 && !ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetWorld(), candidate)) { far = candidate; break; }
}
if (!ERR_GeneratedCheck(far != vector.Zero, "dry outside-radius test location available")) return;
m_ERRGeneratedFar = ERR_Actors.Spawn(Factions().PrisonerPrefab, far); Own(m_ERRGeneratedFar);
m_ERRGeneratedVehicle = Vehicle.Cast(ERR_Actors.Spawn("{259EE7B78C51B624}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et", ERR_Actors.Ground(PrisonPosition() + "0 0 20"))); Own(m_ERRGeneratedVehicle);
BaseCompartmentManagerComponent seats = BaseCompartmentManagerComponent.Cast(m_ERRGeneratedVehicle.FindComponent(BaseCompartmentManagerComponent));
array<BaseCompartmentSlot> slots = {}; seats.GetCompartments(slots);
foreach (BaseCompartmentSlot slot : slots)
{
if (slot.GetType() != ECompartmentType.CARGO) continue;
AIGroup group;
m_ERRGeneratedSeated = slot.SpawnCharacterInCompartment(Factions().PrisonerPrefab, group, "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et");
Own(group); Own(m_ERRGeneratedSeated); break;
}
if (!ERR_GeneratedCheck(m_ERRGeneratedNear && m_ERRGeneratedFar && m_ERRGeneratedVehicle && m_ERRGeneratedSeated, "friendly foot AI, distant AI and seated AI fixtures created")) return;
m_ERRGeneratedWait = GetWorld().GetWorldTime() + 2000; m_ERRGeneratedStage = 1; return;
}
if (m_ERRGeneratedStage == 1)
{
if (!ERR_GeneratedCheck(ERR_Actors.Alive(m_ERRGeneratedNear) && ERR_Actors.Alive(m_ERRGeneratedFar) && ERR_Actors.Alive(m_ERRGeneratedSeated) && CompartmentAccessComponent.GetVehicleIn(m_ERRGeneratedSeated) == m_ERRGeneratedVehicle, "targets alive and passenger physically seated")) return;
array<string> denied = {}; Admin().Execute(this, false, "killais", denied);
if (!ERR_GeneratedCheck(denied.Count() == 1 && denied[0] == "Administrator access required." && ERR_Actors.Alive(m_ERRGeneratedNear), "unauthorized kill request refused")) return;
controller.ERR_AdminSubmit("killais");
m_ERRGeneratedWait = GetWorld().GetWorldTime() + 2000; m_ERRGeneratedStage = 2; return;
}
if (m_ERRGeneratedStage == 2)
{
if (!ERR_GeneratedCheck(!ERR_Actors.Alive(m_ERRGeneratedNear) && !ERR_Actors.Alive(m_ERRGeneratedSeated) && ERR_Actors.Alive(m_ERRGeneratedFar) && ERR_Actors.Alive(m_ERRGeneratedVehicle) && ERR_Actors.Alive(m_ERRGeneratedPlayer), "authenticated killais kills nearby foot and seated AI, preserves player, vehicle and distant AI")) return;
if (!ERR_GeneratedCheck(UnlockPrison(m_ERRGeneratedPlayer), "generated prison unlock enters normal progression")) return;
vector approach = ERR_Actors.Ground(m_ERRGeneratedRadio.Position() + ERR_CampTerrain.Rotate("0 0 -1.5", Sites().Find("everon-radio-1").Definition.Heading));
m_ERRGeneratedPlayer.SetOrigin(approach);
m_ERRGeneratedWait = GetWorld().GetWorldTime() + 1000; m_ERRGeneratedStage = 3; return;
}
if (m_ERRGeneratedStage == 3)
{
if (!ERR_GeneratedCheck(TuneRadio(m_ERRGeneratedPlayer, m_ERRGeneratedRadio) && Phase() == ERR_Phase.RADIO_DEFENCE, "generated radio starts existing transmission objective")) return;
CharacterControllerComponent.Cast(m_ERRGeneratedPlayer.FindComponent(CharacterControllerComponent)).SetHeadingAngle(Sites().Find("everon-radio-1").Definition.Heading * Math.DEG2RAD, true);
Print("ERR GENERATED CAPTURE READY");
m_ERRGeneratedWait = GetWorld().GetWorldTime() + 3000; m_ERRGeneratedStage = 4; return;
}
if (m_ERRGeneratedStage == 4)
{
GetGame().GetCallqueue().Remove(ERR_GeneratedProbeTick);
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: generated sites, actual intel markers, admin killais and radio progression; scripted local fixture");
}
}
}
#endif
@@ -0,0 +1,109 @@
#ifdef WORKBENCH
modded class ERR_GameMode
{
protected int m_ERROpeningStage;
protected int m_ERROpeningDeadline;
protected ref array<IEntity> m_ERROpeningDocuments = {};
protected SCR_AIGroup m_ERROpeningFallbackGroup;
protected ref ERR_PrisonPatrol m_ERROpeningFallback;
protected ref ERR_AdminItemCallback m_ERROpeningPickup;
protected IEntity m_ERROpeningPicked;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "opening-tools") return;
m_ERROpeningDeadline = System.GetTickCount() + 150000;
GetGame().GetCallqueue().CallLater(ERR_OpeningTick, 500, true);
}
protected bool ERR_OpeningCheck(bool value, string text)
{
if (value) { Print("ERR SMOKE PASS: opening " + text); return true; }
Print("ERR SMOKE FAIL: opening " + text, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_OpeningTick); return false;
}
protected bool ERR_OpeningDocument(IEntity entity)
{
if (entity && entity.GetPrefabData() && entity.GetPrefabData().GetPrefabName() == ERR_Resources.IntelFolder()) m_ERROpeningDocuments.Insert(entity);
return true;
}
protected bool ERR_OpeningAircraftAudit(IEntity entity)
{
if (!entity || !entity.GetPrefabData()) return true;
string prefab = entity.GetPrefabData().GetPrefabName(); string name = prefab; name.ToLower();
if (name.Contains("helicopter") || name.Contains("mi8") || name.Contains("uh1"))
PrintFormat("ERR OPENING AIRFRAME: prefab=%1 position=%2 extraction=%3", prefab, entity.GetOrigin(), entity == ExtractionVehicle());
return true;
}
protected void ERR_OpeningTick()
{
if (System.GetTickCount() >= m_ERROpeningDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_OpeningCheck(false, "fixture exceeded deadline or mission failed"); return; }
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
IEntity player; if (controller) player = controller.GetControlledEntity();
if (Phase() != ERR_Phase.PRISON || !player || !Players().CurrentActor(player)) return;
if (m_ERROpeningStage == 0)
{
array<ResourceName> roster = {"rifleman", "squadleader", "Character_RTO.et", "Character_RTO_second.et"};
for (int size = 3; size <= 5; size++)
{
array<ResourceName> selected = ERR_PrisonPatrol.Members(roster, size);
int operators; foreach (ResourceName prefab : selected) if (prefab.Contains("RTO")) operators++;
if (!ERR_OpeningCheck(selected.Count() == size && operators == 1, "roster selection retains exactly one RTO at size " + size)) return;
}
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
GetWorld().QueryEntitiesByAABB(low, high, ERR_OpeningAircraftAudit, null, EQueryEntitiesFlags.ALL);
ERR_SiteRuntime prison = Sites().Find("everon-prison-1");
array<AIAgent> guards = {}; prison.Group.GetAgents(guards);
if (!ERR_OpeningCheck(guards.Count() >= 3 && guards.Count() <= 5 && guards.Count() == prison.InitialGuardCount, "3-5 physical prison guards")) return;
int radios;
foreach (AIAgent guard : guards)
{
if (!ERR_OpeningCheck(!guard.IsAIActivated() && ERR_Actors.CanFight(guard.GetControlledEntity()), "guard held alive until lockpick")) return;
if (ERR_HQRadio.Capability(guard.GetControlledEntity()) == 2) radios++;
}
if (!ERR_OpeningCheck(radios >= 1, "large manpack radio physically equipped")) return;
if (!ERR_OpeningCheck(ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "initial extraction helicopter intact and grounded")) return;
// Test the fallback on a faction roster containing no RTO.
m_ERROpeningFallbackGroup = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Actors.Ground(prison.Group.GetOrigin() + "10 0 10")));
Own(m_ERROpeningFallbackGroup);
m_ERROpeningFallbackGroup.SetFaction(EnemyFaction());
m_ERROpeningFallbackGroup.m_aUnitPrefabSlots = {"{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et"};
m_ERROpeningFallbackGroup.RequestSpawn(1);
m_ERROpeningFallback = new ERR_PrisonPatrol();
array<string> denied = {}; Admin().Execute(this, false, "cheat intel", denied);
if (!ERR_OpeningCheck(denied.Count() == 1 && denied[0] == "Administrator access required.", "unauthorized intel command denied")) return;
controller.ERR_AdminSubmit("cheat intel");
m_ERROpeningStage = 1; return;
}
if (m_ERROpeningStage == 1)
{
m_ERROpeningDocuments.Clear();
GetWorld().QueryEntitiesBySphere(player.GetOrigin(), 6, ERR_OpeningDocument, null, EQueryEntitiesFlags.ALL);
if (m_ERROpeningDocuments.IsEmpty()) return;
if (!ERR_OpeningCheck(m_ERROpeningDocuments.Count() == 6, "authenticated command spawned exactly six physical intel documents")) return;
foreach (IEntity document : m_ERROpeningDocuments)
{
InventoryItemComponent item = InventoryItemComponent.Cast(document.FindComponent(InventoryItemComponent));
if (!ERR_OpeningCheck(item && !item.GetParentSlot() && Math.AbsFloat(document.GetOrigin()[1] - ERR_Actors.Ground(document.GetOrigin())[1]) < 0.4 && !Discovery().IsSpent(document), "fresh document lies on nearby ground outside inventory")) return;
}
m_ERROpeningPicked = m_ERROpeningDocuments[0];
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(player.FindComponent(SCR_InventoryStorageManagerComponent));
m_ERROpeningPickup = new ERR_AdminItemCallback();
if (!ERR_OpeningCheck(inventory.TryInsertItem(m_ERROpeningPicked, EStoragePurpose.PURPOSE_DEPOSIT, m_ERROpeningPickup), "ground document pickup accepted")) return;
m_ERROpeningStage = 2; return;
}
if (m_ERROpeningStage == 2)
{
ERR_PrepareResult radio = m_ERROpeningFallback.Radio(this, m_ERROpeningFallbackGroup, GetWorld().GetWorldTime() + 10000);
if (radio == ERR_PrepareResult.FAILED) { ERR_OpeningCheck(false, "fallback radio insertion failed"); return; }
if (radio != ERR_PrepareResult.READY || !m_ERROpeningPickup.Finished || !Discovery().IsSpent(m_ERROpeningPicked)) return;
if (!ERR_OpeningCheck(m_ERROpeningPickup.Succeeded, "picked-up cheat document consumed by normal discovery")) return;
if (!ERR_OpeningCheck(ERR_HQRadio.Capability(m_ERROpeningFallbackGroup.GetLeaderEntity()) == 2, "non-RTO loadout receives actual large radio")) return;
if (!ERR_OpeningCheck(UnlockPrison(player), "normal lockpick progression remains available")) return;
GetGame().GetCallqueue().Remove(ERR_OpeningTick);
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: prison patrol, large radio, ground intel admin command and discovery");
}
}
}
#endif
@@ -38,7 +38,7 @@ class ERR_NativePatrolProbe
}
protected bool DefinitionChecks()
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
if (!Check(definition && definition.Valid(), "authored definition resolves")) return false;
ERR_SiteDefinition site = definition.Find("everon-outpost-1");
if (!Check(site && site.Patrol && site.Patrol.Count() == 4, "four ordered outpost points authored")) return false;
@@ -0,0 +1,17 @@
#ifdef WORKBENCH
modded class ERR_HQ
{
bool ERR_ProbeRadioReservation(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
if (!unit.ReservedForSupport || !unit.HomeGuard || unit.Eligible(m_Mode)) return false;
if (ReserveLogistics(group, 999)) return false;
ReleaseMortarCrew(group); // Only actual destroyed mortar crews may be released.
return unit.ReservedForSupport && !unit.Eligible(m_Mode) && !unit.Waypoint && !unit.PendingOrder && !unit.Assignment;
}
return false;
}
}
#endif
@@ -27,7 +27,7 @@ class ERR_NativeTransportProbe
}
protected bool DefinitionChecks()
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
ERR_SiteDefinition site;
if (definition) site = definition.Find("everon-supply-2");
if (!Check(site && site.Transport && definition.Valid(), "authored transport definition resolves")) return false;
@@ -189,7 +189,7 @@ class ERR_NativeTransportProbe
if (m_Step == 5)
{
if (!Check(!m_Site.Transport && m_Mode.Sites().TransportCount() == 0 && m_Mode.Sites().Prepare() == ERR_PrepareResult.READY, "lost transport remains absent after service ticks and preparation queries")) return -1;
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
if (!Check(!m_Mode.Sites().Begin(definition, ERR_Supplies.Map()) && m_Mode.Sites().TransportCount() == 0, "duplicate initialization cannot replace lost transport")) return -1;
m_Mode.Sites().Stop();
if (!Check(m_Mode.Sites().Prepare() == ERR_PrepareResult.FAILED && !m_Mode.Sites().Begin(definition, ERR_Supplies.Map()), "stopped finite owner cannot reinitialize transport")) return -1;
@@ -15,7 +15,7 @@ class ERR_NativeTransportSurvey
{
static void Run(ERR_GameMode mode)
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
ERR_SiteDefinition site = definition.Find("everon-supply-2");
int clear;
for (int x = -40; x <= 40; x += 8)
@@ -54,8 +54,8 @@ class ERR_ObjectiveText
}
// Scan all sites before acknowledging rough intel: an identified radio wins.
if (roughRadio)
return "Intel recovered: a 500 m search square is now marked on the map. Use a carried field map to view it. Take a different intelligence folder into your inventory to pinpoint the radio; a folder already recovered cannot provide another clue. Try the other marked supply cache if needed. A close approach with a clear view can also identify the station.";
return "Choose either marked supply cache. Take its intelligence folders into your own inventory to reveal the station on your map. If only a search area appears, take another folder. Each cache has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view.";
return "Intel recovered: a question mark shows an approximate location, but its type is still unknown. Take a different intelligence folder into your inventory to pinpoint the radio; a folder already recovered cannot provide another clue. Try the other marked supply cache if needed. A close approach with a clear view can also identify the station.";
return "Choose either marked supply cache. Take its intelligence folders into your own inventory to reveal the station on your map. If the location is not yet identified, take another folder. Each cache has enough folders; use the other if one is lost. If every folder is lost, a close approach with a clear view can identify the station itself. Nearby medical and guard sites can also be discovered by approaching with a clear view.";
}
if (phase == ERR_Phase.RADIO_DEFENCE)
{
@@ -11,7 +11,7 @@ modded class SCR_ChimeraAIAgent
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
if (!mode || mode.Phase() > ERR_Phase.PRISON || !actor || ERR_Actors.Player(actor) || IsPlayerPending_S()) return;
if (!mode.EnemyFaction() || SCR_FactionManager.SGetFaction(actor) != mode.EnemyFaction()) return;
if (!mode.IsMissionEnemy(SCR_FactionManager.SGetFaction(actor))) return;
DeactivateAI();
if (m_ERRPrisonMode) return;
m_ERRPrisonMode = mode;
@@ -161,7 +161,7 @@ class ERR_PrisonEscapeService
vector position = actor.GetOrigin();
float ground = GetGame().GetWorld().GetSurfaceY(position[0], position[2]);
if (!ERR_PrisonEscapeRules.Grounded(position[1] - ground)) continue;
vector offset = position - m_Center;
vector offset = ERR_CampTerrain.Local(position - m_Center, m_Mode.PrisonHeading());
ERR_PrisonEscapeSample sample = m_Samples.Get(life.AccountId);
bool armed;
if (sample && sample.Actor == actor && sample.Controller == controller)
@@ -169,7 +169,7 @@ class ERR_PrisonEscapeService
float elapsed = now - sample.ObservedAt;
float displacement = vector.Distance(position, sample.Position);
bool continuous = ERR_PrisonEscapeRules.Continuous(elapsed, displacement);
vector previousOffset = sample.Position - m_Center;
vector previousOffset = ERR_CampTerrain.Local(sample.Position - m_Center, m_Mode.PrisonHeading());
if (ERR_PrisonEscapeRules.Crossing(sample.Armed, previousOffset[0], previousOffset[2], offset[0], offset[2], elapsed, displacement))
{
// An actual qualifying exit is sufficient even if the gate remains intact.
@@ -0,0 +1,106 @@
// One finite opening patrol. Build the selected faction's roster before asking
// the native spawn queue for any members; never spawn a full squad and trim it.
class ERR_PrisonPatrol
{
protected IEntity m_Radio;
protected bool m_RequestedRadio;
protected bool m_Placed;
bool Place(SCR_AIGroup group, vector center)
{
if (m_Placed) return true;
if (!Replication.IsServer() || !group) return false;
array<AIAgent> agents = {}; group.GetAgents(agents);
if (agents.Count() < 3 || agents.Count() > 5) return false;
map<int, vector> positions = new map<int, vector>();
ERR_SpawnPlacement placement = new ERR_SpawnPlacement();
float start = Math.RandomFloat(0, 360);
float sector = 360.0 / agents.Count();
for (int index = 0; index < agents.Count(); index++)
{
if (!agents[index] || !ChimeraCharacter.Cast(agents[index].GetControlledEntity())) return false;
bool found;
for (int attempt = 0; attempt < 80; attempt++)
{
float angle = (start + sector * index + Math.RandomFloat(-sector * 0.3, sector * 0.3)) * Math.DEG2RAD;
float radius = Math.RandomFloat(12, 24);
vector position = ERR_Actors.Ground(center + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius));
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) continue;
if (!placement.Clear(position, positions, index, agents[index].GetControlledEntity())) continue;
positions.Insert(index, position); found = true; break;
}
if (!found) return false;
}
for (int member = 0; member < agents.Count(); member++)
{
ChimeraCharacter actor = ChimeraCharacter.Cast(agents[member].GetControlledEntity());
vector transform[4];
Math3D.AnglesToMatrix(Vector(Math.RandomFloat(0, 360), 0, 0), transform);
transform[3] = positions.Get(member);
actor.Teleport(transform);
}
m_Placed = true;
return true;
}
static array<ResourceName> Members(array<ResourceName> source, int count)
{
array<ResourceName> result = {};
if (!source || source.IsEmpty() || count < 3 || count > 5) return result;
array<ResourceName> soldiers = {};
ResourceName radioman;
foreach (ResourceName prefab : source)
{
string name = prefab; name.ToLower();
if (name.Contains("_rto") || name.Contains("radioman"))
{ if (radioman.IsEmpty()) radioman = prefab; }
else if (!prefab.IsEmpty()) soldiers.Insert(prefab);
}
if (!radioman.IsEmpty()) result.Insert(radioman);
if (soldiers.IsEmpty()) soldiers.Insert(source[0]);
int index;
while (result.Count() < count) { result.Insert(soldiers[index % soldiers.Count()]); index++; }
return result;
}
static bool Configure(SCR_AIGroup group, ResourceName profile, Faction faction)
{
if (!Replication.IsServer() || !group || !faction || group.GetAgentsCount() != 0) return false;
Resource resource = Resource.Load(profile);
if (!resource || !resource.IsValid()) return false;
IEntitySource source = resource.GetResource().ToEntitySource();
array<ResourceName> roster = {};
if (!source || !source.Get("m_aUnitPrefabSlots", roster)) return false;
group.m_aUnitPrefabSlots = Members(roster, Math.RandomIntInclusive(3, 5));
if (group.m_aUnitPrefabSlots.IsEmpty()) return false;
group.SetFaction(faction);
group.SetSpawnImmediately(true);
group.RequestSpawn(group.m_aUnitPrefabSlots.Count());
return true;
}
// Existing faction RTO loadouts are preferred. If a mod's fireteam has none,
// equip one soldier with a real large radio, replacing its backpack if needed.
ERR_PrepareResult Radio(ERR_GameMode mode, SCR_AIGroup group, float deadline)
{
array<AIAgent> agents = {}; group.GetAgents(agents);
foreach (AIAgent member : agents)
if (member && ERR_HQRadio.Capability(member.GetControlledEntity()) == 2) return ERR_PrepareResult.READY;
if (GetGame().GetWorld().GetWorldTime() >= deadline) return ERR_PrepareResult.FAILED;
if (m_RequestedRadio) return ERR_PrepareResult.PENDING;
if (agents.IsEmpty() || !agents[0]) return ERR_PrepareResult.PENDING;
IEntity actor = agents[0].GetControlledEntity();
if (!actor) return ERR_PrepareResult.PENDING;
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(actor.FindComponent(SCR_InventoryStorageManagerComponent));
if (!inventory) return ERR_PrepareResult.PENDING;
m_RequestedRadio = true;
ResourceName radio = "{54C68E438DD34265}Prefabs/Items/Equipment/Radios/Radio_R107M.et";
if (group.GetFaction().GetFactionKey() == "US" || group.GetFaction().GetFactionKey() == "RHS_USAF")
radio = "{9B6B61BB3FE3DFB0}Prefabs/Items/Equipment/Radios/Radio_ANPRC77.et";
m_Radio = ERR_Actors.Spawn(radio, actor.GetOrigin());
if (!m_Radio) return ERR_PrepareResult.FAILED;
mode.Own(m_Radio);
inventory.EquipCloth(m_Radio);
return ERR_PrepareResult.PENDING;
}
}
@@ -56,7 +56,7 @@ class ERR_RadioDefenceService
int DurationSeconds()
{
if (!m_Mode || !m_Mode.Settings()) return 0;
return m_Mode.Settings().Get("radioDefenceSeconds");
return m_Mode.Settings().Runtime("radioDefenceSeconds");
}
int SecondsRemaining(float now)
{
@@ -6,12 +6,17 @@ class ERR_ScenarioComponent : ScriptComponent
protected ResourceName m_Definition;
protected ref ERR_SiteService m_Sites;
protected bool m_Started;
protected ref ERR_ScenarioDefinition m_RuntimeDefinition;
protected ref ERR_GeneratedSites m_Generated;
protected bool m_Finished;
protected bool m_Failed;
ERR_SiteService Sites() { return m_Sites; }
string FailureReason()
{
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Scouts() && !hq.Scouts().Error().IsEmpty()) return hq.Scouts().Error();
if (m_Generated && !m_Generated.Error().IsEmpty()) return m_Generated.Error();
if (m_Sites && !m_Sites.FailureReason().IsEmpty()) return m_Sites.FailureReason();
return "Scenario or additional force preparation failed.";
}
@@ -23,12 +28,35 @@ class ERR_ScenarioComponent : ScriptComponent
if (!m_Started)
{
m_Started = true;
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
#ifdef WORKBENCH
string historicalFixture;
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "generated-sites" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
#endif
m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
if (!m_RuntimeDefinition || !m_RuntimeDefinition.Valid()) { m_Failed = true; return ERR_PrepareResult.FAILED; }
#ifdef WORKBENCH
// Existing deterministic probes intentionally use authored coordinates.
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "generated-sites" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
#endif
m_Generated = new ERR_GeneratedSites();
if (!m_Generated.Begin(m_RuntimeDefinition)) { m_Failed = true; return ERR_PrepareResult.FAILED; }
}
ERR_PrepareResult placement = m_Generated.Tick();
if (placement == ERR_PrepareResult.FAILED) m_Failed = true;
if (placement != ERR_PrepareResult.READY) return placement;
if (!m_Sites)
{
m_Sites = new ERR_SiteService(mode);
if (!m_Sites.Begin(definition, FieldMap())) { m_Failed = true; return ERR_PrepareResult.FAILED; }
if (!m_Sites.Begin(m_RuntimeDefinition, FieldMap())) { m_Failed = true; return ERR_PrepareResult.FAILED; }
}
ERR_PrepareResult result = m_Sites.Prepare();
if (result != ERR_PrepareResult.READY) return result;
ERR_PrepareResult population = PrepareAdditionalPopulation();
if (population == ERR_PrepareResult.FAILED) { m_Failed = true; m_Sites.Rollback(); }
if (population != ERR_PrepareResult.READY) return population;
if (!PrepareAdditionalForces())
{
m_Failed = true;
@@ -47,6 +75,7 @@ class ERR_ScenarioComponent : ScriptComponent
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.Active() && m_Sites) m_Sites.Activate();
}
protected ERR_PrepareResult PrepareAdditionalPopulation() { return ERR_PrepareResult.READY; }
protected bool PrepareAdditionalForces() { return true; }
protected ResourceName FieldMap() { return ERR_Supplies.Map(); }
protected override void OnDelete(IEntity owner)
@@ -3,16 +3,19 @@ class ERR_Settings
{
static const string PATH = "$profile:rereintroduced.json";
protected ref map<string, int> m_Values = new map<string, int>();
protected ref map<string, int> m_Resolved = new map<string, int>();
protected bool m_ReadOnly;
protected string m_Path;
static array<string> Keys()
{
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn", "automaticConvoyEnabled"};
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
if (key == "heliTimerMinutes") return value >= 1 && value <= 1440;
if (key == "convoyCount") return value >= 0 && value <= 20;
if (key == "automaticConvoyEnabled") return value == 0 || value == 1;
if (key == "stingerChanceOneIn") return value >= 0 && value <= 10000;
if (key == "reportIntervalSeconds") return value >= 5 && value <= 60;
@@ -44,8 +47,57 @@ class ERR_Settings
return false;
}
// Saved modes are distinct from validated concrete gameplay values.
static bool Randomizable(string key)
{
return key == "heliTimerMinutes" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres" || key == "aiIntelChancePercent" || key == "intelRevealChancePercent" || key == "radioDefenceSeconds" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile";
}
static bool ValidSelection(string key, int value)
{
if (value == -1) return Randomizable(key);
if (value == -2) return key == "startTimeMinutes";
return Valid(key, value);
}
static string SelectionText(int value)
{
if (value == -1) return "Random";
if (value == -2) return "Random day";
return value.ToString();
}
static bool RandomBounds(string key, out int minimum, out int maximum)
{
minimum = 0;
if (key == "heliTimerMinutes") { minimum = 1; maximum = 1440; }
else if (key == "convoyCount") maximum = 20;
else if (key == "reportIntervalSeconds") { minimum = 5; maximum = 60; }
else if (key == "supportSquadLimit") maximum = 8;
else if (key == "responseRadiusMetres") { minimum = 500; maximum = 5000; }
else if (key == "aiIntelChancePercent" || key == "intelRevealChancePercent") maximum = 100;
else if (key == "radioDefenceSeconds") { minimum = 30; maximum = 1800; }
else if (key == "startTimeMinutes") maximum = 1439;
else if (key == "timeMultiplier") { minimum = 1; maximum = 24; }
else if (key == "weatherProfile") { minimum = 1; maximum = 3; }
else return false;
return true;
}
// Server-only, once per setting per round. Polls and gameplay reads never reroll.
// Date and daylight depend on the world and are resolved by their services.
int Runtime(string key)
{
int value = Get(key);
if (value != -1 || !Replication.IsServer()) return value;
if (m_Resolved.Contains(key)) return m_Resolved.Get(key);
int minimum, maximum;
if (!RandomBounds(key, minimum, maximum)) return value;
value = Math.RandomInt(minimum, maximum + 1);
m_Resolved.Set(key, value);
return value;
}
static int AddedDefault(string key)
{
if (key == "heliTimerMinutes") return 15;
if (key == "convoyCount") return 3;
if (key == "automaticConvoyEnabled") return 1;
if (key == "stingerChanceOneIn") return 15;
if (key == "startTimeMinutes") return 840;
@@ -69,6 +121,8 @@ class ERR_Settings
m_Values.Set("weatherProfile", AddedDefault("weatherProfile"));
m_Values.Set("stingerChanceOneIn", AddedDefault("stingerChanceOneIn"));
m_Values.Set("automaticConvoyEnabled", AddedDefault("automaticConvoyEnabled"));
m_Values.Set("convoyCount", AddedDefault("convoyCount"));
m_Values.Set("heliTimerMinutes", AddedDefault("heliTimerMinutes"));
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
map<string, int> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded;
@@ -97,7 +151,7 @@ class ERR_Settings
float number;
if (!json.ReadValue(key, number)) return false;
int value = Math.Round(number);
if (number != value || !Valid(key, value)) return false;
if (number != value || !ValidSelection(key, value)) return false;
values.Set(key, value);
}
return true;
@@ -105,7 +159,7 @@ class ERR_Settings
bool Set(string key, int value)
{
if (!Replication.IsServer() || m_ReadOnly || !Valid(key, value)) return false;
if (!Replication.IsServer() || m_ReadOnly || !ValidSelection(key, value)) return false;
JsonSaveContext json = new JsonSaveContext();
json.EnableTypeDiscriminator(false);
foreach (string option : Keys())
@@ -139,6 +193,7 @@ class ERR_Settings
return false;
}
}
if (Get(key) != value) m_Resolved.Remove(key);
m_Values = checked;
return true;
}
@@ -57,8 +57,21 @@ class ERR_SiteDefinition
ResourceName Prefab;
[Attribute("0 0 0", UIWidgets.Coords)]
vector Position;
[Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, radio or mortar)")]
bool RandomClear;
[Attribute("0", UIWidgets.EditBox, "Composition heading in degrees")]
float Heading;
[Attribute("0", UIWidgets.EditBox, "Height of composition root above its surveyed ground")]
float RootHeight;
bool PlacementResolved;
[Attribute("12 0 0", UIWidgets.Coords, "Extraction aircraft offset from its site")]
vector AircraftOffset;
[Attribute("0", UIWidgets.CheckBox, "Require a clear pad on airfield grounds before aircraft creation")]
bool RequireAirfield;
[Attribute("", UIWidgets.ResourceNamePicker, "Optional physical guard group", "et")]
ResourceName Guards;
[Attribute("0", UIWidgets.CheckBox, "Choose a guard group from enabled content factions; retain catalog fallback when none enabled")]
bool AllowFactionPool;
[Attribute()] ref array<ref ERR_SitePatrolPoint> Patrol;
[Attribute("0", UIWidgets.CheckBox, "Populate a finite squad from the faction catalog") ]
bool Guarded;
@@ -81,6 +94,8 @@ class ERR_ScenarioDefinition
ref array<ref ERR_SiteDefinition> Sites;
[Attribute("2", UIWidgets.EditBox, "Independent accessible caches containing enough radio documents") ]
int MinimumRadioSources;
[Attribute("0", UIWidgets.EditBox, "Finite hill scouts per round (0-32); each has a radio, binoculars and a sniper rifle")]
int ScoutCount;
ERR_SiteDefinition Find(string id)
{
@@ -91,6 +106,7 @@ class ERR_ScenarioDefinition
bool Valid()
{
if (ScoutCount < 0 || ScoutCount > 32) return false;
if (!Sites || Sites.IsEmpty() || Sites.Count() > 32) return false;
set<string> ids = new set<string>();
int prisons, radios, extractions, groups, items, sources, maps, props, transports;
@@ -99,6 +115,9 @@ class ERR_ScenarioDefinition
foreach (ERR_SiteDefinition site : Sites)
{
if (!site || site.Id.IsEmpty() || ids.Contains(site.Id) || site.Prefab.IsEmpty()) return false;
if (site.RequireAirfield && site.Kind != ERR_LocationKind.EXTRACTION) return false;
if (site.Kind == ERR_LocationKind.RADIO && !site.Guarded && site.Guards.IsEmpty()) return false;
if (site.RandomClear && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR) return false;
if (site.Label.IsEmpty() || site.Label.Length() > 64) return false;
if (!ERR_SiteDiscoveryRules.ValidRadius(site.Kind, site.DiscoverRadius)) return false;
bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST;
@@ -118,7 +137,7 @@ class ERR_ScenarioDefinition
foreach (ERR_SiteProp prop : site.Props)
if (!prop || prop.Prefab.IsEmpty() || prop.Offset.Length() > 40) return false;
}
if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.DEPOT) return false;
if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.MORTAR) return false;
ids.Insert(site.Id);
if (site.Kind == ERR_LocationKind.PRISON) prisons++;
if (site.Kind == ERR_LocationKind.RADIO) radios++;
@@ -7,7 +7,7 @@ class ERR_SiteDiscoveryRules
{
if (radius < 0 || radius > 60) return false;
if (kind == ERR_LocationKind.RADIO) return radius <= RadioRadiusLimit();
if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST) return true;
if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.MORTAR) return true;
return (kind == ERR_LocationKind.PRISON || kind == ERR_LocationKind.EXTRACTION) && radius == 0;
}
static bool InRange(float radius, float horizontalDistance, float verticalDistance)
@@ -13,18 +13,22 @@ class ERR_SiteRuntime
ERR_LocationComponent Location;
IEntity Container;
SCR_AIGroup Group;
string ExpectedGuardFaction;
int InitialGuardCount;
ref ERR_PrisonPatrol PrisonPatrol;
int LastInitialPhysical = -1;
int LastInitialConscious = -1;
Vehicle Transport;
AIWaypointCycle PatrolCycle;
ref array<AIWaypoint> PatrolWaypoints = {};
ref array<IEntity> Props = {};
}
// One owner for finite physical site contents. No population or stock refill tick.
class ERR_SiteService
{
protected ERR_GameMode m_Mode;
protected int m_ContentGuardIndex;
protected ref ERR_ExtractionPlacement m_ExtractionPlacement;
ERR_ExtractionPlacement ExtractionPlacement() { return m_ExtractionPlacement; }
protected ref array<ref ERR_SiteRuntime> m_Sites = {};
@@ -43,6 +47,11 @@ class ERR_SiteService
void ERR_SiteService(ERR_GameMode mode) { m_Mode = mode; }
int Count() { return m_Sites.Count(); }
ERR_SiteRuntime At(int index)
{
if (index < 0 || index >= m_Sites.Count()) return null;
return m_Sites[index];
}
bool Owns(IEntity entity) { return entity && m_Entities.Contains(entity); }
// Snapshot only nearby mission-owned objects. Distant sites must not block
// arrival, and vanilla map streaming remains the native preload's job.
@@ -100,12 +109,23 @@ class ERR_SiteService
PrintFormat("Reintroduced [sites]: cache clearance position=%1 clear=%2", ground, !m_Obstructed);
return !m_Obstructed;
}
protected bool BackpackClearance(vector position)
{
vector floor = ERR_Actors.Ground(position);
TraceBox trace = new TraceBox();
trace.Start = floor + "0 0.15 0"; trace.End = floor + "0 1.15 0";
trace.Mins = "-0.5 0 -0.5"; trace.Maxs = "0.5 0.1 0.5";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
return GetGame().GetWorld().TraceMove(trace, null) >= 1;
}
protected bool CreateGuardOrders(ERR_SiteRuntime site, vector anchor)
{
if (site.Definition.Kind == ERR_LocationKind.RADIO) anchor = ERR_Actors.Ground(site.Location.Position());
if (!site.Definition.Patrol || site.Definition.Patrol.IsEmpty())
{
AIWaypoint defend = AIWaypoint.Cast(Spawn(ERR_Resources.DefendWaypoint(), anchor));
if (!defend) return false;
if (site.Definition.Kind == ERR_LocationKind.RADIO) defend.SetCompletionRadius(20);
site.Group.AddWaypoint(defend);
return true;
}
@@ -247,25 +267,35 @@ class ERR_SiteService
{
vector position = ERR_Actors.Ground(spec.Position);
if (position[1] < 1) return Fail("site below sea level: " + spec.Id);
IEntity root = Spawn(spec.Prefab, position);
vector rootPosition = position;
if (spec.PlacementResolved && spec.Kind == ERR_LocationKind.PRISON) rootPosition = spec.Position;
rootPosition[1] = rootPosition[1] + spec.RootHeight;
IEntity root = ERR_Actors.Spawn(spec.Prefab, rootPosition, Vector(spec.Heading, 0, 0));
if (root) m_Entities.Insert(root);
if (!root) return Fail("site composition: " + spec.Id);
ERR_SiteRuntime site = new ERR_SiteRuntime();
site.Definition = spec;
site.Location = ERR_LocationComponent.Cast(root.FindComponent(ERR_LocationComponent));
if (!site.Location || !site.Location.Configure(spec.Id, spec.Kind, spec.Label)) return Fail("location identity: " + spec.Id);
m_Sites.Insert(site);
if (spec.Kind == ERR_LocationKind.PRISON) m_Mode.ConfigureStart(position);
if (spec.Kind == ERR_LocationKind.PRISON) m_Mode.ConfigureStart(position, spec.Heading);
if (spec.Kind == ERR_LocationKind.EXTRACTION)
{
Vehicle helicopter = Vehicle.Cast(Spawn(m_Mode.Factions().ExtractionPrefab, position + "12 0 0"));
vector aircraftPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(spec.AircraftOffset, spec.Heading));
if (spec.RequireAirfield && !ERR_AirfieldPlacement.Clear(aircraftPosition)) return Fail("extraction airfield pad is missing or obstructed; no off-airfield fallback");
Vehicle helicopter = Vehicle.Cast(Spawn(m_Mode.Factions().ExtractionPrefab, aircraftPosition));
if (!helicopter) return Fail("extraction vehicle");
if (!m_Mode.SetExtractionVehicle(helicopter)) return Fail("extraction vehicle registration");
m_ExtractionPlacement = new ERR_ExtractionPlacement(m_Mode, helicopter);
}
if (!spec.Container.IsEmpty())
{
if (!CacheClearance(position + spec.ContainerOffset)) return Fail("obstructed cache position: " + spec.Id);
site.Container = Spawn(spec.Container, position + spec.ContainerOffset);
vector containerPosition = position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading);
bool clearContainer;
if (spec.Kind == ERR_LocationKind.PRISON) clearContainer = BackpackClearance(containerPosition);
else clearContainer = CacheClearance(containerPosition);
if (!clearContainer) return Fail("obstructed cache position: " + spec.Id);
site.Container = Spawn(spec.Container, position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading));
if (!site.Container) return Fail("container: " + spec.Id);
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(site.Container.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return Fail("container inventory: " + spec.Id);
@@ -301,21 +331,39 @@ class ERR_SiteService
{
foreach (ERR_SiteProp prop : spec.Props)
{
vector propPosition = ERR_Actors.Ground(position + prop.Offset);
if (!CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id);
vector propPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(prop.Offset, spec.Heading));
// Radio furniture intentionally surrounds/supports its own elevated radio.
// Generated camp terrain was checked before any of these objects existed.
if (spec.Kind != ERR_LocationKind.RADIO && !CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id);
propPosition[1] = propPosition[1] + prop.Offset[1];
IEntity scenery = ERR_Actors.Spawn(prop.Prefab, propPosition, prop.Angles);
IEntity scenery = ERR_Actors.Spawn(prop.Prefab, propPosition, prop.Angles + Vector(spec.Heading, 0, 0));
if (!scenery) return Fail("scenery prefab: " + spec.Id);
m_Entities.Insert(scenery);
m_Entities.Insert(scenery); site.Props.Insert(scenery);
}
}
if (!CreateTransport(site, position)) return false;
if (spec.Guarded || !spec.Guards.IsEmpty())
{
vector guardPosition = position + spec.GuardOffset;
vector guardPosition = position + ERR_CampTerrain.Rotate(spec.GuardOffset, spec.Heading);
ResourceName groupPrefab = spec.Guards;
site.ExpectedGuardFaction = m_Mode.Factions().EnemyFactionKey;
if (spec.AllowFactionPool && m_Mode.Content())
{
ResourceName selectedGroup = m_Mode.Content().GuardGroup(m_ContentGuardIndex++);
if (!selectedGroup.IsEmpty())
{
groupPrefab = selectedGroup;
site.ExpectedGuardFaction = m_Mode.Content().GuardFaction(selectedGroup);
}
}
if (groupPrefab.IsEmpty()) groupPrefab = m_Mode.Factions().GuardGroupPrefab;
site.Group = SCR_AIGroup.Cast(Spawn(groupPrefab, guardPosition));
if (spec.Kind == ERR_LocationKind.PRISON)
{
site.Group = SCR_AIGroup.Cast(Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", guardPosition));
if (!ERR_PrisonPatrol.Configure(site.Group, groupPrefab, m_Mode.EnemyFaction())) return Fail("prison patrol roster: " + spec.Id);
site.PrisonPatrol = new ERR_PrisonPatrol();
}
else site.Group = SCR_AIGroup.Cast(Spawn(groupPrefab, guardPosition));
if (!site.Group) return Fail("guard group resource: " + spec.Id);
if (!site.Group.m_aUnitPrefabSlots || site.Group.m_aUnitPrefabSlots.IsEmpty() || !site.Group.GetSpawnImmediately()) return Fail("guard profile must request an initial physical squad: " + spec.Id);
site.InitialGuardCount = site.Group.m_aUnitPrefabSlots.Count();
@@ -337,11 +385,16 @@ class ERR_SiteService
Fail("initial guard group disappeared or has no configured members: " + site.Definition.Id);
return m_State;
}
if (site.Group.GetFaction() != m_Mode.EnemyFaction())
if (!m_Mode.IsMissionEnemy(site.Group.GetFaction()))
{
Fail("guard group faction disagrees with catalog: " + site.Definition.Id);
return m_State;
}
if (site.Group.GetFaction().GetFactionKey() != site.ExpectedGuardFaction)
{
Fail("guard prefab faction disagrees with its content definition: " + site.Definition.Id);
return m_State;
}
array<AIAgent> agents = {};
array<IEntity> physical = {};
site.Group.GetAgents(agents);
@@ -352,7 +405,7 @@ class ERR_SiteService
IEntity actor = agent.GetControlledEntity();
if (!actor || physical.Contains(actor)) continue;
physical.Insert(actor);
if (SCR_FactionManager.SGetFaction(actor) != m_Mode.EnemyFaction())
if (SCR_FactionManager.SGetFaction(actor) != site.Group.GetFaction())
{
Fail("initial guard actor faction disagrees with catalog: " + site.Definition.Id);
return m_State;
@@ -381,6 +434,15 @@ class ERR_SiteService
PrintFormat("Reintroduced [guards]: timeout detail site=%1 navmesh=%2 tileLoaded=%3 tileRequested=%4 dormantAlive=%5 dormantDead=%6", site.Definition.Id, navmesh != null, tileLoaded, tileRequested, site.Group.GetDormantAliveCount(), site.Group.GetDormantDeadCount());
Fail(string.Format("initial guard population incomplete or invalid: site=%1 requested=%2 physical=%3 conscious=%4 nativeComplete=%5; limit=90s; no replacement requested", site.Definition.Id, site.InitialGuardCount, physical.Count(), conscious, complete));
}
if (result == ERR_PrepareResult.READY && site.PrisonPatrol)
{
if (!site.PrisonPatrol.Place(site.Group, site.Location.Position()))
{
Fail("no clear perimeter positions for prison patrol"); return ERR_PrepareResult.FAILED;
}
result = site.PrisonPatrol.Radio(m_Mode, site.Group, m_GuardDeadline);
if (result == ERR_PrepareResult.FAILED) Fail("prison patrol large radio could not be equipped");
}
return result;
}
protected void CancelInitialGuardRequests()
@@ -146,7 +146,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
protected bool SpawnPosition(ERR_GameMode mode, out vector position)
{
if (mode.Phase() == ERR_Phase.PRISON) return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position);
if (mode.Phase() == ERR_Phase.PRISON) return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position, mode.PrisonHeading());
array<IEntity> survivors = {};
ERR_Actors.Players(survivors, true);
foreach (IEntity survivor : survivors)
@@ -155,7 +155,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
if (!mode.Players() || !mode.Players().CurrentActor(survivor) || !mode.PlayerFaction() || SCR_FactionManager.SGetFaction(survivor) != mode.PlayerFaction()) continue;
if (m_Placement.Near(survivor, m_AuthorizedPositions, position)) return true;
}
return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position);
return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position, mode.PrisonHeading());
}
ERR_SpawnTicket CreateSpawnTicket(SCR_SpawnRequestComponent request, SCR_SpawnData data)
@@ -328,7 +328,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
m_Attempts.Remove(playerId);
m_Retries.Remove(playerId);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode) mode.PlayerSpawned(playerId, entity);
if (mode) { ERR_Actors.SetFaction(entity, mode.PlayerFaction()); mode.PlayerSpawned(playerId, entity); }
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (controller) controller.ERR_SetObserver(ERR_ObserverReason.NONE);
PrintFormat("Reintroduced [spawn]: player %1 at %2", playerId, entity.GetOrigin());
@@ -2,18 +2,25 @@
class ERR_SpawnPlacement
{
protected IEntity m_Exclude;
protected vector m_Candidate;
protected bool m_Blocked;
protected bool Obstacle(IEntity entity)
protected bool Occupied(IEntity entity)
{
if (!entity || (!entity.GetVObject() && !ChimeraCharacter.Cast(entity))) return true;
// Character controller geometry is not included in every native shape trace.
// Narrow the broad-phase result by actual body position, not its query bounds.
if (entity != m_Exclude && ChimeraCharacter.Cast(entity) && vector.DistanceXZ(m_Candidate, entity.GetOrigin()) < 1.2 && Math.AbsFloat(m_Candidate[1] - entity.GetOrigin()[1]) < 2) m_Blocked = true;
return !m_Blocked;
}
protected bool Obstacle(IEntity entity, vector start = "0 0 0", vector direction = "0 0 0")
{
if (!entity) return false;
IEntity parent = entity;
for (int depth = 0; parent && depth < 32; depth++)
{
if (parent == m_Exclude) return true;
if (parent == m_Exclude) return false;
parent = parent.GetParent();
}
m_Blocked = true;
return false;
return true;
}
bool Clear(vector position, map<int, vector> reservations, int ownReservation = -1, IEntity exclude = null)
{
@@ -30,16 +37,24 @@ class ERR_SpawnPlacement
vector sample = ERR_Actors.Ground(position + Vector(x * 0.45, 0, z * 0.45));
if (Math.AbsFloat(sample[1] - ground[1]) > 0.3) return false;
}
m_Blocked = false;
m_Exclude = exclude;
GetGame().GetWorld().QueryEntitiesByAABB(ground + "-0.45 0.15 -0.45", ground + "0.45 1.9 0.45", Obstacle);
m_Candidate = ground; m_Blocked = false;
GetGame().GetWorld().QueryEntitiesByAABB(ground + "-1.2 -2 -1.2", ground + "1.2 2 1.2", Occupied);
if (m_Blocked) { m_Exclude = null; return false; }
// Broad entity bounds overlap clear slots when the prison is rotated.
// Test the filled standing volume against physical geometry instead.
TraceBox volume = new TraceBox();
volume.Start = ground + "0 0.15 0"; volume.End = volume.Start + "0 0.01 0";
volume.Mins = "-0.45 0 -0.45"; volume.Maxs = "0.45 1.75 0.45";
volume.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
volume.Exclude = exclude;
float fraction = GetGame().GetWorld().TraceMove(volume, Obstacle);
m_Exclude = null;
return !m_Blocked;
return ERR_ForceRules.Finite(fraction) && fraction >= 1;
}
bool Prison(vector center, map<int, vector> reservations, out vector position)
bool Prison(vector center, map<int, vector> reservations, out vector position, float heading = 0)
{
// Native character query bounds are about 4m wide. A 3m lattice leaves
// clearance between that conservative bound and the 0.9m query column.
// A 3m lattice leaves clearance around the 0.9m standing volume.
// Search the four inner positions, then eight edge positions, then corners.
// All sixteen centers remain at least 1.5m inside the authored +/-6m walls.
for (int band = 0; band <= 2; band++)
@@ -50,7 +65,7 @@ class ERR_SpawnPlacement
if (row == 0 || row == 3) outerAxes++;
if (column == 0 || column == 3) outerAxes++;
if (outerAxes != band) continue;
vector candidate = ERR_Actors.Ground(center + Vector(-4.5 + column * 3, 0, -4.5 + row * 3));
vector candidate = ERR_Actors.Ground(center + ERR_CampTerrain.Rotate(Vector(-4.5 + column * 3, 0, -4.5 + row * 3), heading));
if (!Clear(candidate, reservations)) continue;
position = candidate;
return true;
@@ -33,6 +33,14 @@ class ERR_TimeService
minutes = hours * 60 + remainder;
return true;
}
// Whole minutes strictly after sunrise and before sunset; exclusive end.
static bool DaylightBounds(float sunrise, float sunset, out int first, out int end)
{
if (sunrise < 0 || sunset > 24 || sunrise >= sunset) return false;
first = Math.Floor(sunrise * 60) + 1;
end = Math.Ceil(sunset * 60);
return first >= 0 && first < end && end <= 1440;
}
bool Start(ERR_GameMode mode)
{
if (!Replication.IsServer() || !mode || !mode.Settings()) return false;
@@ -41,8 +49,15 @@ class ERR_TimeService
if (!world) return false;
m_Manager = world.GetTimeAndWeatherManager();
if (!m_Manager) return false;
int startMinutes = mode.Settings().Get("startTimeMinutes");
int multiplier = mode.Settings().Get("timeMultiplier");
int startMinutes = mode.Settings().Runtime("startTimeMinutes");
if (startMinutes == -2)
{
float sunrise, sunset;
int first, end;
if (!m_Manager.GetSunriseHour(sunrise) || !m_Manager.GetSunsetHour(sunset) || !DaylightBounds(sunrise, sunset, first, end)) return false;
startMinutes = Math.RandomInt(first, end);
}
int multiplier = mode.Settings().Runtime("timeMultiplier");
if (!ERR_Settings.Valid("startTimeMinutes", startMinutes) || !ERR_Settings.Valid("timeMultiplier", multiplier)) return false;
float previousDuration = m_Manager.GetDayDuration();
bool previousAdvancement = m_Manager.GetIsDayAutoAdvanced();
@@ -0,0 +1,239 @@
// A unit's low-ammo radio check-in can request this airport-loaded delivery.
// Each trip creates at most twelve compatible magazines at the airfield; those
// exact identities must physically arrive before any native inventory transfer.
class ERR_AirAmmoMission
{
protected ERR_HQ m_HQ;
protected ERR_GameMode m_Mode;
protected ERR_ReconFlightComponent m_Flight;
protected ERR_HQUnit m_Unit;
protected ref ERR_HQRoute m_Order, m_RecipientOrder;
protected ref array<IEntity> m_Cargo = {}, m_Spent = {}, m_Members = {};
protected ref array<ref ERR_ConvoyCargoCallback> m_Loads = {};
protected ref ERR_InfantryAmmoTransfer m_Move;
protected ref array<ref ERR_InfantryAmmoTransfer> m_Operations = {};
protected ref array<IEntity> m_PreviouslySubmitted;
protected float m_NextDiagnostic;
bool PendingNative()
{
foreach (ERR_ConvoyCargoCallback loading : m_Loads) if (!loading.Finished) return true;
foreach (ERR_InfantryAmmoTransfer operation : m_Operations) if (operation.Unresolved()) return true;
return false;
}
protected AIWaypoint m_Waypoint;
protected vector m_Point, m_Pad;
protected int m_Message, m_Token, m_State, m_Created, m_Delivered;
protected float m_Deadline, m_NextTick;
protected bool m_Finished;
bool Finished() { return m_Finished;
}
void ERR_AirAmmoMission(ERR_HQ hq, ERR_GameMode mode, ERR_ReconFlightComponent flight, ERR_HQUnit recipient, vector reported, int message, int trip, array<IEntity> submitted)
{
m_PreviouslySubmitted = submitted;
m_HQ = hq;
m_Mode = mode;
m_Flight = flight;
m_Unit = recipient;
m_Point = reported;
m_Message = message;
m_Token = 200000 + trip;
m_Deadline = GetGame().GetWorld().GetWorldTime() + 600000;
if (!m_HQ.ReserveAirAmmo(recipient, m_Token)) { m_Finished = true;
return;
}
array<AIAgent> agents = {};
recipient.Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) m_Members.Insert(agent.GetControlledEntity());
bool clear;
for (int i = 0; i < 12; i++)
{
float angle = i * Math.PI / 6;
if (ERR_ReconAirports.FindClearPad(reported + Vector(Math.Sin(angle), 0, Math.Cos(angle)) * 80, m_Pad) && vector.DistanceXZ(reported, m_Pad) <= 200) { clear = true;
break;
}
}
if (!clear || m_Members.IsEmpty()) { Finish("no safe landing area near delivered request");
return;
}
m_Order = hq.Downlink(flight.Crew(), "AIR AMMUNITION", "load twelve magazines and fly to reported unit position " + reported.ToString(), message);
m_RecipientOrder = hq.Downlink(recipient.Group, "AMMUNITION RENDEZVOUS", "meet supply helicopter at " + m_Pad.ToString(), message);
if (!m_Order || !m_RecipientOrder) Finish("physical radio downlink unavailable");
}
protected ResourceName Magazine(IEntity actor)
{
if (!actor) return "";
BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(actor.FindComponent(BaseWeaponManagerComponent));
if (!manager) return "";
array<BaseWeaponComponent> weapons = {};
manager.GetWeapons(weapons);
foreach (BaseWeaponComponent weapon : weapons)
{
if (!weapon || (weapon.GetWeaponType() != EWeaponType.WT_RIFLE && weapon.GetWeaponType() != EWeaponType.WT_MACHINEGUN && weapon.GetWeaponType() != EWeaponType.WT_SNIPERRIFLE)) continue;
array<BaseMuzzleComponent> muzzles = {};
weapon.GetMuzzlesList(muzzles);
foreach (BaseMuzzleComponent muzzle : muzzles)
{
BaseMagazineComponent magazine = muzzle.GetMagazine();
if (magazine && magazine.GetOwner().GetPrefabData()) return magazine.GetOwner().GetPrefabData().GetPrefabName();
}
}
return "";
}
protected bool Owned()
{
return m_Unit && m_Unit.Group && m_Unit.Group.GetPlayerCount(true) == 0 && m_Unit.Group.GetFaction() == m_Mode.EnemyFaction()
&& m_HQ.LogisticsToken(m_Unit.Group) == m_Token && m_Flight && !m_Flight.Lost() && m_Flight.Phase() != ERR_ReconPhase.RELEASED;
}
protected void Finish(string reason)
{
if (m_Finished) return;
m_Finished = true;
if (m_Move) m_Move.Stop();
if (m_Waypoint) { if (m_Unit && m_Unit.Group) m_Unit.Group.RemoveWaypoint(m_Waypoint);
ERR_Actors.DeleteSpawned(m_Waypoint);
m_Waypoint = null;
}
if (m_Unit) m_HQ.ReleaseLogistics(m_Unit.Group, m_Token);
if (m_Unit && m_Unit.Group)
{
array<AIAgent> releasedAgents = {};
m_Unit.Group.GetAgents(releasedAgents);
foreach (AIAgent releasedAgent : releasedAgents) if (releasedAgent) releasedAgent.SetPermanentLOD(-1);
}
if (m_Flight) m_Flight.ReturnHome();
ERR_HQ.Event("AIR AMMUNITION", string.Format("delivered=%1; %2; remaining original cargo retained", m_Delivered, reason));
}
void Tick(float now)
{
if (m_Finished || now < m_NextTick) return;
m_NextTick = now + 1000;
if (!m_Mode.Active() || !Owned() || now >= m_Deadline) { Finish("mission expired or ownership lost");
return;
}
if (m_HQ.AirAssault().MortarsBusy(m_Pad, now)) { Finish("mortar fire conflicts with landing corridor");
return;
}
if (m_State == 0)
{
int aircraft = 1, recipient = 1;
if (m_Order) aircraft = m_Order.Advance(now, m_HQ.Journal());
if (m_RecipientOrder) recipient = m_RecipientOrder.Advance(now, m_HQ.Journal());
if (aircraft == 1) m_Order = null;
if (recipient == 1) m_RecipientOrder = null;
if (aircraft < 0 || recipient < 0) { Finish("radio order interrupted");
return;
}
if (aircraft == 0 || recipient == 0) return;
m_Unit.ReleaseAssignment();
m_State = 1;
}
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_Flight.Aircraft().FindComponent(InventoryStorageManagerComponent));
if (!inventory) { Finish("aircraft cargo storage unavailable");
return;
}
if (m_State == 1)
{
if (!m_Flight.AtAirport()) { Finish("loading requires airport");
return;
}
if (m_Created < 12)
{
ResourceName prefab = Magazine(m_Members[m_Created % m_Members.Count()]);
m_Created++;
if (prefab.IsEmpty()) return;
ERR_ConvoyCargoCallback callback = new ERR_ConvoyCargoCallback();
m_Loads.Insert(callback);
if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, callback)) callback.Finished = true;
return;
}
foreach (ERR_ConvoyCargoCallback operation : m_Loads) if (!operation.Finished) return;
array<IEntity> items = {};
ERR_Inventory.Items(inventory, items);
foreach (IEntity item : items) if (item && item.FindComponent(BaseMagazineComponent) && !m_Cargo.Contains(item) && !m_PreviouslySubmitted.Contains(item)) m_Cargo.Insert(item);
if (m_Cargo.IsEmpty()) { Finish("no physical cargo loaded");
return;
}
m_Flight.LaunchDelivery(m_Pad);
m_State = 2;
ERR_HQ.Event("AIR AMMUNITION DEPARTURE", "native cargo aboard; destination from delivered unit request");
return;
}
if (m_State == 2)
{
if (m_Flight.Phase() != ERR_ReconPhase.PARKED || vector.DistanceXZ(m_Flight.Aircraft().GetOrigin(), m_Pad) > 10) return;
array<vector> anchors = {};
if (!ERR_InfantryAmmoAccess.Points(m_Flight.Aircraft(), anchors)) { Finish("no authored aircraft cargo access");
return;
}
vector accessPoint = anchors[0];
vector outward = accessPoint - m_Flight.Aircraft().GetOrigin();
outward[1] = 0;
if (outward.Length() < 0.1) outward = "1 0 0";
accessPoint = ERR_Actors.Ground(accessPoint + outward.Normalized() * 2);
m_Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), accessPoint));
if (!m_Waypoint) { Finish("rendezvous waypoint unavailable");
return;
}
m_Waypoint.SetCompletionRadius(0.5);
m_Unit.Group.AddWaypointAt(m_Waypoint, 0);
m_State = 3;
}
if (m_State == 3)
{
if (now >= m_NextDiagnostic)
{
m_NextDiagnostic = now + 15000;
float closest = 1e20;
foreach (IEntity approaching : m_Members)
if (ERR_Actors.CanFight(approaching)) closest = Math.Min(closest, vector.Distance(approaching.GetOrigin(), m_Flight.Aircraft().GetOrigin()));
ERR_HQ.Event("AIR AMMUNITION RENDEZVOUS", string.Format("nearest original recipient=%1m; confirmed magazines=%2", closest, m_Delivered));
}
SCR_AIGroupUtilityComponent utility = m_Unit.Group.GetGroupUtilityComponent();
if (utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty()) { Finish("recipient engaged in combat; delivery aborted"); return; }
m_Unit.Group.ActivateAI();
array<AIAgent> receivingAgents = {};
m_Unit.Group.GetAgents(receivingAgents);
foreach (AIAgent receivingAgent : receivingAgents)
{
receivingAgent.SetPermanentLOD(0);
receivingAgent.ActivateAI();
}
}
if (m_Move)
{
int result = m_Move.Tick(now);
if (result == 0) return;
if (result > 0) m_Delivered++;
m_Move = null;
if (m_Delivered >= 12) { Finish("delivery complete");
return;
}
}
foreach (IEntity member : m_Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != m_Unit.Group || vector.Distance(member.GetOrigin(), m_Flight.Aircraft().GetOrigin()) > 15) continue;
foreach (IEntity magazine : m_Cargo)
{
if (!magazine || m_Spent.Contains(magazine) || !ERR_InfantryAmmoTransfer.Compatible(member, magazine)) continue;
m_Move = new ERR_InfantryAmmoTransfer(m_Mode, m_Flight.Aircraft(), m_Flight.Crew(), member, m_Unit.Group, magazine);
m_Move.Tick(now);
if (m_Move.Submitted()) { m_Spent.Insert(magazine);
m_PreviouslySubmitted.Insert(magazine);
m_Operations.Insert(m_Move);
return;
}
if (now >= m_NextDiagnostic)
{
m_NextDiagnostic = now + 15000;
ERR_HQ.Event("AIR AMMUNITION WAIT", m_Move.Reason());
}
m_Move = null;
}
}
if (m_Spent.Count() >= m_Cargo.Count()) Finish("all original cargo operations completed");
}
bool BlocksMortar(vector point) { return !m_Finished && vector.DistanceXZ(point, m_Pad) < 400;
}
void Stop() { Finish("mission stopped");
}
}
@@ -0,0 +1,350 @@
class ERR_AirPassenger
{
IEntity Actor;
BaseCompartmentSlot Seat;
bool Locked = true;
bool Jumped;
ERR_AssaultParachute Chute;
}
class ERR_AirAssaultTeam
{
SCR_AIGroup Group;
ref array<ref ERR_AirPassenger> Members = {};
ref ERR_AirDropOrder Order;
bool Released;
void Landed() {} // Canopy completion is observed by the owning load for the entire original team.
void Update()
{
if (Released || !Group || !Order || Group.GetPlayerCount(true) > 0) return;
bool survivor;
foreach (ERR_AirPassenger member : Members)
{
if (!ERR_Actors.Alive(member.Actor)) continue;
if (!member.Jumped || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Actor.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(member.Actor.GetOrigin()) > 3) return;
survivor = survivor || ERR_Actors.CanFight(member.Actor);
}
if (!survivor) return;
Released = true;
Group.ActivateAI();
Group.SetPermanentLOD(-1);
ERR_HQ.Get().LandAirTeam(Group, Order);
}
}
class ERR_AirDropGeometry
{
static float Along(vector position, vector center, vector direction)
{ vector offset = position - center;
offset[1] = 0;
return vector.Dot(offset, direction);
}
static bool Corridor(vector position, vector center, vector direction, vector velocity)
{
float along = Along(position, center, direction);
vector lateral = position - center - direction * along;
lateral[1] = 0;
velocity[1] = 0;
return along >= -175 && along <= 250 && lateral.Length() <= 45 && velocity.Length() >= 15 && vector.Dot(velocity.Normalized(), direction) >= 0.8;
}
static bool LandingLine(vector center, vector direction)
{
for (int step = -4; step <= 5; step++)
{
vector point = ERR_Actors.Ground(center + direction * (step * 50));
if (point[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !ERR_HQRoadIntercept.Standable(point)) return false;
TraceBox trace = new TraceBox();
trace.Start = point + "0 3 0";
trace.End = point + "0 190 0";
trace.Mins = "-4 -1 -4";
trace.Maxs = "4 1 4";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false;
}
return true;
}
}
// Eight original passengers, two original teams, one consumed load per aircraft.
class ERR_AirAssaultLoad
{
static const ResourceName PARACHUTE = "{C31F210ABE0781A0}Prefabs/ERR/AssaultParachute.et";
protected ERR_GameMode m_Mode;
protected ERR_ReconFlightComponent m_Flight;
protected Vehicle m_Aircraft;
protected int m_Serial, m_NextPassenger;
protected ref array<BaseCompartmentSlot> m_Seats = {};
protected ref array<ref ERR_AirPassenger> m_Passengers = {};
protected ref array<ref ERR_AirAssaultTeam> m_Teams = {};
protected ref ERR_AirDropOrder m_Order;
protected float m_CoverUntil;
bool Cover(float now, out vector center) { if (!m_Order || !m_Used || m_Approaching || now >= m_CoverUntil) return false;
center = m_Order.Target;
return true;
}
bool NeedsService(float now) { return m_Used && !m_Approaching && now >= m_CoverUntil;
}
protected float m_LoadDeadline, m_NextBoard, m_NextJump, m_AirspaceUntil;
protected bool m_Ready, m_Failed, m_Stopped, m_Used, m_Approaching, m_RunCommitted;
protected vector m_Center, m_Direction, m_RunStart, m_RunEnd;
static void SetLock(IEntity actor, bool locked)
{
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.AirAssault()) hq.AirAssault().Lock(actor, locked);
}
void ReuseRemaining()
{
m_Used = false;
m_Order = null;
m_CoverUntil = 0;
foreach (ERR_AirPassenger member : m_Passengers)
if (member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) { member.Jumped = false;
member.Locked = true;
}
}
int Count() { return m_Passengers.Count();
}
ERR_AirPassenger At(int index) { return m_Passengers[index];
}
bool Used() { return m_Used;
}
bool Preparing() { return !m_Ready && !m_Failed && !m_Stopped;
}
bool Ready()
{
return m_Ready && !m_Failed && !m_Stopped && !m_Used && m_Flight && !m_Flight.Lost()
&& (m_Flight.Phase() == ERR_ReconPhase.SEARCH || m_Flight.Phase() == ERR_ReconPhase.SHADOW) && Aboard() > 0;
}
void ERR_AirAssaultLoad(ERR_GameMode mode, ERR_ReconFlightComponent flight, int serial)
{ m_Mode = mode;
m_Flight = flight;
m_Aircraft = flight.Aircraft();
m_Serial = serial;
}
protected void Seats(IEntity entity)
{
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(entity.FindComponent(BaseCompartmentManagerComponent));
if (manager)
{
array<BaseCompartmentSlot> seats = {};
manager.GetCompartments(seats);
foreach (BaseCompartmentSlot seat : seats)
if (seat && seat.GetType() == ECompartmentType.CARGO && seat.IsCompartmentAccessible() && !seat.IsOccupied() && !m_Seats.Contains(seat)) m_Seats.Insert(seat);
}
IEntity child = entity.GetChildren();
while (child) { Seats(child);
child = child.GetSibling();
}
}
protected void Failure(string reason)
{
m_Failed = true;
m_Approaching = false;
foreach (ERR_AirPassenger member : m_Passengers) member.Locked = false;
ERR_HQ.Event("AIR LOAD UNAVAILABLE", reason + "; original partial entities retained, reconnaissance remains available");
}
void Load(vector pad)
{
m_LoadDeadline = GetGame().GetWorld().GetWorldTime() + 30000;
Seats(m_Aircraft);
ResourceName soldier = m_Mode.FactionSelection().Soldier();
Resource chute = Resource.Load(PARACHUTE);
if (m_Seats.Count() < 8 || soldier.IsEmpty() || !chute || !chute.IsValid()) { Failure("eight usable cargo seats, soldier or canopy unavailable");
return;
}
for (int t = 0; t < 2; t++)
{
ERR_AirAssaultTeam team = new ERR_AirAssaultTeam();
team.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{C31F210ABE078195}Prefabs/ERR/ReconCrew.et", pad));
m_Teams.Insert(team);
if (!team.Group) { Failure("passenger group spawn failed");
return;
}
m_Mode.Own(team.Group);
team.Group.SetFaction(m_Mode.EnemyFaction());
team.Group.SetPermanentLOD(0);
}
for (int i = 0; i < 8; i++)
{
ERR_AirPassenger member = new ERR_AirPassenger();
member.Seat = m_Seats[i];
AIGroup group = m_Teams[i % 2].Group;
member.Actor = member.Seat.SpawnCharacterInCompartment(soldier, group, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et");
m_Passengers.Insert(member);
m_Teams[i % 2].Members.Insert(member);
if (!member.Actor || group != m_Teams[i % 2].Group) { Failure("native passenger creation or group identity failed");
return;
}
}
}
protected void Boarding(float now)
{
foreach (ERR_AirAssaultTeam loadingTeam : m_Teams)
{
if (!loadingTeam.Group) continue;
loadingTeam.Group.ActivateAI();
array<AIAgent> loadingAgents = {};
loadingTeam.Group.GetAgents(loadingAgents);
foreach (AIAgent loadingAgent : loadingAgents) { loadingAgent.SetPermanentLOD(0);
loadingAgent.ActivateAI();
}
}
if (now >= m_LoadDeadline) { Failure(string.Format("native passenger boarding timed out; seated=%1/%2", Aboard(), Count()));
return;
}
bool all = m_Passengers.Count() == 8;
foreach (ERR_AirPassenger member : m_Passengers)
{
if (!ERR_Actors.CanFight(member.Actor)) { all = false;
continue;
}
if (CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft && member.Seat.GetOccupant() == member.Actor) continue;
all = false;
if (now < m_NextBoard || CompartmentAccessComponent.GetVehicleIn(member.Actor) || member.Seat.IsOccupied()) continue;
SCR_CompartmentAccessComponent access = SCR_CompartmentAccessComponent.Cast(member.Actor.FindComponent(SCR_CompartmentAccessComponent));
if (access) access.MoveInVehicle(m_Aircraft, ECompartmentType.CARGO, false, member.Seat);
}
if (now >= m_NextBoard) m_NextBoard = now + 1000;
if (!all) return;
for (int t = 0; t < m_Teams.Count(); t++)
{
ERR_AirAssaultTeam team = m_Teams[t];
foreach (ERR_AirPassenger original : team.Members)
if (ERR_Actors.Group(original.Actor) != team.Group || !ERR_Actors.SetFaction(original.Actor, m_Mode.EnemyFaction())) { Failure("original passenger faction/group changed");
return;
}
ERR_ForceRecord record = m_Mode.Forces().Register(string.Format("airborne-%1-team-%2", m_Serial, t), ERR_ForceKind.PATROL, m_Aircraft.GetOrigin(), 4, null, team.Group, now);
if (!record) { Failure("finite passenger ledger refused registration");
return;
}
record.Pinned = true;
m_Mode.RegisterGroup(team.Group);
ERR_HQ.Get().ReserveGroup(team.Group);
team.Group.ActivateAI();
array<AIAgent> agents = {};
team.Group.GetAgents(agents);
foreach (AIAgent agent : agents) { agent.SetPermanentLOD(0);
agent.ActivateAI();
}
}
m_Ready = true;
ERR_HQ.Event("AIR LOAD READY", "eight original passengers seated in two four-person teams");
}
int Aboard()
{
if (!m_Aircraft) return 0;
int count;
foreach (ERR_AirPassenger member : m_Passengers)
if (ERR_Actors.CanFight(member.Actor) && !ERR_Actors.Player(member.Actor) && CompartmentAccessComponent.GetVehicleIn(member.Actor) == m_Aircraft) count++;
return count;
}
bool HoldsSeat(IEntity actor)
{
if (m_Stopped || m_Failed || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor)) return false;
foreach (ERR_AirAssaultTeam team : m_Teams)
foreach (ERR_AirPassenger member : team.Members)
if (member.Actor == actor && member.Locked && ERR_Actors.Group(actor) == team.Group)
return (m_Flight && m_Flight.Phase() < ERR_ReconPhase.PARKED && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft) || (member.Chute && CompartmentAccessComponent.GetVehicleIn(actor) == member.Chute);
return false;
}
void Lock(IEntity actor, bool locked)
{ foreach (ERR_AirPassenger member : m_Passengers) if (member.Actor == actor) member.Locked = locked;
}
bool Begin(ERR_AirDropOrder order, float now)
{
if (!Ready() || !order || now >= order.ExpiresAt) return false;
vector radial = m_Aircraft.GetOrigin() - order.Target;
radial[1] = 0;
float heading = Math.Atan2(radial[0], radial[2]);
bool found;
for (int attempt = 0; attempt < 12; attempt++)
{
float angle = heading + attempt * Math.PI / 6;
radial = Vector(Math.Sin(angle), 0, Math.Cos(angle));
m_Center = ERR_Actors.Ground(order.Target + radial * 450);
m_Direction = Vector(-radial[2], 0, radial[0]);
if (ERR_AirDropGeometry.LandingLine(m_Center, m_Direction)) { found = true;
break;
}
}
if (!found) return false;
m_RunStart = m_Center - m_Direction * 350;
m_RunEnd = m_Center + m_Direction * 450;
m_CoverUntil = now + 300000;
m_Order = order;
m_Used = true;
m_Approaching = true;
m_RunCommitted = false;
m_NextPassenger = 0;
m_NextJump = now;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order;
m_AirspaceUntil = order.ExpiresAt + 150000;
return true;
}
bool Approach(out vector point)
{
point = m_RunStart;
if (m_RunCommitted) point = m_RunEnd;
return m_Approaching && !m_Stopped;
}
void Abort()
{ if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000;
m_Approaching = false;
m_RunCommitted = false;
}
bool BlocksMortar(vector impact, float now)
{
if (!m_Order || vector.DistanceXZ(impact, m_Order.Target) >= 1200) return false;
foreach (ERR_AirPassenger member : m_Passengers)
if (member.Chute && member.Actor && CompartmentAccessComponent.GetVehicleIn(member.Actor) == member.Chute) return true;
return now < m_AirspaceUntil;
}
void Update(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active()) return;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Update();
if (Preparing()) { Boarding(now);
return;
}
if (!m_Approaching) return;
if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_Order.ExpiresAt)
{ Abort();
ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created");
return;
}
foreach (ERR_AirAssaultTeam originalTeam : m_Teams)
if (!originalTeam.Group || originalTeam.Group.GetPlayerCount(true) > 0 || originalTeam.Group.GetFaction() != m_Mode.EnemyFaction()) { Abort();
return;
}
vector position = m_Aircraft.GetOrigin();
if (!m_RunCommitted && vector.DistanceXZ(position, m_RunStart) <= 80) m_RunCommitted = true;
if (m_RunCommitted && ERR_AirDropGeometry.Along(position, m_Center, m_Direction) > 300) { Abort();
return;
}
float height = position[1] - SCR_TerrainHelper.GetTerrainY(position);
if (!m_RunCommitted || now < m_NextJump || height < 90 || height > 180 || !m_Aircraft.GetPhysics()
|| !ERR_AirDropGeometry.Corridor(position, m_Center, m_Direction, m_Aircraft.GetPhysics().GetVelocity())) return;
while (m_NextPassenger < m_Passengers.Count())
{
int index = m_NextPassenger++;
ERR_AirPassenger passenger = m_Passengers[index];
if (passenger.Jumped || !ERR_Actors.CanFight(passenger.Actor) || ERR_Actors.Player(passenger.Actor) || CompartmentAccessComponent.GetVehicleIn(passenger.Actor) != m_Aircraft) continue;
vector exit = m_Aircraft.CoordToParent("12 -4 0");
ERR_AssaultParachute chute = ERR_AssaultParachute.Cast(ERR_Actors.Spawn(PARACHUTE, exit));
m_NextJump = now + 2000;
if (!chute) { ERR_HQ.Event("AIR DROP FAILED", "canopy creation refused; original passenger retained");
continue;
}
m_Mode.Own(chute);
passenger.Chute = chute;
vector landing = m_Center + m_Direction * ERR_AirDropGeometry.Along(position, m_Center, m_Direction);
passenger.Jumped = chute.BeginJump(passenger.Actor, m_Teams[index % 2], landing);
ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped));
break;
}
if (m_NextPassenger >= m_Passengers.Count()) Abort();
}
void Stop()
{
m_Stopped = true;
Abort();
foreach (ERR_AirPassenger passenger : m_Passengers) passenger.Locked = false;
}
}
@@ -0,0 +1,223 @@
class ERR_AirAssaultSystem
{
protected ERR_HQ m_HQ;
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_AirAssaultLoad> m_Loads = {};
protected ref ERR_AirSupportPolicy m_Policy = new ERR_AirSupportPolicy();
protected ref ERR_HQRoute m_Route;
protected ref ERR_AirDropOrder m_Order;
protected ERR_AirAssaultLoad m_OrderedLoad;
protected float m_NextDecision;
protected bool m_Stopped;
protected string m_LoggedReason;
void ERR_AirAssaultSystem(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq;
m_Mode = mode;
}
string Status() { return "Airborne support: " + m_Policy.Reason + string.Format("; finite loads %1/4", m_Loads.Count());
}
ERR_AirAssaultLoad Load(ERR_ReconFlightComponent flight, vector pad, int serial)
{
if (!Replication.IsServer() || m_Stopped || !m_Mode.Active() || m_Loads.Count() >= 4 || m_Mode.Forces().Count() > 253) return null;
ERR_AirAssaultLoad load = new ERR_AirAssaultLoad(m_Mode, flight, m_Loads.Count() + 1);
m_Loads.Insert(load);
// Consume the finite load before any native spawn; never refund partial failures.
load.Load(pad);
return load;
}
bool HoldsSeat(IEntity actor)
{
if (m_Stopped || !m_Mode.Active()) return false;
foreach (ERR_AirAssaultLoad load : m_Loads) if (load.HoldsSeat(actor)) return true;
return false;
}
void Lock(IEntity actor, bool locked)
{
foreach (ERR_AirAssaultLoad load : m_Loads) load.Lock(actor, locked);
}
bool MortarsBusy(vector point, float now)
{
array<ref ERR_MortarBattery> batteries = {};
m_HQ.Mortars().Batteries(batteries);
foreach (ERR_MortarBattery battery : batteries)
{
if (now < battery.AirspaceUntil && vector.DistanceXZ(point, battery.AirspaceImpact) < battery.AirspaceRadius + 900) return true;
ERR_HQReport report = battery.Report;
if (battery.PendingRadioReport) report = battery.PendingRadioReport;
if ((battery.Activity || battery.RadioOrder || battery.CommandOpen || battery.Released) && report && vector.DistanceXZ(point, report.Position) < 1200) return true;
}
return false;
}
bool BlocksMortar(vector impact, float now)
{
if (m_Stopped) return false;
if (m_Order && vector.DistanceXZ(impact, m_Order.Target) < 1200) return true;
foreach (ERR_AirAssaultLoad load : m_Loads) if (load.BlocksMortar(impact, now)) return true;
return false;
}
void Maintain(float now)
{
if (!Replication.IsServer() || m_Stopped || !m_Mode.Active()) return;
foreach (ERR_AirAssaultLoad load : m_Loads) load.Update(now);
}
protected void CancelOrder(float now, string reason)
{
m_Route = null;
m_Order = null;
m_OrderedLoad = null;
m_Policy.Retry(now);
m_Policy.Reason = reason;
ERR_HQ.Event("AIR DROP WITHHELD", reason);
}
void Tick(float now)
{
if (!Replication.IsServer() || m_Stopped || !m_Mode.Active() || m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return;
if (m_Route)
{
if (!m_OrderedLoad || !m_OrderedLoad.Ready() || now >= m_Order.ExpiresAt) { CancelOrder(now, "order expired or original passenger load unavailable");
return;
}
int delivery = m_Route.Advance(now, m_HQ.Journal());
if (delivery < 0) { CancelOrder(now, "helicopter downlink interrupted; troops retained");
return;
}
if (delivery == 0) return;
ERR_HQBelief belief = m_HQ.Memory().GetById(m_Order.BeliefId);
ERR_AirSupportSituation situation;
if (belief) situation = m_HQ.AirSituation(belief, m_OrderedLoad, now);
if (!belief || !ERR_AirSupportPolicy.Refusal(situation).IsEmpty() || vector.DistanceXZ(belief.PredictedPosition, m_Order.Target) > 150)
{ CancelOrder(now, "memory, ground response or airspace changed during radio delivery");
return;
}
if (!m_OrderedLoad.Begin(m_Order, now)) { CancelOrder(now, "no clear drop corridor; continue reconnaissance");
return;
}
m_Policy.Committed(now);
m_Policy.Reason = "HQ-authorized drop underway";
ERR_HQ.Event("AIR DROP AUTHORIZED", string.Format("belief=%1 report=M%2 target=%3; immutable delivered order", m_Order.BeliefId, m_Order.MessageId, m_Order.Target));
m_Route = null;
m_Order = null;
m_OrderedLoad = null;
return;
}
if (now < m_NextDecision) return;
m_NextDecision = now + 5000;
ERR_ReconFlightComponent flight = m_HQ.Recon().Current();
ERR_AirAssaultLoad load;
if (flight) load = flight.Assault();
ERR_HQBelief best;
ERR_AirSupportSituation bestSituation;
float score = -1;
for (int index = 0; index < m_HQ.Memory().Count(); index++)
{
ERR_HQBelief candidate = m_HQ.Memory().At(index);
if (!candidate || candidate.Kind != ERR_HQMemoryKind.SIGHTING) continue;
ERR_AirSupportSituation candidateSituation = m_HQ.AirSituation(candidate, load, now);
float value = candidate.Confidence / (1 + candidate.Radius / 500);
if (!ERR_AirSupportPolicy.Refusal(candidateSituation).IsEmpty()) value -= 10;
if (value <= score && best) continue;
score = value;
best = candidate;
bestSituation = candidateSituation;
}
bool approve = m_Policy.Consider(bestSituation, now);
if (m_Mode.Settings().Runtime("supportSquadLimit") <= 0) { approve = false;
m_Policy.Reason = "HQ support disabled";
}
if (m_LoggedReason != m_Policy.Reason) { m_LoggedReason = m_Policy.Reason;
ERR_HQ.Event("AIR DROP DECISION", Status());
}
if (!approve || !best || !load) return;
ERR_HQRoute route = m_HQ.Downlink(flight.Crew(), "AIR DROP", m_Policy.Reason, best.MessageId);
if (!route) { m_Policy.Reason = "no physical radio downlink to helicopter";
return;
}
m_Route = route;
m_OrderedLoad = load;
m_Order = new ERR_AirDropOrder();
m_Order.BeliefId = best.BeliefId;
m_Order.BeliefRevision = best.BeliefRevision;
m_Order.MessageId = best.MessageId;
m_Order.Target = best.PredictedPosition;
m_Order.ExpiresAt = now + 120000;
m_Order.Report = best.Report.Snapshot();
}
void Stop()
{
m_Stopped = true;
m_Route = null;
m_Order = null;
foreach (ERR_AirAssaultLoad load : m_Loads) load.Stop();
}
}
modded class ERR_HQ
{
ERR_AirSupportSituation AirSituation(ERR_HQBelief belief, ERR_AirAssaultLoad load, float now)
{
ERR_AirSupportSituation result = new ERR_AirSupportSituation();
result.AircraftReady = load && load.Ready();
if (!belief) return result;
result.BeliefId = belief.BeliefId;
result.Samples = belief.SampleCount;
result.Age = (now - belief.ObservedAt) / 1000;
result.Confidence = belief.Confidence;
result.Radius = belief.Radius;
result.MotionKnown = belief.HasVelocity;
result.Speed = belief.Speed;
result.Infantry = belief.TravelMode == "on foot";
if (belief.Report) result.Spotted = belief.Report.SeenEscapists;
result.ArtilleryBusy = m_AirAssault.MortarsBusy(belief.PredictedPosition, now);
foreach (ERR_HQUnit unit : m_Units)
{
// HQ knows friendly positions/strength from delivered check-ins, never live locations.
ERR_HQWatchEntry status = m_Watch.Find(unit.ForceSourceId);
if (!status || status.ObservedAt < 0 || now < status.ObservedAt || now - status.ObservedAt > 180000) continue;
if (vector.DistanceXZ(status.Position, belief.PredictedPosition) < 1000) result.ConfirmedLosses += status.ConfirmedLosses;
if (status.Closed || status.Fit <= 0 || unit.ReservedForSupport || unit.HomeGuard || unit.LogisticsReservation > 0 || m_Watch.ArmedSearchBlocked(unit.ForceSourceId, now)) continue;
ERR_HQTask task = unit.PlannedTask();
float distance = vector.DistanceXZ(status.Position, belief.PredictedPosition);
if (task)
{
if (task.BeliefId == belief.BeliefId && now < task.ExpiresAt && distance <= 900) result.CommittedFit += status.Fit;
}
else if (distance <= 600) result.GroundFit += status.Fit;
}
return result;
}
void LandAirTeam(SCR_AIGroup group, ERR_AirDropOrder order)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
unit.ReservedForSupport = false;
if (!order || !unit.Eligible(m_Mode)) return;
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(order.Target)));
if (!waypoint) return;
unit.Release();
unit.Waypoint = waypoint;
unit.Assignment = order.Report.Snapshot();
unit.Task = new ERR_HQTask();
unit.Task.BeliefId = order.BeliefId;
unit.Task.BeliefRevision = order.BeliefRevision;
unit.Task.Role = ERR_HQPursuitRole.SEARCH;
unit.Task.Destination = order.Target;
unit.Task.QueuedAt = GetGame().GetWorld().GetWorldTime();
unit.Task.ExpiresAt = unit.Task.QueuedAt + 120000;
group.AddWaypointAt(waypoint, 0);
ERR_HQ.Event("AIR TEAM LANDED", "original team searching carried HQ order at " + order.Target.ToString());
return;
}
}
}
modded class ERR_MortarSystem
{
override bool Safe(ERR_MortarBattery battery, vector impact, float flight)
{
if (m_HQ.AirAssault().BlocksMortar(impact, GetGame().GetWorld().GetWorldTime()) || m_HQ.Airfield().BlocksMortar(impact))
{ SafetyReason = "HQ airborne drop corridor or descending troops";
return false;
}
return super.Safe(battery, impact, flight);
}
}
@@ -0,0 +1,109 @@
// Owner's original search-helicopter launch/loss cadence, connected to this mission's HQ owner.
class ERR_ReconSchedule
{
float Deadline = -1;
int Minutes = 15;
bool Started;
bool Stopped;
void Begin(float now) { if (!Started && !Stopped) { Started = true; Deadline = now + Minutes * 60000.0; } }
bool Due(float now, bool active) { return Started && !Stopped && !active && Deadline >= 0 && now >= Deadline; }
void Launched() { Deadline = -1; }
void Lost(float now) { if (!Stopped) Deadline = now + Minutes * 60000.0; }
void Change(int minutes, float now, bool active) { Minutes = minutes; if (Started && !Stopped && !active) Deadline = now + Minutes * 60000.0; }
void Stop() { Stopped = true; Deadline = -1; }
}
class ERR_AirReconSystem
{
protected ERR_GameMode m_Mode;
protected ref ERR_ReconAirports m_Airports = new ERR_ReconAirports();
protected ref ERR_ReconSchedule m_Schedule = new ERR_ReconSchedule();
protected ref array<ERR_ReconFlightComponent> m_Flights = {};
protected ERR_ReconFlightComponent m_Current;
protected int m_Serial, m_PadAttempts;
protected float m_NextPad;
protected string m_Reason = "countdown starts after prison escape";
void ERR_AirReconSystem(ERR_GameMode mode) { m_Mode = mode; m_Schedule.Minutes = mode.Settings().Runtime("heliTimerMinutes"); }
bool SupportPad(out vector point)
{
foreach (vector airport : m_Airports.m_Positions)
if (ERR_ReconAirports.FindClearPad(airport, point)) return true;
return false;
}
ERR_ReconFlightComponent Current() { return m_Current; }
bool Owns(SCR_AIGroup group)
{
foreach (ERR_ReconFlightComponent flight : m_Flights) if (flight && flight.Crew() == group) return true;
return false;
}
bool SetDelay(int minutes)
{
if (!Replication.IsServer() || m_Schedule.Stopped || !m_Mode.Settings().Set("heliTimerMinutes", minutes)) return false;
m_Schedule.Change(minutes, GetGame().GetWorld().GetWorldTime(), m_Current != null);
if (!m_Current) m_PadAttempts = 0;
ERR_HQ.Event("HELICOPTER TIMER", Status());
return true;
}
void Return() { if (Replication.IsServer() && m_Current) m_Current.ReturnHome(); }
string Status()
{
string state = m_Reason;
if (m_Current) state = m_Current.Status();
else if (m_Schedule.Started && !m_Schedule.Stopped)
{
float due = m_Schedule.Deadline; if (m_PadAttempts > 0) due = m_NextPad;
state += string.Format("; next attempt in %1 seconds", Math.Ceil(Math.Max(0, due - GetGame().GetWorld().GetWorldTime()) / 1000));
}
return string.Format("Search helicopter: delay %1 minutes; %2", m_Schedule.Minutes, state);
}
void Tick(float now)
{
if (!Replication.IsServer() || m_Schedule.Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
m_Airports.Update();
if (m_Current)
{
m_Current.Update(now);
if (!m_Current.Lost()) return;
m_Reason = m_Current.Status(); m_Current.Release(false);
m_Current = null; m_Schedule.Lost(now); m_PadAttempts = 0;
ERR_HQ.Event("HELICOPTER LOST", Status());
return;
}
if (m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return;
if (!m_Schedule.Started) { m_Schedule.Begin(now); ERR_HQ.Event("HELICOPTER TIMER", Status()); }
if (m_Schedule.Due(now, false) && m_PadAttempts == 0)
{ m_PadAttempts = 3; m_NextPad = now; m_Schedule.Lost(now); }
if (m_PadAttempts == 0 || now < m_NextPad) return;
m_PadAttempts--; m_NextPad = now + 30000;
// All waves share the normal finite ledger cap. Never create an untracked crew.
if (m_Serial >= 64 || m_Mode.Forces().Count() >= 256) { m_Reason = "round aircraft/force bookkeeping capacity reached"; m_Schedule.Stop(); return; }
vector pad; bool found;
if (!m_Airports.m_Positions.IsEmpty())
{
int first = Math.RandomInt(0, m_Airports.m_Positions.Count());
for (int attempt = 0; attempt < m_Airports.m_Positions.Count(); attempt++)
if (ERR_ReconAirports.FindClearPad(m_Airports.m_Positions[(first + attempt) % m_Airports.m_Positions.Count()], pad)) { found = true; break; }
}
if (!found) { m_Reason = "no clear airfield departure pad; bounded retry pending"; ERR_HQ.Event("HELICOPTER PAD", Status()); return; }
m_PadAttempts = 0; m_Serial++;
Vehicle aircraft = Vehicle.Cast(ERR_Actors.Spawn("{C31F210ABE078192}Prefabs/ERR/ReconHelicopter.et", pad, Vector(Math.RandomFloat(0, 360), 0, 0)));
if (!aircraft) { m_Reason = "aircraft creation failed; next attempt after full delay"; return; }
m_Mode.Own(aircraft);
ERR_ReconFlightComponent flight = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent));
if (!flight) { m_Reason = "flight component missing; partial aircraft retained"; return; }
m_Current = flight; m_Flights.Insert(flight); m_Schedule.Launched();
flight.Initialize(m_Mode, pad, m_Serial);
ERR_HQ.Event("HELICOPTER LAUNCH", string.Format("wave=%1 airfield pad=%2", m_Serial, pad));
}
void Removed(ERR_ReconFlightComponent flight)
{
if (!Replication.IsServer() || m_Schedule.Stopped || !flight || m_Current != flight || !m_Mode || !m_Mode.Active()) return;
m_Current = null; m_PadAttempts = 0; m_Reason = "aircraft removed; replacement delayed";
m_Schedule.Lost(GetGame().GetWorld().GetWorldTime());
ERR_HQ.Event("HELICOPTER LOST", Status());
}
void Stop()
{
m_Schedule.Stop(); m_Reason = "mission stopped";
foreach (ERR_ReconFlightComponent flight : m_Flights) if (flight) flight.Release(false);
}
}
@@ -0,0 +1,79 @@
// Decisions consume detached HQ memory and delivered friendly check-ins only.
class ERR_AirSupportSituation
{
int BeliefId;
int Samples;
int Spotted;
int GroundFit;
int CommittedFit;
int ConfirmedLosses;
float Age;
float Confidence;
float Radius;
float Speed;
bool MotionKnown;
bool Infantry;
bool AircraftReady;
bool ArtilleryBusy;
}
class ERR_AirSupportPolicy
{
int Candidate;
float ConsideringSince = -1;
float NextDrop;
string Reason = "waiting for delivered intelligence";
static string Refusal(ERR_AirSupportSituation s)
{
if (!s || !s.AircraftReady) return "no ready helicopter passenger load";
if (!ERR_HQMemoryRules.Finite(s.Age) || !ERR_HQMemoryRules.Finite(s.Confidence) || !ERR_HQMemoryRules.Finite(s.Radius) || !ERR_HQMemoryRules.Finite(s.Speed)) return "invalid memory sample";
if (s.BeliefId <= 0 || s.Samples < 2 || s.Age < 0 || s.Age > 90 || s.Confidence < 0.7 || s.Confidence > 1 || s.Radius < 0 || s.Radius > 160) return "memory is too uncertain; continue reconnaissance";
if (!s.Infantry || !s.MotionKnown || s.Speed < 0 || s.Speed > 6) return "target movement does not justify an infantry drop";
int needed = Math.Clamp(s.Spotted * 2, 4, 12);
if (s.CommittedFit >= needed) return "ground forces already committed in sufficient strength";
if (s.GroundFit + s.CommittedFit >= needed && s.ConfirmedLosses < 2) return "nearby ground forces can handle this contact";
if (s.ArtilleryBusy) return "mortar order or shells occupy the approach area";
return "";
}
bool Consider(ERR_AirSupportSituation s, float now)
{
if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false;
string refusal = Refusal(s);
if (!refusal.IsEmpty()) { Candidate = 0;
ConsideringSince = -1;
Reason = refusal;
return false;
}
if (now < NextDrop) { Reason = "reinforcement cooldown; retain remaining troops";
return false;
}
if (Candidate != s.BeliefId || ConsideringSince < 0) { Candidate = s.BeliefId;
ConsideringSince = now;
}
float delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 20000;
if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall";
return false;
}
Reason = "persistent infantry contact exceeds available ground response";
return true;
}
void Committed(float now) { NextDrop = now + 480000;
Candidate = 0;
ConsideringSince = -1;
}
void Retry(float now) { NextDrop = Math.Max(NextDrop, now + 15000);
Candidate = 0;
ConsideringSince = -1;
}
}
// One immutable authorization; receiver revalidates memory and physical safety.
class ERR_AirDropOrder
{
int BeliefId;
int BeliefRevision;
int MessageId;
vector Target;
float ExpiresAt;
ref ERR_HQReport Report;
}
@@ -0,0 +1,90 @@
// Aircraft weapons are physical magazines. Only the landed airport service
// calls Service; the covering orbit consumes normal native weapon ammunition.
class ERR_AircraftAmmo
{
static void Magazines(IEntity entity, array<BaseMagazineComponent> magazines, bool gunsOnly = false)
{
if (!entity || ChimeraCharacter.Cast(entity))
return;
BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(entity.FindComponent(BaseWeaponManagerComponent));
array<BaseWeaponComponent> weapons = {};
if (manager)
manager.GetWeapons(weapons);
BaseWeaponComponent mounted = BaseWeaponComponent.Cast(entity.FindComponent(BaseWeaponComponent));
if (mounted && !weapons.Contains(mounted))
weapons.Insert(mounted);
foreach (BaseWeaponComponent weapon : weapons)
{
if (!weapon || (gunsOnly && weapon.GetWeaponType() != EWeaponType.WT_MACHINEGUN && weapon.GetWeaponType() != EWeaponType.WT_AUTOCANNON))
continue;
array<BaseMuzzleComponent> muzzles = {};
weapon.GetMuzzlesList(muzzles);
foreach (BaseMuzzleComponent muzzle : muzzles)
{
BaseMagazineComponent magazine = muzzle.GetMagazine();
if (magazine && !magazines.Contains(magazine))
magazines.Insert(magazine);
}
}
IEntity child = entity.GetChildren();
while (child)
{
Magazines(child, magazines, gunsOnly);
child = child.GetSibling();
}
}
static bool HasUsableGun(IEntity aircraft)
{
array<BaseMagazineComponent> magazines = {};
Magazines(aircraft, magazines, true);
foreach (BaseMagazineComponent magazine : magazines)
{
if (magazine.GetAmmoCount() > magazine.GetMaxAmmoCount() * 0.25)
return true;
}
return false;
}
// Native rocket pods hold projectile entities rather than magazines.
static int ServicePods(IEntity entity, int available)
{
if (!Replication.IsServer() || !entity || available <= 0 || ChimeraCharacter.Cast(entity)) return 0;
int consumed;
SCR_RocketEjectorMuzzleComponent pod = SCR_RocketEjectorMuzzleComponent.Cast(entity.FindComponent(SCR_RocketEjectorMuzzleComponent));
if (pod && !pod.GetDefaultRocketPrefab().IsEmpty())
{
for (int barrel = 0; barrel < pod.GetBarrelsCount() && consumed < available; barrel++)
{
if (!pod.CanReloadBarrel(barrel)) continue;
consumed++;
IEntity rocket = ERR_Actors.Spawn(pod.GetDefaultRocketPrefab(), entity.GetOrigin());
if (!rocket) continue;
pod.ReloadBarrel(barrel, rocket);
if (pod.GetBarrelProjectile(barrel) != rocket && !rocket.GetParent()) ERR_Actors.DeleteSpawned(rocket);
}
}
IEntity child = entity.GetChildren();
while (child && consumed < available)
{
consumed += ServicePods(child, available - consumed);
child = child.GetSibling();
}
return consumed;
}
static int Service(IEntity aircraft, int available)
{
if (!Replication.IsServer() || available <= 0) return 0;
array<BaseMagazineComponent> magazines = {};
Magazines(aircraft, magazines);
int used;
foreach (BaseMagazineComponent magazine : magazines)
{
int moved = Math.Min(available - used, Math.Max(0, magazine.GetMaxAmmoCount() - magazine.GetAmmoCount()));
magazine.SetAmmoCount(magazine.GetAmmoCount() + moved);
used += moved;
}
return used;
}
}
@@ -0,0 +1,196 @@
// One physical airport depot and a separate logistics airframe per round.
class ERR_AirfieldSupport
{
protected ERR_HQ m_HQ;
protected ERR_GameMode m_Mode;
protected IEntity m_Store, m_FuelTruck;
protected ERR_ReconFlightComponent m_Cargo;
protected ref ERR_HQRoute m_ReturnOrder;
protected ERR_ReconFlightComponent m_ReturnFlight;
protected ref ERR_AirAmmoMission m_Mission;
protected ref array<ref ERR_AirAmmoMission> m_RetiredMissions = {};
protected ref array<IEntity> m_SubmittedCargo = {};
protected float m_ServiceAt = -1, m_NextTick, m_NextAttempt, m_NextDepot;
protected bool m_Started, m_Stopped, m_Serviced;
protected int m_Rounds = 24000, m_Rockets = 512, m_Services, m_Trips;
protected float m_Fuel = 12000;
protected float m_CargoServiceAt = -1, m_NextCargo;
protected int m_CargoAttempts;
protected vector m_Airport;
protected bool m_CargoSpawned, m_CargoServiced;
protected ref map<string, int> m_Requests = new map<string, int>();
bool BlocksMortar(vector point) { return m_Mission && m_Mission.BlocksMortar(point);
}
ERR_ReconFlightComponent Cargo() { return m_Cargo;
}
IEntity Store() { return m_Store;
}
string Status() { return string.Format("Airport: %1 services; gun rounds=%2 fuel=%3; ammo trips=%4 rockets=%5", m_Services, m_Rounds, Math.Round(m_Fuel), m_Trips, m_Rockets);
}
void ERR_AirfieldSupport(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq;
m_Mode = mode;
}
protected IEntity Spawn(ResourceName prefab, vector point)
{ IEntity entity = ERR_Actors.Spawn(prefab, ERR_Actors.Ground(point));
if (entity) m_Mode.Own(entity);
return entity;
}
protected void Start(vector airport)
{
m_Started = true;
m_Airport = airport;
m_Store = Spawn("{EBBC859CCD94D96D}Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et", airport + "35 0 0");
m_FuelTruck = Spawn("{16C1F16C9B053801}Prefabs/Vehicles/Wheeled/Ural4320/Ural4320_transport.et", airport + "40 0 12");
if (m_FuelTruck) ERR_Actors.SetFaction(m_FuelTruck, m_Mode.EnemyFaction());
Spawn("{BE5D5CFA90224646}Prefabs/Structures/Military/Camps/TentUSSR_01/TentUSSR_01_base.et", airport + "35 0 8");
}
protected void SpawnCargo(float now)
{
if (m_CargoSpawned || m_CargoAttempts >= 3 || now < m_NextCargo || !DepotAvailable()) return;
m_CargoAttempts++; m_NextCargo = now + 30000;
vector pad;
if (!ERR_ReconAirports.FindClearPad(m_Airport, pad) || vector.DistanceXZ(pad, m_Airport) < 30 || m_Mode.Forces().Count() >= 254) return;
m_CargoSpawned = true;
Vehicle aircraft = Vehicle.Cast(Spawn("{C31F210ABE078192}Prefabs/ERR/ReconHelicopter.et", pad));
if (!aircraft) return;
m_Cargo = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent));
if (m_Cargo) m_Cargo.Initialize(m_Mode, pad, 10001, true);
ERR_HQ.Event("AIRFIELD SUPPORT", "physical service depot and separate ammunition helicopter initialized");
}
bool DepotAvailable()
{ return m_Store && m_FuelTruck && ERR_Actors.Alive(m_FuelTruck) && SCR_FactionManager.SGetFaction(m_FuelTruck) == m_Mode.EnemyFaction();
}
protected void Service(ERR_ReconFlightComponent flight, float now)
{
if (!flight.AtAirport()) { m_ServiceAt = -1;
m_Serviced = false;
return;
}
if (m_Serviced || !DepotAvailable() || flight.Lost() || vector.DistanceXZ(flight.Aircraft().GetOrigin(), m_Airport) > 500) return;
if (m_ServiceAt < 0) { m_ServiceAt = now;
return;
}
if (now - m_ServiceAt < 60000) return;
m_Serviced = true;
m_Services++;
Replenish(flight);
flight.ReloadPassengers();
ERR_HQ.Event("AIRFIELD SERVICE", Status() + "; original airframe and crew retained");
}
protected void Replenish(ERR_ReconFlightComponent flight)
{
m_Rounds -= ERR_AircraftAmmo.Service(flight.Aircraft(), m_Rounds);
m_Rockets -= ERR_AircraftAmmo.ServicePods(flight.Aircraft(), m_Rockets);
FuelManagerComponent fuel = FuelManagerComponent.Cast(flight.Aircraft().FindComponent(FuelManagerComponent));
if (fuel)
{
array<BaseFuelNode> nodes = {};
fuel.GetFuelNodesList(nodes);
foreach (BaseFuelNode node : nodes)
{ float amount = Math.Min(m_Fuel, node.GetMaxFuel() - node.GetFuel());
if (amount > 0) { node.SetFuel(node.GetFuel() + amount);
m_Fuel -= amount;
} }
}
}
protected bool CargoServiced(float now)
{
if (!m_Cargo || !m_Cargo.AtAirport()) { m_CargoServiceAt = -1; m_CargoServiced = false; return false; }
if (m_Trips == 0 || m_CargoServiced) return true;
if (!DepotAvailable()) return false;
if (m_CargoServiceAt < 0) m_CargoServiceAt = now;
if (now - m_CargoServiceAt < 60000) return false;
Replenish(m_Cargo); m_CargoServiced = true;
return true;
}
void Maintain(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active()) return;
if (m_Cargo) m_Cargo.Update(now);
if (m_Mission) m_Mission.Tick(now);
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_NextTick) return;
m_NextTick = now + 1000;
ERR_ReconFlightComponent recon = m_HQ.Recon().Current();
if (!m_Started && now >= m_NextDepot)
{
m_NextDepot = now + 30000;
vector airport;
if (m_HQ.Recon().SupportPad(airport)) Start(airport);
}
if (m_Started) SpawnCargo(now);
bool cargoReady = CargoServiced(now);
if (recon)
{
Service(recon, now);
if (m_Serviced && recon.AtAirport() && (!recon.Assault() || !recon.Assault().Preparing())) recon.RestartFlight();
if (m_ReturnOrder)
{
int delivered = m_ReturnOrder.Advance(now, m_HQ.Journal());
if (delivered != 0) { if (delivered == 1 && m_ReturnFlight == recon) recon.ReturnHome();
m_ReturnOrder = null;
m_ReturnFlight = null;
}
}
else if (recon.Assault() && recon.Assault().NeedsService(now) && (recon.Phase() == ERR_ReconPhase.SEARCH || recon.Phase() == ERR_ReconPhase.SHADOW))
{ m_ReturnOrder = m_HQ.Downlink(recon.Crew(), "AIRCRAFT RELOAD", "cover complete; return to airport for another squad and ammunition", 0);
m_ReturnFlight = recon;
}
}
if (!cargoReady || !m_Cargo || m_Cargo.Lost() || !m_Cargo.AtAirport() || !DepotAvailable() || now < m_NextAttempt || m_Trips >= 12) return;
if (m_Mission && (!m_Mission.Finished() || m_Mission.PendingNative())) return;
m_NextAttempt = now + 10000;
ERR_HQUnit recipient = m_HQ.AirAmmoRecipient(now, m_Requests);
if (!recipient) return;
ERR_HQWatchEntry demand = m_HQ.Watch().Find(recipient.ForceSourceId);
if (!demand) return;
m_Trips++;
m_Requests.Set(demand.SourceId, demand.MessageId);
if (m_Mission) m_RetiredMissions.Insert(m_Mission);
m_Mission = new ERR_AirAmmoMission(m_HQ, m_Mode, m_Cargo, recipient, demand.Position, demand.MessageId, m_Trips, m_SubmittedCargo);
}
void Stop()
{
m_Stopped = true;
m_ReturnOrder = null;
if (m_Mission) m_Mission.Stop();
if (m_Cargo) m_Cargo.Release(false);
}
}
modded class ERR_HQ
{
bool ReserveAirAmmo(ERR_HQUnit unit, int token)
{
if (!Replication.IsServer() || !MissionActive() || !unit || token <= 0 || !unit.Eligible(m_Mode) || unit.HomeGuard || !unit.Radio || unit.PendingOrder || unit.Downlink || Fighting(unit)) return false;
// Keep its delivered search task until the replacement order actually arrives.
unit.LogisticsReservation = token;
return true;
}
ERR_HQUnit AirAmmoRecipient(float now, map<string, int> served)
{
foreach (ERR_HQUnit unit : m_Units)
{
ERR_HQWatchEntry request = m_Watch.Find(unit.ForceSourceId);
if (!unit.Eligible(m_Mode) || unit.ReservedForSupport || unit.HomeGuard || unit.LogisticsReservation > 0 || unit.PendingOrder || unit.Downlink || Fighting(unit)) continue;
if (!ERR_InfantryResupplyRules.Need(request, now) || request.MessageId <= served.Get(request.SourceId)) continue;
return unit;
}
return null;
}
ERR_HQRoute SupportUplink(SCR_AIGroup group, string detail)
{
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group || !unit.Radio || unit.Uplink || OriginOccupied(unit.Radio, unit)) continue;
ERR_HQRoute route = m_Network.Route(unit.Radio);
if (!route) return null;
route.MessageId = m_Journal.Message();
route.Payload = detail;
m_Journal.Write("SUPPORT REPORT QUEUED", detail, route.MessageId);
return route;
}
return null;
}
}
@@ -0,0 +1,321 @@
class ERR_AssaultParachuteClass : ParachuteDeployedEntityClass
{
}
// Reuse the framework's canopy, cargo compartment and movement replication.
// AI owns no PlayerController: flight, transfer and landing run on the server.
class ERR_AssaultParachute : ParachuteDeployedEntity
{
protected ref ERR_AirAssaultTeam m_ERR_Team;
protected IEntity m_ERR_Soldier;
protected IEntity m_ERR_DepartureVehicle;
protected BaseCompartmentSlot m_ERR_DepartureSeat;
protected vector m_vERR_Landing;
protected float m_fERR_TransferDeadline;
protected float m_fERR_NextTransfer;
protected float m_fERR_NextExit;
protected float m_fERR_FlightDeadline;
protected float m_fERR_NextFlightLog;
protected vector m_vERR_LastProgress;
protected float m_fERR_ProgressAt;
protected float m_fERR_EscapeUntil;
protected int m_iERR_EscapeAttempts;
protected vector m_vERR_Escape;
protected bool m_bERR_Started;
protected bool m_bERR_VerticalDescent;
protected bool m_bERR_Transferring;
protected bool m_bERR_DidBoard;
protected bool m_bERR_Finished;
protected bool m_bERR_Broken;
protected bool m_bERR_Cleanup;
override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (SCR_Global.IsEditMode())
return;
SetFlags(EntityFlags.ACTIVE);
// The framework's generic destruction path deletes its vehicle after
// 100ms. AI must first confirm the native compartment is empty.
if (m_DamageManager)
{
m_DamageManager.GetOnDamage().Remove(OnParachuteDamaged);
m_DamageManager.GetOnDamage().Insert(ERR_OnDamage);
}
}
bool BeginJump(IEntity soldier, ERR_AirAssaultTeam team, vector landing)
{
if (!Replication.IsServer())
return false;
m_bERR_Started = true;
m_vERR_LastProgress = GetOrigin();
m_fERR_ProgressAt = GetGame().GetWorld().GetWorldTime();
m_ERR_Soldier = soldier;
m_ERR_Team = team;
m_vERR_Landing = landing;
CompartmentAccessComponent access;
if (soldier)
access = CompartmentAccessComponent.Cast(soldier.FindComponent(CompartmentAccessComponent));
// Physics handles are local-only; reacquire them instead of retaining
// the framework's cached pointer across engine callbacks.
Physics physics = GetPhysics();
if (!access || !m_Compartment || m_Compartment.IsOccupied() || !physics || !physics.IsDynamic())
{
m_bERR_Finished = true;
Print("Reintroduced [air assault]: invalid canopy seat/physics; passenger retained", LogLevel.WARNING);
return false;
}
physics.ChangeSimulationState(SimulationState.SIMULATION);
physics.SetActive(ActiveState.ALWAYS_ACTIVE);
m_ERR_DepartureVehicle = CompartmentAccessComponent.GetVehicleIn(soldier);
m_ERR_DepartureSeat = access.GetCompartment();
m_fERR_TransferDeadline = GetGame().GetWorld().GetWorldTime() + 2000;
m_fERR_FlightDeadline = m_fERR_TransferDeadline + 120000;
m_bERR_Transferring = true;
ERR_AirAssaultLoad.SetLock(soldier, false);
vector exitTransform[4];
GetWorldTransform(exitTransform);
if (!access.GetOutVehicle_NoDoor(exitTransform, false, true, true))
{
m_bERR_Transferring = false;
m_bERR_Finished = true;
ERR_AirAssaultLoad.SetLock(soldier, true);
Print("Reintroduced [air assault]: helicopter exit rejected; passenger retained", LogLevel.WARNING);
return false;
}
return true;
}
protected void ERR_OnDamage(BaseDamageContext damageContext)
{
if (Replication.IsServer() && m_DamageManager && m_DamageManager.IsDestroyed())
DestroyParachute();
}
override void DestroyParachute()
{
if (!Replication.IsServer())
return;
m_bERR_Broken = true;
// Leave the damaged canopy/occupant to physics until native ejection
// succeeds. Never delete an occupied replicated hierarchy.
AskServerExit();
}
protected void ERR_Transfer(float now)
{
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERR_Soldier.FindComponent(CompartmentAccessComponent));
if (m_Compartment.GetOccupant() == m_ERR_Soldier)
{
m_bERR_Transferring = false;
m_bERR_DidBoard = true;
m_ERR_DepartureVehicle = null;
m_ERR_DepartureSeat = null;
ERR_AirAssaultLoad.SetLock(m_ERR_Soldier, true);
ERR_HQ.Event("PARACHUTE BOARDED", string.Format("canopy=%1 soldier=%2 position=%3", this, m_ERR_Soldier, GetOrigin()));
return;
}
if (now >= m_fERR_TransferDeadline || !access || !ERR_Actors.CanFight(m_ERR_Soldier))
{
// If the native transfer fails, try to return the same soldier to
// their original seat. Do not spawn a replacement or delete them.
if (access && !access.IsInCompartment() && m_ERR_DepartureVehicle && m_ERR_DepartureSeat && !m_ERR_DepartureSeat.IsOccupied())
access.GetInVehicle(m_ERR_DepartureVehicle, m_ERR_DepartureSeat, true, 0, ECloseDoorAfterActions.INVALID, true);
m_bERR_Transferring = false;
m_bERR_Finished = true;
Print("Reintroduced [air assault]: canopy transfer failed; attempted return to original seat", LogLevel.WARNING);
return;
}
if (now >= m_fERR_NextTransfer && !access.IsInCompartment())
{
m_fERR_NextTransfer = now + 100;
access.GetInVehicle(this, m_Compartment, true, 0, ECloseDoorAfterActions.INVALID, true);
}
}
override void AskServerExit()
{
if (!Replication.IsServer() || m_bERR_Transferring || !m_Compartment || !m_Compartment.IsOccupied())
return;
float now = GetGame().GetWorld().GetWorldTime();
if (now < m_fERR_NextExit)
return;
vector position = GetOrigin();
float ground = SCR_TerrainHelper.GetTerrainY(position);
if (!m_bERR_Broken && position[1] - ground > 2)
return;
m_fERR_NextExit = now + 1000;
IEntity occupant = m_Compartment.GetOccupant();
// A GM/player takeover must never be controlled by this AI controller.
if (occupant != m_ERR_Soldier || ERR_Actors.Player(occupant))
return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(occupant.FindComponent(CompartmentAccessComponent));
if (!access)
return;
vector exitTransform[4];
GetWorldTransform(exitTransform);
if (!m_bERR_Broken)
exitTransform[3] = Vector(position[0], ground + 0.2, position[2]);
ERR_AirAssaultLoad.SetLock(occupant, false);
access.GetOutVehicle_NoDoor(exitTransform, m_bERR_Broken, true, true);
}
protected void ERR_Finish()
{
if (m_Compartment && m_Compartment.IsOccupied())
return;
if (m_bERR_Cleanup)
return;
m_bERR_Finished = true;
ERR_HQ.Event("PARACHUTE FINISHED", string.Format("canopy=%1 soldier=%2 position=%3 boarded=%4 damaged=%5", this, m_ERR_Soldier, GetOrigin(), m_bERR_DidBoard, m_bERR_Broken));
if (m_ERR_Soldier && CompartmentAccessComponent.GetVehicleIn(m_ERR_Soldier) == null)
{
ERR_AirAssaultLoad.SetLock(m_ERR_Soldier, false);
AIControlComponent control = AIControlComponent.Cast(m_ERR_Soldier.FindComponent(AIControlComponent));
AIAgent agent; if (control) agent = control.GetControlAIAgent();
if (agent)
{
agent.SetPermanentLOD(-1);
agent.ActivateAI();
}
vector position = GetOrigin();
if (m_bERR_DidBoard && !m_bERR_Broken && position[1] - SCR_TerrainHelper.GetTerrainY(position) < 3 && m_ERR_Team && ERR_Actors.CanFight(m_ERR_Soldier))
m_ERR_Team.Landed();
}
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(this);
if (garbage)
m_bERR_Cleanup = garbage.Insert(this);
if (m_bERR_Cleanup)
{
Physics physics = GetPhysics();
if (physics)
physics.SetActive(ActiveState.ACTIVE);
m_ERR_Soldier = null;
m_ERR_Team = null;
ClearEventMask(EntityEvent.FRAME | EntityEvent.SIMULATE);
}
}
// Transfer, landing and replication must run even if physics was asleep.
// Do not call the parent's frame handler: it installs human input controls.
override void EOnFrame(IEntity owner, float timeSlice)
{
if (!Replication.IsServer() || !m_bERR_Started || !m_Compartment)
return;
float now = GetGame().GetWorld().GetWorldTime();
if (m_bERR_Transferring && !m_ERR_Soldier)
{
m_bERR_Transferring = false;
m_bERR_Finished = true;
}
if (m_bERR_Transferring && m_ERR_Soldier)
ERR_Transfer(now);
// A native get-in completion can arrive after the timeout tick.
// Resume descent for any late occupant instead of freezing them aloft.
if (m_bERR_Finished && m_Compartment.IsOccupied())
{
m_bERR_Finished = false;
m_bERR_DidBoard = true;
}
if (m_bERR_Finished || (!m_bERR_Transferring && !m_Compartment.IsOccupied()))
{
ERR_Finish();
return;
}
if (m_Compartment.IsOccupied() && (m_Compartment.GetOccupant() != m_ERR_Soldier || ERR_Actors.Player(m_Compartment.GetOccupant())))
return;
Physics physics = GetPhysics();
if (!physics)
return;
if (physics.GetSimulationState() != SimulationState.SIMULATION)
physics.ChangeSimulationState(SimulationState.SIMULATION);
physics.SetActive(ActiveState.ALWAYS_ACTIVE);
if (m_fERR_FlightDeadline > 0 && now >= m_fERR_FlightDeadline)
{
// A guidance timeout is not canopy damage. Keep the soldier seated
// and descend vertically instead of cutting them loose at altitude.
m_fERR_FlightDeadline = 0;
m_bERR_VerticalDescent = true;
Print("Reintroduced [air assault]: canopy guidance expired; continuing vertical descent", LogLevel.WARNING);
}
vector progressPosition = GetOrigin();
if (m_bERR_Transferring || vector.Distance(progressPosition, m_vERR_LastProgress) > 2)
{
m_vERR_LastProgress = progressPosition;
m_fERR_ProgressAt = now;
}
else if (!m_bERR_Broken && now - m_fERR_ProgressAt > 5000 && progressPosition[1] - SCR_TerrainHelper.GetTerrainY(progressPosition) > 2)
{
// A roof/tree collision must not eject a soldier at terrain altitude.
// Try a short lateral escape using collision-aware physics, retaining
// the occupied canopy. No teleport, forced damage, or deletion.
int escapeSide = m_iERR_EscapeAttempts++ % 4;
float angle = escapeSide * Math.PI / 2;
m_vERR_Escape = Vector(Math.Cos(angle) * 3, 0, Math.Sin(angle) * 3);
m_fERR_EscapeUntil = now + 5000;
m_fERR_ProgressAt = now;
ERR_HQ.Event("PARACHUTE OBSTRUCTED", string.Format("canopy=%1 soldier=%2 position=%3 lateral attempt=%4", this, m_ERR_Soldier, progressPosition, m_iERR_EscapeAttempts));
}
AskServerExit();
if (now >= m_fERR_NextFlightLog)
{
m_fERR_NextFlightLog = now + 10000;
vector position = GetOrigin();
ERR_HQ.Event("PARACHUTE STATUS", string.Format("canopy=%1 soldier=%2 position=%3 height=%4 velocity=%5 occupied=%6 transferring=%7 damaged=%8", this, m_ERR_Soldier, position, Math.Round(position[1] - SCR_TerrainHelper.GetTerrainY(position)), physics.GetVelocity(), m_Compartment.IsOccupied(), m_bERR_Transferring, m_bERR_Broken));
}
// Preserve the framework's proxy movement protocol, at five updates
// per second for at most eight canopies per aircraft.
m_NetAccTime += timeSlice;
if (m_NetAccTime >= 0.2)
{
m_NetAccTime = 0;
vector transform[4];
GetWorldTransform(transform);
Rpc(RpcDo_SyncMovement, transform, physics.GetVelocity(), physics.GetAngularVelocity(), 0.0, 0.0);
}
}
override void EOnSimulate(IEntity owner, float timeSlice)
{
if (!Replication.IsServer() || !m_bERR_Started || m_bERR_Finished || m_bERR_Broken || !m_Compartment)
return;
if (m_Compartment.IsOccupied() && (m_Compartment.GetOccupant() != m_ERR_Soldier || ERR_Actors.Player(m_Compartment.GetOccupant())))
return;
if (!m_bERR_Transferring && !m_Compartment.IsOccupied())
return;
Physics physics = GetPhysics();
if (!physics)
return;
vector position = GetOrigin();
vector toward = m_vERR_Landing - position;
toward[1] = 0;
float height = position[1] - SCR_TerrainHelper.GetTerrainY(position);
vector velocity;
if (!m_bERR_VerticalDescent && toward.LengthSq() > 0.01)
velocity = toward.Normalized() * Math.Min(7, toward.Length() * 0.4);
if (GetGame().GetWorld().GetWorldTime() < m_fERR_EscapeUntil) velocity = m_vERR_Escape;
velocity[1] = -5;
if (height < 10)
velocity[1] = -2;
if (m_bERR_Transferring)
velocity = vector.Zero;
// Let physics move the entire occupied hierarchy without resetting its
// transform between fixed steps.
physics.SetAngularVelocity(vector.Zero);
physics.SetVelocity(velocity);
}
void ~ERR_AssaultParachute()
{
if (m_DamageManager)
m_DamageManager.GetOnDamage().Remove(ERR_OnDamage);
}
override void EOnDeactivate(IEntity owner)
{
if (m_DamageManager)
m_DamageManager.GetOnDamage().Remove(ERR_OnDamage);
super.EOnDeactivate(owner);
}
}
@@ -0,0 +1,300 @@
class ERR_BodyRecoveryJob
{
IEntity Body;
vector Position;
SCR_AIGroup Team;
AIWaypoint Waypoint;
ref ERR_PrisonPatrol Radio = new ERR_PrisonPatrol();
ref array<string> Missing = {};
ref ERR_HQRoute Order, Report;
float Started, SearchAt = -1, RetryAt;
int State;
bool Delivered;
float NextDiagnostic;
// 0 native team, 1 radio order, 2 walk/search, 3 evidence uplink, 4 done
}
class ERR_BodyRecovery
{
protected ERR_HQ m_HQ;
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_BodyRecoveryJob> m_Jobs = {};
protected ref set<IEntity> m_ReportedBodies = new set<IEntity>();
protected int m_Waves;
protected float m_NextTick;
protected bool m_Stopped;
void ERR_BodyRecovery(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq;
m_Mode = mode;
}
string Status() { return string.Format("Recovery: %1/%2 reports searched; teams dispatched=%3/8", Completed(), m_Jobs.Count(), m_Waves);
}
protected int Completed()
{
int count;
foreach (ERR_BodyRecoveryJob job : m_Jobs) { if (job.Delivered) count++;
}
return count;
}
void ReportBody(ERR_HQReport report)
{
if (m_Stopped || !report || !report.Target || m_ReportedBodies.Contains(report.Target) || m_Jobs.Count() >= 128 || !m_Mode.Evidence().Find(report.Target)) return;
ERR_BodyRecoveryJob job = new ERR_BodyRecoveryJob();
job.Body = report.Target;
job.Position = report.Position;
m_ReportedBodies.Insert(report.Target);
m_Jobs.Insert(job);
ERR_HQ.Event("BODY RECOVERY REQUEST", "delivered body sighting; search ordered at reported position " + job.Position.ToString());
}
protected bool SpawnTeam(ERR_BodyRecoveryJob job, float now)
{
foreach (ERR_BodyRecoveryJob previous : m_Jobs)
{
if (previous == job || previous.State != 4 || !previous.Team || previous.Team.GetPlayerCount(true) > 0 || previous.Team.GetFaction() != m_Mode.EnemyFaction() || m_Mode.ForceLiving(previous.Team) == 0) continue;
job.Team = previous.Team;
previous.Team = null;
job.State = 1;
job.Started = now;
m_HQ.ReserveGroup(job.Team);
return true;
}
if (m_Waves >= 8 || m_Mode.Forces().Count() >= 254) return false;
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
ERR_LocationComponent base;
float best = 1e20;
foreach (ERR_LocationComponent location : locations)
{
if (!location || (location.Kind() != ERR_LocationKind.RADIO && location.Kind() != ERR_LocationKind.DEPOT)) continue;
float distance = vector.DistanceXZ(location.Position(), job.Position);
if (distance < best) { base = location;
best = distance;
}
}
if (!base) return false;
vector point = ERR_Actors.Ground(base.Position() + "20 0 20");
if (!ERR_HQRoadIntercept.Standable(point)) return false;
// Only reject visible pop-in; these live player positions never choose a search destination.
array<IEntity> players = {};
ERR_Actors.Players(players);
foreach (IEntity player : players) if (vector.DistanceXZ(player.GetOrigin(), point) < 300) return false;
m_Waves++;
job.Team = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", point));
job.Started = now;
if (!job.Team) { job.State = 4;
return false;
}
m_Mode.Own(job.Team);
if (!ERR_PrisonPatrol.Configure(job.Team, m_Mode.FactionSelection().Choice("enemy").Group, m_Mode.EnemyFaction())) { job.State = 4;
return false;
}
ERR_HQ.Event("BODY RECOVERY TEAM", "special search team departing friendly site; bodies remain retained");
return true;
}
protected void RemoveWaypoint(ERR_BodyRecoveryJob job)
{
if (job.Waypoint) { if (job.Team) job.Team.RemoveWaypoint(job.Waypoint);
ERR_Actors.DeleteSpawned(job.Waypoint);
job.Waypoint = null;
}
}
protected void Finish(ERR_BodyRecoveryJob job, bool delivered)
{
RemoveWaypoint(job);
job.State = 4;
job.Delivered = delivered;
job.Order = null;
job.Report = null;
if (delivered) { m_HQ.CompromisedSites(job.Missing, job.Position);
m_Mode.Evidence().Delivered(job.Body);
}
if (!delivered) m_HQ.ReleaseRecovery(job.Team);
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_NextTick) return;
m_NextTick = now + 1000;
m_HQ.SecureKnownSites(now);
int active;
foreach (ERR_BodyRecoveryJob existing : m_Jobs) if (existing.Team && existing.State < 4) active++;
foreach (ERR_BodyRecoveryJob job : m_Jobs)
{
if (job.State == 4 || now < job.RetryAt) continue;
if (!job.Body) { Finish(job, false);
continue;
}
if (!job.Team)
{
if (active >= 2) continue;
job.RetryAt = now + 30000;
if (SpawnTeam(job, now)) active++;
continue;
}
if (job.Team.GetPlayerCount(true) > 0 || job.Team.GetFaction() != m_Mode.EnemyFaction() || now - job.Started > 1800000) { Finish(job, false);
continue;
}
job.Team.ActivateAI();
array<AIAgent> activeAgents = {};
job.Team.GetAgents(activeAgents);
foreach (AIAgent activeAgent : activeAgents)
{
if (!activeAgent) continue;
activeAgent.SetPermanentLOD(0);
activeAgent.ActivateAI();
}
if (job.State > 0 && m_Mode.ForceLiving(job.Team) == 0) { Finish(job, false); continue; }
if (job.State == 0)
{
if (now - job.Started > 60000) { Finish(job, false);
continue;
}
if (!job.Team.IsExpandComplete() || job.Team.GetAgentsCount() == 0) continue;
ERR_PrepareResult radio = job.Radio.Radio(m_Mode, job.Team, job.Started + 60000);
if (radio != ERR_PrepareResult.READY) continue;
m_Mode.RegisterGroup(job.Team);
ERR_ForceRecord recoveryRecord = m_Mode.Forces().FindGroup(job.Team);
if (recoveryRecord) ERR_HQ.Event("SPAWN PURPOSE", "force=" + recoveryRecord.SourceId + "; purpose=body inspection; position=" + job.Team.GetOrigin().ToString() + "; reported body=" + job.Position.ToString());
m_HQ.ReserveGroup(job.Team);
job.Team.ActivateAI();
job.State = 1;
}
if (job.State == 1)
{
if (!job.Order) { job.Order = m_HQ.Downlink(job.Team, "BODY SEARCH", "inspect reported military body " + job.Position.ToString(), 0);
if (!job.Order) continue;
}
int order = job.Order.Advance(now, m_HQ.Journal());
if (order < 0) { job.Order = null;
job.RetryAt = now + 15000;
continue;
}
if (order == 0) continue;
job.Order = null;
job.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), job.Position));
if (!job.Waypoint) { Finish(job, false);
continue;
}
job.Waypoint.SetCompletionRadius(1.5);
job.Team.AddWaypointAt(job.Waypoint, 0);
job.State = 2;
}
if (job.State == 2)
{
SCR_AIGroupUtilityComponent utility = job.Team.GetGroupUtilityComponent();
if (utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty()) { job.SearchAt = -1;
continue;
}
array<AIAgent> agents = {};
job.Team.GetAgents(agents);
IEntity searcher;
foreach (AIAgent agent : agents)
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity()) && !ERR_Actors.Player(agent.GetControlledEntity()) && vector.Distance(agent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin()) <= 3) searcher = agent.GetControlledEntity();
if (now >= job.NextDiagnostic)
{
job.NextDiagnostic = now + 30000;
float nearest = 1e20;
foreach (AIAgent nearbyAgent : agents)
if (nearbyAgent && nearbyAgent.GetControlledEntity()) nearest = Math.Min(nearest, vector.Distance(nearbyAgent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin()));
ERR_HQ.Event("BODY SEARCH PROGRESS", "nearest searcher=" + nearest.ToString() + "m; physical inspection required");
}
if (!searcher) { job.SearchAt = -1;
continue;
}
if (job.SearchAt < 0) job.SearchAt = now;
if (now - job.SearchAt < 10000) continue;
if (!m_Mode.Evidence().Inspect(job.Body, searcher, job.Missing)) continue;
RemoveWaypoint(job);
job.State = 3;
ERR_HQ.Event("BODY SEARCH COMPLETE", "local inspection complete; missing document report awaits radio");
}
if (job.State == 3)
{
if (!job.Report) { job.Report = m_HQ.SupportUplink(job.Team, "searched body; missing documents=" + job.Missing.Count().ToString());
if (!job.Report) continue;
}
int receipt = job.Report.Advance(now, m_HQ.Journal());
if (receipt < 0) { job.Report = null;
job.RetryAt = now + 15000;
}
else if (receipt > 0) Finish(job, true);
}
}
}
void Stop() { m_Stopped = true;
foreach (ERR_BodyRecoveryJob job : m_Jobs) { RemoveWaypoint(job);
job.Order = null;
job.Report = null;
} }
}
modded class ERR_HQ
{
protected ref map<string, float> m_Compromised = new map<string, float>();
protected float m_NextSecurity;
void SecureKnownSites(float now)
{
if (now < m_NextSecurity) return;
m_NextSecurity = now + 30000;
array<string> sites = {};
foreach (string id, float until : m_Compromised) if (now < until) sites.Insert(id);
TrySecureSites(sites);
}
void CompromisedSites(array<string> sites, vector bodyPosition)
{
foreach (string id : sites)
{
if (m_Compromised.Count() >= 64 && !m_Compromised.Contains(id)) continue;
m_Compromised.Set(id, GetGame().GetWorld().GetWorldTime() + 1800000);
}
m_NextSecurity = 0;
SecureKnownSites(GetGame().GetWorld().GetWorldTime());
}
void ReleaseRecovery(SCR_AIGroup group)
{ foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) unit.ReservedForSupport = false;
}
protected void TrySecureSites(array<string> sites)
{
// A missing issued document means the site MAY be compromised. Never read
// player discoveries and never turn this hypothesis into an artillery target.
foreach (string id : sites)
{
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
ERR_LocationComponent target;
foreach (ERR_LocationComponent location : locations) if (location.Id() == id) target = location;
if (!target) continue;
bool secured;
foreach (ERR_HQUnit assigned : m_Units)
{
ERR_HQTask assignedTask = assigned.PlannedTask();
if (assignedTask && assignedTask.SecuritySite == id && GetGame().GetWorld().GetWorldTime() < assignedTask.ExpiresAt) secured = true;
}
if (secured) continue;
ERR_HQ.Event("INTEL COMPROMISE", "missing issued document concerns " + id + "; defensive reinforcement requested");
ERR_HQUnit best;
float distance = 1e20;
foreach (ERR_HQUnit unit : m_Units)
{
ERR_HQWatchEntry status = m_Watch.Find(unit.ForceSourceId);
if (!unit.Eligible(m_Mode) || unit.HomeGuard || unit.Assignment || unit.PendingOrder || unit.Downlink || Fighting(unit) || !ERR_InfantryResupplyRules.Fresh(status, GetGame().GetWorld().GetWorldTime())) continue;
float candidate = vector.DistanceXZ(status.Position, target.Position());
if (candidate < distance && candidate < 2500) { best = unit;
distance = candidate;
}
}
if (!best) continue;
ERR_HQRoute route = Downlink(best.Group, "SITE SECURITY", "secure potentially compromised site " + id, 0);
if (!route) continue;
ERR_HQTask task = new ERR_HQTask();
task.SecuritySite = id;
task.Role = ERR_HQPursuitRole.SEARCH;
task.Destination = target.Position();
task.QueuedAt = GetGame().GetWorld().GetWorldTime();
task.ExpiresAt = task.QueuedAt + 600000;
ERR_HQReport lineage = new ERR_HQReport();
lineage.Position = target.Position();
lineage.ObservedAt = task.QueuedAt;
lineage.Distress = true;
best.PendingTask = task;
best.PendingOrder = lineage;
best.Downlink = route;
}
}
}
@@ -13,10 +13,14 @@ class ERR_ConvoyPool
{
protected ERR_GameMode m_Mode;
protected int m_Limit;
protected bool m_Supply;
protected int m_BodyLimit = 60;
protected int m_GroupLimit = 128;
protected ref array<ref ERR_ConvoyReceipt> m_Receipts = {};
void ERR_ConvoyPool(ERR_GameMode mode, int maxConvoys)
void ERR_ConvoyPool(ERR_GameMode mode, int maxConvoys, bool supply = false)
{
m_Mode = mode; m_Limit = Math.Clamp(maxConvoys, 0, 8);
m_Mode = mode; m_Supply = supply; m_Limit = Math.Clamp(maxConvoys, 0, 20);
if (supply) { m_BodyLimit = 60 + 25 * m_Limit; m_GroupLimit = 256; }
}
static int CrewCount(int vehicle)
{
@@ -26,6 +30,22 @@ class ERR_ConvoyPool
if (vehicle == 0 || vehicle == 3 || vehicle == 5) return 2;
return 0;
}
int PlannedCount(int index)
{
if (m_Supply && index == 2) return 1;
if (m_Supply && (index == 6 || index == 7)) return 7;
return CrewCount(index);
}
int PlannedBodies()
{
if (m_Supply) return 25;
return 19;
}
int PlannedGroups()
{
if (m_Supply) return 8;
return 6;
}
protected bool Current()
{
return Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Mode.Forces();
@@ -57,25 +77,26 @@ class ERR_ConvoyPool
{
if (!Current() || sourceId.IsEmpty() || sourceId.Length() > 64) return null;
foreach (ERR_ConvoyReceipt known : m_Receipts) if (known.SourceId == sourceId) return known;
if (m_Receipts.Count() >= m_Limit || CommittedBodies() + 19 > 60 || CommittedGroups() + 6 > 128) return null;
if (m_Receipts.Count() >= m_Limit || CommittedBodies() + PlannedBodies() > m_BodyLimit || CommittedGroups() + PlannedGroups() > m_GroupLimit) return null;
ERR_ConvoyReceipt receipt = new ERR_ConvoyReceipt(); receipt.Id = m_Receipts.Count() + 1; receipt.SourceId = sourceId;
if (m_Supply) { receipt.OutstandingBodies = 25; receipt.OutstandingGroups = 8; receipt.Records.Insert(-1); receipt.Records.Insert(-1); }
m_Receipts.Insert(receipt);
return receipt;
}
bool Consume(ERR_ConvoyReceipt receipt)
{
if (!Current() || !Owns(receipt) || receipt.Consumed || CommittedBodies() > 60 || CommittedGroups() > 128) return false;
if (!Current() || !Owns(receipt) || receipt.Consumed || CommittedBodies() > m_BodyLimit || CommittedGroups() > m_GroupLimit) return false;
receipt.Consumed = true;
return true;
}
bool BindRecord(ERR_ConvoyReceipt receipt, int vehicle, ERR_ForceRecord record)
{
if (!Current() || !Owns(receipt) || !receipt.Consumed || vehicle < 0 || vehicle >= 6 || !record) return false;
if (!Current() || !Owns(receipt) || !receipt.Consumed || vehicle < 0 || vehicle >= PlannedGroups() || !record) return false;
if (receipt.Records[vehicle] >= 0) return receipt.Records[vehicle] == record.Id;
if (m_Mode.Forces().Find(record.Id) != record || record.Kind != ERR_ForceKind.CONVOY || !record.Group || !record.Physical || record.Eliminated) return false;
if (record.SourceId != receipt.SourceId + "-vehicle-" + vehicle.ToString() || record.InitialStrength != CrewCount(vehicle) || record.Strength != record.InitialStrength) return false;
if (record.SourceId != receipt.SourceId + "-vehicle-" + vehicle.ToString() || record.InitialStrength != PlannedCount(vehicle) || record.Strength != record.InitialStrength) return false;
receipt.Records[vehicle] = record.Id;
receipt.OutstandingBodies -= CrewCount(vehicle); receipt.OutstandingGroups--;
receipt.OutstandingBodies -= PlannedCount(vehicle); receipt.OutstandingGroups--;
return true;
}
// There is intentionally no refund/reset method. A consumed startup attempt
@@ -8,6 +8,7 @@ class ERR_ConvoyCombatOrder
string SourceId;
SCR_AIGroup Group;
bool Hold;
vector Position;
float ObservedAt = -1;
float IssuedAt = -1;
float ExpiresAt = -1;
@@ -11,7 +11,11 @@ class ERR_ConvoyResources
return "";
}
static ResourceName GroupPrefab() { return "{7A968AB297475FA7}Prefabs/ERR/ConvoyCrew.et"; }
static ResourceName Soldier() { return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; }
static ResourceName Soldier()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.FactionSelection() && mode.FactionSelection().Ready() && mode.FactionSelection().Active("enemy") != "ussr") return mode.FactionSelection().Soldier();
return "{DCB41B3746FDD1BE}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_Rifleman.et"; }
static ResourceName Rifle() { return "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et"; }
static ResourceName Magazine() { return "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et"; }
static bool Available()
@@ -225,6 +229,18 @@ class ERR_ConvoyService
{
return !m_Stopped && Replication.IsServer() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Pool;
}
protected ERR_ConvoyTask NewTask() { return new ERR_ConvoyTask(); }
protected ERR_ConvoyVehicle NewVehicle() { return new ERR_ConvoyVehicle(); }
ERR_ConvoyTask At(int index)
{
if (index < 0 || index >= m_Tasks.Count()) return null;
return m_Tasks[index];
}
ERR_ConvoyTask Find(string source)
{
foreach (ERR_ConvoyTask task : m_Tasks) if (task.Receipt && task.Receipt.SourceId == source) return task;
return null;
}
protected bool FillCrew(ERR_ConvoyVehicle unit)
{
array<BaseCompartmentSlot> all = {};
@@ -259,14 +275,13 @@ class ERR_ConvoyService
ERR_ConvoyTask Dispatch(ERR_ConvoyReceipt receipt, array<vector> positions, array<vector> angles, ERR_ForceRoute route, float now)
{
if (!Current() || !ERR_ForceRules.Finite(now) || now < 0 || !ERR_ConvoyRules.Plan(positions, angles, route) || !ERR_ConvoyResources.Available() || !ERR_ConvoyRules.GroundClear(positions, angles)) return null;
// The selected stock crew and group are USSR. Other factions require a new
// reviewed catalog; never silently rewrite affiliations on incompatible kit.
if (!m_Mode.EnemyFaction() || m_Mode.EnemyFaction().GetFactionKey() != "USSR" || !m_Pool.Consume(receipt)) return null;
ERR_ConvoyTask task = new ERR_ConvoyTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000;
// The server role selection owns the memberless group affiliation.
if (!m_Mode.EnemyFaction() || !m_Pool.Consume(receipt)) return null;
ERR_ConvoyTask task = NewTask(); task.Receipt = receipt; task.Route = route; task.Deadline = now + 30000;
m_Tasks.Insert(task);
for (int index = 0; index < 6; index++)
{
ERR_ConvoyVehicle unit = new ERR_ConvoyVehicle(); unit.Index = index; task.Vehicles.Insert(unit);
ERR_ConvoyVehicle unit = NewVehicle(); unit.Index = index; task.Vehicles.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), positions[index]));
if (unit.Group) m_Mode.Own(unit.Group);
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(index), positions[index], angles[index]));
@@ -278,7 +293,7 @@ class ERR_ConvoyService
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(35);
}
ERR_HQ.Event("CONVOY CREATED", "six physical vehicles and 19 finite crew; native readiness and ledger admission pending");
ERR_HQ.Event("CONVOY CREATED", "six physical vehicles; finite crew created; native readiness and ledger admission pending");
return task;
}
protected string ReadinessReason(ERR_ConvoyVehicle unit)
@@ -7,12 +7,11 @@ class ERR_EnemyEquipmentRules
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !mode || mode != ERR_GameMode.Get() || !mode.Active()) return false;
if (!group || !actor || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group) return false;
SCR_AIGroupUtilityComponent utility = group.GetGroupUtilityComponent();
return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && group.GetFaction() == mode.EnemyFaction() && !ERR_EnemyGunnerSkill.CapturedGroup(mode, group);
return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && mode.IsMissionEnemy(group.GetFaction()) && !ERR_EnemyGunnerSkill.CapturedGroup(mode, group);
}
static EAISkill Floor(EAISkill skill)
{
if (skill < EAISkill.EXPERT) return EAISkill.EXPERT;
return skill;
return EAISkill.CYLON;
}
}
@@ -158,7 +157,7 @@ class ERR_EnemyEquipment
void ERR_EnemyEquipment(ERR_GameMode mode) { m_Mode = mode; }
void RegisterGroup(SCR_AIGroup group, int radioId = 0, bool fitRadios = true)
{
if (m_Stopped || !Replication.IsServer() || !group || m_Groups.Count() >= 128) return;
if (m_Stopped || !Replication.IsServer() || !group || m_Groups.Count() >= 256) return;
foreach (ERR_EnemyEquipmentGroup existing : m_Groups) if (existing.Group == group) return;
ERR_EnemyEquipmentGroup state = new ERR_EnemyEquipmentGroup(); state.Group = group;
state.ReadyAt = GetGame().GetWorld().GetWorldTime() + 2000;
@@ -2,12 +2,24 @@
// Owned extension and inspected vanilla API provenance: docs/ai-equipment-migration.md.
class ERR_EnemyGunnerSkill
{
static ERR_ConvoyTask TaskForGroup(ERR_HQ hq, SCR_AIGroup group)
{
if (!hq || !group || !hq.Convoys()) return null;
for (int i = 0; i < hq.Convoys().Count(); i++)
{
ERR_ConvoyTask task = hq.Convoys().At(i);
if (!task || task.Vehicles.Count() > 6) continue;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
if (vehicle && vehicle.Group == group) return task;
}
return null;
}
static bool CapturedGroup(ERR_GameMode mode, SCR_AIGroup group)
{
if (!mode || !group) return false;
ERR_HQ hq = ERR_HQ.Cast(mode.Command());
if (!hq) return false;
ERR_ConvoyTask task = hq.ConvoyTask();
ERR_ConvoyTask task = TaskForGroup(hq, group);
if (!task || task.Vehicles.Count() > 6) return false;
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
if (unit && unit.Group == group && ERR_ConvoyRules.Captured(unit.Entity, group)) return true;
@@ -18,7 +30,7 @@ class ERR_EnemyGunnerSkill
if (!ERR_EnemyEquipmentRules.Eligible(mode, group, actor)) return false;
ERR_HQ hq = ERR_HQ.Cast(mode.Command());
if (!hq) return false;
ERR_ConvoyTask task = hq.ConvoyTask();
ERR_ConvoyTask task = TaskForGroup(hq, group);
if (!task || !task.Ready || task.Stopped || !task.Receipt || !task.Receipt.Consumed || task.Vehicles.Count() > 6 || !mode.Forces()) return false;
foreach (ERR_ConvoyVehicle unit : task.Vehicles)
{
@@ -114,6 +114,7 @@ class ERR_EnemyOptics
int draw = Math.RandomInt(0, remaining.Count());
int index = remaining[draw]; remaining.Remove(draw);
ResourceName prefab = candidates[index];
if (!mode.Content() || !mode.Content().Allows(prefab)) continue;
for (int slotIndex = 0; slotIndex < storage.GetSlotsCount(); slotIndex++)
{
InventoryStorageSlot slot = storage.GetSlot(slotIndex);
@@ -0,0 +1,38 @@
// Mission-owned military AI use maximum decision skill, independently of aim error.
modded class SCR_AICombatComponent
{
bool ERR_EnhancedEnemy()
{
return m_ERRSkillMode && ERR_EnemyEquipmentRules.Eligible(m_ERRSkillMode, m_ERRSkillGroup, GetOwner());
}
override void ERR_RefreshEnemySkill()
{
super.ERR_RefreshEnemySkill();
if (ERR_EnhancedEnemy() && m_Perception && m_Utility && m_Utility.m_ThreatSystem)
UpdatePerceptionFactor(m_Perception, m_Utility.m_ThreatSystem);
}
override void UpdatePerceptionFactor(PerceptionComponent perceptionComp, SCR_AIThreatSystem threatSystem)
{
if (!ERR_EnhancedEnemy()) { super.UpdatePerceptionFactor(perceptionComp, threatSystem); return; }
// Use native alert recognition even while safe/suppressed. Native visibility,
// vegetation, darkness, viewing direction and binocular equipment still apply.
if (perceptionComp) perceptionComp.SetPerceptionFactor(PERCEPTION_FACTOR_ALERTED * m_fEquipmentPerceptionFactor * m_fPerceptionFactor);
}
override void ERR_ReleaseEnemySkill(ERR_GameMode mode)
{
super.ERR_ReleaseEnemySkill(mode);
if (m_Perception && m_Utility && m_Utility.m_ThreatSystem)
UpdatePerceptionFactor(m_Perception, m_Utility.m_ThreatSystem);
}
}
modded class SCR_AIGetAimErrorOffset
{
override float GetRandomFactor(EAISkill skill, float mu)
{
// CYLON normally eliminates aim error. Keep the ordinary REGULAR distribution
// for these soldiers while leaving skill-dependent decisions at maximum.
if (m_CombatComponent && m_CombatComponent.ERR_EnhancedEnemy()) skill = EAISkill.REGULAR;
return super.GetRandomFactor(skill, mu);
}
}
+209 -22
View File
@@ -3,6 +3,8 @@ class ERR_HQUnit
int Id;
SCR_AIGroup Group;
SCR_AIGroupUtilityComponent Utility;
SCR_AIGroupPerception Detection;
float NextDetectionLog;
IEntity Radio;
float NextReport;
ref ERR_HQRoute Uplink;
@@ -88,6 +90,20 @@ class ERR_HQ : ERR_CommandService
protected ERR_GameMode m_Mode;
protected ref ERR_MortarSystem m_Mortars;
protected ref ERR_EnemyEquipment m_Equipment;
protected ref ERR_HunterService m_Hunters;
protected ref ERR_ObjectiveDefence m_ObjectiveDefence;
protected ref array<SCR_AICombatComponent> m_SpecialSkills = {};
ERR_HunterService Hunters() { return m_Hunters; }
protected ref ERR_ScoutSystem m_Scouts;
protected ref ERR_AirfieldSupport m_Airfield;
ERR_AirfieldSupport Airfield() { return m_Airfield; }
protected ref ERR_BodyRecovery m_Recovery;
ERR_BodyRecovery Recovery() { return m_Recovery; }
protected ref ERR_AirAssaultSystem m_AirAssault;
ERR_AirAssaultSystem AirAssault() { return m_AirAssault; }
protected ref ERR_AirReconSystem m_Recon;
ERR_AirReconSystem Recon() { return m_Recon; }
ERR_ScoutSystem Scouts() { return m_Scouts; }
protected ref ERR_ConvoyPool m_ConvoyPool;
protected ref ERR_ConvoyService m_Convoys;
protected ref ERR_ConvoyTask m_ConvoyTask;
@@ -100,6 +116,7 @@ class ERR_HQ : ERR_CommandService
protected ref ERR_MortarLogistics m_Logistics;
protected ref ERR_InfantryResupply m_Resupply;
protected ref ERR_ConvoyStartup m_ConvoyStartup;
protected ref ERR_SupplyConvoySystem m_SupplyConvoys;
protected ref array<ref ERR_HQUnit> m_Units = {};
protected ref array<ref ERR_HQReport> m_Contacts = {};
protected ref ERR_HQMemory m_Memory = new ERR_HQMemory();
@@ -113,7 +130,42 @@ class ERR_HQ : ERR_CommandService
protected float m_NextNetwork;
protected float m_NextAssignment;
void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Equipment = new ERR_EnemyEquipment(mode); m_ConvoyPool = new ERR_ConvoyPool(mode, 1); m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool); m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); m_Resupply = new ERR_InfantryResupply(this, mode); m_ConvoyStartup = new ERR_ConvoyStartup(mode.Settings().Get("automaticConvoyEnabled") == 1); }
void ERR_HQ(ERR_GameMode mode)
{
m_Mode = mode;
m_Equipment = new ERR_EnemyEquipment(mode);
m_Hunters = new ERR_HunterService(mode, null);
m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this);
m_Scouts = new ERR_ScoutSystem(mode);
m_Recon = new ERR_AirReconSystem(mode);
m_AirAssault = new ERR_AirAssaultSystem(this, mode);
m_Airfield = new ERR_AirfieldSupport(this, mode); m_Recovery = new ERR_BodyRecovery(this, mode);
bool legacyFixture;
#ifdef WORKBENCH
// These pre-existing diagnostic scenarios specifically exercise the retained
// finite-route controller. Ordinary Workbench sessions use the full fleet.
string fixtureEnabled, fixtureOutcome;
legacyFixture = System.GetCLIParam("errSmoke", fixtureEnabled) && fixtureEnabled == "1"
&& System.GetCLIParam("errSmokeOutcome", fixtureOutcome) && (fixtureOutcome == "convoy" || fixtureOutcome == "hq-memory");
#endif
if (legacyFixture)
{
m_ConvoyPool = new ERR_ConvoyPool(mode, 1);
m_Convoys = new ERR_ConvoyService(mode, m_ConvoyPool);
}
else
{
int convoyCount = mode.Settings().Runtime("convoyCount");
m_ConvoyPool = new ERR_ConvoyPool(mode, convoyCount, true);
ERR_SupplyConvoyService supply = new ERR_SupplyConvoyService(mode, m_ConvoyPool);
m_Convoys = supply;
m_SupplyConvoys = new ERR_SupplyConvoySystem(mode, m_ConvoyPool, supply, convoyCount, mode.Settings().Get("automaticConvoyEnabled") == 1);
}
m_Mortars = new ERR_MortarSystem(this);
m_Logistics = new ERR_MortarLogistics(this, mode);
m_Resupply = new ERR_InfantryResupply(this, mode);
m_ConvoyStartup = new ERR_ConvoyStartup(mode.Settings().Get("automaticConvoyEnabled") == 1);
}
static ERR_HQ Get()
{
ERR_GameMode mode = ERR_GameMode.Get();
@@ -134,6 +186,7 @@ class ERR_HQ : ERR_CommandService
ERR_ConvoyPool ConvoyPool() { return m_ConvoyPool; }
ERR_ConvoyService Convoys() { return m_Convoys; }
ERR_ConvoyTask ConvoyTask() { return m_ConvoyTask; }
ERR_SupplyConvoySystem SupplyConvoys() { return m_SupplyConvoys; }
bool PrepareConvoy(vector seed)
{
if (!Replication.IsServer() || !MissionActive() || m_ConvoyTask) return false;
@@ -188,6 +241,12 @@ class ERR_HQ : ERR_CommandService
protected void AutomaticConvoy(float now)
{
if (!Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
if (m_SupplyConvoys)
{
m_SupplyConvoys.Tick(now, ConvoyAdmissionReady());
if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0);
return;
}
if (!m_ConvoyStartup || m_ConvoyStartup.Finished()) return;
string previous = m_ConvoyStartup.Status();
if (m_ConvoyStartup.Decide(now, m_ConvoyTask != null, ConvoyAdmissionReady()))
@@ -197,6 +256,13 @@ class ERR_HQ : ERR_CommandService
}
bool StartScenarioConvoy()
{
if (m_SupplyConvoys)
{
if (!ConvoyAdmissionReady()) return false;
bool started = m_SupplyConvoys.StartOne();
if (!m_ConvoyTask && m_Convoys.Count() > 0) m_ConvoyTask = m_Convoys.At(0);
return started;
}
if (!m_Mode || !m_Mode.Sites()) return false;
ERR_SiteRuntime site = m_Mode.Sites().Find("everon-supply-1");
if (!site || !site.Definition) return false;
@@ -205,7 +271,8 @@ class ERR_HQ : ERR_CommandService
string ConvoyStatus()
{
string detail = m_Convoys.Status();
if (m_ConvoyStartup) detail += "; automatic startup: " + m_ConvoyStartup.Status();
if (m_SupplyConvoys) detail += "; " + m_SupplyConvoys.Status();
else if (m_ConvoyStartup) detail += "; automatic startup: " + m_ConvoyStartup.Status();
if (!m_ConvoyFailure.IsEmpty()) detail += "; " + m_ConvoyFailure;
return detail;
}
@@ -219,9 +286,45 @@ class ERR_HQ : ERR_CommandService
if (unit.Group != group) continue;
ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.ReservedForSupport = true;
m_Journal.Write("ROLE RESERVED", string.Format("group=%1 force=%2 home=%3; excluded from general HQ retasking", unit.Id, unit.ForceSourceId, unit.HomeId));
}
}
void Dedicate(SCR_AIGroup group, string siteId)
{
ReserveGroup(group);
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) { unit.HomeId = siteId; unit.HomeGuard = true; }
}
protected void Detected(SCR_AIGroup group, SCR_AITargetInfo target)
{
if (!Replication.IsServer() || !m_Mode.Active() || m_Mode.Phase() <= ERR_Phase.PRISON || !group || !target || !target.m_Entity) return;
float now = GetGame().GetWorld().GetWorldTime();
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
unit.NextReport = 0;
// Native combat reacts locally now; remote orders still require radio delivery.
group.ActivateAI();
if (now >= unit.NextDetectionLog)
{
unit.NextDetectionLog = now + 10000;
m_Journal.Write("CONTACT LOCAL", string.Format("group=%1 force=%2 observed=%3 home=%4 dedicated=%5; native combat active, radio report due", unit.Id, unit.ForceSourceId, target.m_vWorldPos, unit.HomeId, unit.ReservedForSupport));
}
}
}
// Temporary reservations never alter the permanent support reservation.
void ReleaseMortarCrew(SCR_AIGroup group)
{
ERR_MortarBattery battery = m_Mortars.FindGroup(group);
if (!battery) return;
if (battery.Gun)
{
DamageManagerComponent damage = DamageManagerComponent.Cast(battery.Gun.FindComponent(DamageManagerComponent));
if (!damage || !damage.IsDestroyed()) return;
}
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) unit.ReservedForSupport = false;
}
int LogisticsToken(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.LogisticsReservation;
@@ -316,7 +419,7 @@ class ERR_HQ : ERR_CommandService
ERR_HQJournal Journal() { return m_Journal; }
string Status()
{
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells", m_Memory.Count(), m_Coverage.Count()) + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status() + "; " + m_Resupply.Status();
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells; aggression=%3/30 identified bodies=%4; ", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies()) + m_Hunters.Status() + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status() + "; " + m_Resupply.Status() + "; " + m_Recon.Status();
}
protected void BindPhysical(ERR_HQUnit unit)
@@ -328,11 +431,42 @@ class ERR_HQ : ERR_CommandService
if (!unit.StatusSampler.Bound()) unit.StatusSampler.Bind(unit.Group, record.SourceId, record.InitialStrength, m_Mode.EnemyFaction());
if (unit.StatusSampler.Bound()) m_Bodies.RegisterGroup(unit.Group, unit.StatusSampler);
}
protected void BindSpecialSkills(SCR_AIGroup group)
{
array<AIAgent> specialists = {}; group.GetAgents(specialists);
foreach (AIAgent specialist : specialists)
{
if (!specialist || !specialist.GetControlledEntity()) continue;
SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(specialist.GetControlledEntity().FindComponent(SCR_AICombatComponent));
if (!combat) continue;
combat.ERR_BindEnemySkill(m_Mode, group);
if (!m_SpecialSkills.Contains(combat)) m_SpecialSkills.Insert(combat);
}
}
protected void AuditGroup(ERR_HQUnit unit)
{
SCR_AIGroup group = unit.Group;
string factionKey;
if (group.GetFaction()) factionKey = group.GetFaction().GetFactionKey();
m_Journal.Write("REGISTER", string.Format("group=%1 force=%2 faction=%3 position=%4 members=%5 home=%6 dedicated=%7; policy=maximum skill/regular aim/alert recognition", unit.Id, unit.ForceSourceId, factionKey, group.GetOrigin(), m_Mode.ForceLiving(group), unit.HomeId, unit.ReservedForSupport));
array<AIAgent> auditAgents = {}; group.GetAgents(auditAgents);
foreach (AIAgent auditAgent : auditAgents)
{
if (!auditAgent || !auditAgent.GetControlledEntity()) continue;
IEntity actor = auditAgent.GetControlledEntity();
string prefab; if (actor.GetPrefabData()) prefab = actor.GetPrefabData().GetPrefabName();
m_Journal.Write("SPAWN MEMBER", string.Format("group=%1 force=%2 actor=%3 prefab=%4 position=%5", unit.Id, unit.ForceSourceId, actor, prefab, actor.GetOrigin()));
}
}
override void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !group) 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);
foreach (ERR_HQUnit known : m_Units) if (known.Group == group) { BindPhysical(known); return; }
if (m_Units.Count() >= 128) return;
if (m_Units.Count() >= 256) return;
SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent));
if (!utility || group.GetFaction() != m_Mode.EnemyFaction()) return;
ERR_HQUnit unit = new ERR_HQUnit();
@@ -345,16 +479,25 @@ class ERR_HQ : ERR_CommandService
if (home)
{
unit.HomeId = home.Definition.Id;
// Objective security is permanent; it can report contacts but cannot be tasked
// with searches, reinforcements, resupply or courier work away from its site.
unit.ReservedForSupport = ERR_ObjectiveDefence.Objective(home.Definition.Kind);
unit.HomeGuard = !home.Definition.Patrol || home.Definition.Patrol.IsEmpty();
}
int phase = unit.Id % 5;
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
m_Units.Insert(unit);
m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group));
// Scouts keep their observation post and authored sniper/radio loadout.
if ((m_Scouts && m_Scouts.Contains(group)) || (m_Recon && m_Recon.Owns(group)) || (m_Airfield && m_Airfield.Cargo() && m_Airfield.Cargo().Crew() == group))
{
unit.ReservedForSupport = true;
BindSpecialSkills(group);
}
else m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group));
BindPhysical(unit);
unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000;
unit.AmmoReporting.Initialize(unit.Id, GetGame().GetWorld().GetWorldTime());
m_Journal.Write("REGISTER", string.Format("group %1", unit.Id));
AuditGroup(unit);
}
override void UnregisterGroup(SCR_AIGroup group)
@@ -363,6 +506,7 @@ class ERR_HQ : ERR_CommandService
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != group) continue;
if (unit.Detection) { unit.Detection.GetOnEnemyDetected().Remove(Detected); unit.Detection = null; }
ReleaseUnit(unit, GetGame().GetWorld().GetWorldTime());
unit.Group = null;
unit.Utility = null;
@@ -443,7 +587,7 @@ class ERR_HQ : ERR_CommandService
protected void Observe(ERR_HQUnit unit, float now)
{
if (!unit.Utility || unit.Uplink || now < unit.NextReport) return;
unit.NextReport = now + m_Mode.Settings().Get("reportIntervalSeconds") * 1000;
unit.NextReport = now + m_Mode.Settings().Runtime("reportIntervalSeconds") * 1000;
array<ref ERR_HQVisualContact> samples = {};
ERR_HQObservation.Gather(unit.Utility, now, samples);
if (samples.IsEmpty()) return;
@@ -572,7 +716,7 @@ class ERR_HQ : ERR_CommandService
if (!m_Watch.InvestigationDue(entry.SourceId, now)) continue;
ERR_HQUnit best;
ERR_HQRoute bestRoute;
float distance = m_Mode.Settings().Get("responseRadiusMetres") + 1;
float distance = m_Mode.Settings().Runtime("responseRadiusMetres") + 1;
int first = -1;
int second = -1;
foreach (ERR_HQUnit existing : m_Units)
@@ -616,7 +760,10 @@ class ERR_HQ : ERR_CommandService
// Delivered scalar body evidence survives native corpse cleanup during radio transit.
// Memory still applies its own entity-identity rules; casualty accounting needs no live target.
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY && m_Watch.IngestBody(report, now))
m_Bodies.ConfirmDelivered(report.Target);
{
m_Bodies.ConfirmDelivered(report.Target); m_Recovery.ReportBody(report);
m_Journal.Write("AGGRESSION", string.Format("identified source=%1 slot=%2 bodies=%3 aggression=%4/30 hunter allowance=%5/15", report.CasualtySourceId, report.CasualtySlot, m_Watch.IdentifiedBodies(), m_Watch.Aggression(), m_Watch.HunterTarget()), report.MessageId);
}
if (!m_Memory.Ingest(report, now)) return;
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position);
m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId);
@@ -659,7 +806,7 @@ class ERR_HQ : ERR_CommandService
{
ERR_HQTask task = unit.PendingTask;
unit.CancelPending();
if (!task || task.ReleaseOnly) return;
if (!task || task.ReleaseOnly || !task.SecuritySite.IsEmpty()) return;
if (task.Investigation()) { FinishWatch(task, now); return; }
foreach (ERR_HQUnit other : m_Units)
{
@@ -687,7 +834,7 @@ class ERR_HQ : ERR_CommandService
{
if (!WatchTaskValid(task)) { LostOrder(unit, now); return; }
}
else
else if (task.SecuritySite.IsEmpty())
{
ERR_HQBelief belief = m_Memory.GetById(task.BeliefId);
if (!belief || belief.BeliefRevision != task.BeliefRevision
@@ -699,7 +846,7 @@ class ERR_HQ : ERR_CommandService
int deliveryId;
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
ResourceName waypointResource = ERR_Resources.SearchWaypoint();
if (task.RoadIntercept) waypointResource = ERR_Resources.DefendWaypoint();
if (task.RoadIntercept || !task.SecuritySite.IsEmpty()) waypointResource = ERR_Resources.DefendWaypoint();
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(waypointResource, ERR_Actors.Ground(task.Destination)));
if (!waypoint) { LostOrder(unit, now); m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
unit.Release();
@@ -718,7 +865,7 @@ class ERR_HQ : ERR_CommandService
protected void Sweep(ERR_HQUnit unit, float now)
{
ERR_HQTask task = unit.Task;
if (!task || task.RoadIntercept || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return;
if (!task || !task.SecuritySite.IsEmpty() || task.RoadIntercept || !unit.Assignment || task.SweepQueued || !unit.Waypoint || !unit.Eligible(m_Mode)) return;
if (Fighting(unit) || vector.DistanceXZ(unit.Group.GetOrigin(), task.Destination) > 40)
{ task.ArrivedAt = -1; return; }
// Quiet combat state alone does not establish a negative search. Use this
@@ -866,8 +1013,12 @@ class ERR_HQ : ERR_CommandService
}
protected void PlanConvoy(float now)
{
if (!m_ConvoyTask || m_ConvoyTask.Stopped || !m_ConvoyTask.Ready) return;
foreach (ERR_ConvoyVehicle vehicle : m_ConvoyTask.Vehicles)
for (int i = 0; i < m_Convoys.Count(); i++) PlanConvoyTask(m_Convoys.At(i), now);
}
protected void PlanConvoyTask(ERR_ConvoyTask task, float now)
{
if (!task || task.Stopped || !task.Ready) return;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
if (vehicle.Released || vehicle.Arrived || vehicle.SupplyToken > 0 || !vehicle.Registered || !vehicle.Group) continue;
foreach (ERR_HQUnit unit : m_Units)
@@ -880,11 +1031,11 @@ class ERR_HQ : ERR_CommandService
if (vehicle.Combat.Held && contact.MessageId == vehicle.Combat.ReportId) break;
if (contact.ObservedAt < vehicle.Combat.ObservedAt) break;
}
else if (!vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break;
else if (vehicle.Combat.ReportId <= 0 || !vehicle.Combat.Held || now - vehicle.Combat.ObservedAt < 60000) break;
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
if (!route) break;
ERR_ConvoyCombatOrder order = new ERR_ConvoyCombatOrder();
order.CommandId = m_Journal.Message(); order.SourceId = m_ConvoyTask.Receipt.SourceId;
order.CommandId = m_Journal.Message(); order.SourceId = task.Receipt.SourceId;
order.VehicleIndex = vehicle.Index; order.Group = unit.Group;
order.Hold = contact != null; order.IssuedAt = now; order.ExpiresAt = now + 30000;
order.ReportId = vehicle.Combat.ReportId; order.ObservedAt = vehicle.Combat.ObservedAt;
@@ -913,7 +1064,11 @@ class ERR_HQ : ERR_CommandService
// starve a slower radio downlink. New threat evidence always invalidates RESUME.
bool current = !order.Hold && !contact;
if (order.Hold && contact) current = contact.ObservedAt >= order.ObservedAt;
if (current) accepted = m_Convoys.ApplyCombatOrder(m_ConvoyTask, order, now);
if (current)
{
if (contact && order.Hold) order.Position = contact.Position;
accepted = m_Convoys.ApplyCombatOrder(m_Convoys.Find(order.SourceId), order, now);
}
}
if (accepted) m_Journal.Write("CONVOY ORDER DELIVERED", "original crew applied radio travel command", order.CommandId);
else if (order) m_Journal.Write("CONVOY ORDER REJECTED", "stale evidence or unavailable original crew; current order retained", order.CommandId);
@@ -922,9 +1077,9 @@ class ERR_HQ : ERR_CommandService
protected void Assign(float now)
{
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Get("supportSquadLimit"));
int globalLimit = m_Watch.SquadLimit(m_Mode.Settings().Runtime("supportSquadLimit"));
int limit = ERR_HQPursuitRules.Limit(globalLimit);
int radius = m_Mode.Settings().Get("responseRadiusMetres");
int radius = m_Mode.Settings().Runtime("responseRadiusMetres");
int assigned = Assigned();
int roadQueries;
// Snapshot bounded own-force eligibility once per five-second planning pass.
@@ -944,8 +1099,8 @@ class ERR_HQ : ERR_CommandService
if (!unit.PlanEligible || assigned > globalLimit) { FinishWatch(unit.Task, now); FinishWatch(unit.PendingTask, now); unit.Release(); assigned--; continue; }
if (unit.Task && now >= unit.Task.ExpiresAt) { FinishWatch(unit.Task, now); unit.ReleaseAssignment(); }
if (unit.PendingTask && now >= unit.PendingTask.ExpiresAt) { FinishWatch(unit.PendingTask, now); unit.CancelPending(); }
if (unit.Task && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment();
if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending();
if (unit.Task && unit.Task.SecuritySite.IsEmpty() && !unit.Task.Investigation() && !m_Memory.GetById(unit.Task.BeliefId)) unit.ReleaseAssignment();
if (unit.PendingTask && !unit.PendingTask.ReleaseOnly && unit.PendingTask.SecuritySite.IsEmpty() && !unit.PendingTask.Investigation() && !m_Memory.GetById(unit.PendingTask.BeliefId)) unit.CancelPending();
}
assigned = Assigned();
m_HomeAvailable.Clear();
@@ -1039,11 +1194,27 @@ class ERR_HQ : ERR_CommandService
}
}
override void Maintain(float now)
{
// Aircraft already airborne keep flying even when the server is empty.
m_Recon.Tick(now);
m_AirAssault.Maintain(now);
m_Airfield.Maintain(now);
}
override void Tick(float now)
{
if (!Replication.IsServer()) return;
m_Memory.Tick(now);
m_Equipment.Tick(now);
foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills)
{
if (!specialSkill) continue;
SCR_AIGroup specialGroup = ERR_Actors.Group(specialSkill.GetOwner());
if (ERR_EnemyEquipmentRules.Eligible(m_Mode, specialGroup, specialSkill.GetOwner())) specialSkill.ERR_BindEnemySkill(m_Mode, specialGroup);
else specialSkill.ERR_ReleaseEnemySkill(m_Mode);
}
m_Hunters.Tick(now);
m_ObjectiveDefence.Tick(now);
m_Convoys.Tick(now);
AutomaticConvoy(now);
m_Bodies.TickBudget(now);
@@ -1053,6 +1224,11 @@ class ERR_HQ : ERR_CommandService
for (int i = m_Units.Count() - 1; i >= 0; i--)
{
ERR_HQUnit unit = m_Units[i];
if (!unit.Detection && unit.Utility && unit.Group)
{
unit.Detection = unit.Utility.m_Perception;
if (unit.Detection) unit.Detection.GetOnEnemyDetected().Insert(Detected);
}
if (unit.Uplink)
{
int delivered = unit.Uplink.Advance(now, m_Journal);
@@ -1084,12 +1260,23 @@ class ERR_HQ : ERR_CommandService
m_Resupply.Tick(now);
m_Logistics.Tick(now);
m_Mortars.Tick(now);
m_AirAssault.Tick(now);
m_Airfield.Tick(now); m_Recovery.Tick(now);
m_Journal.Flush();
}
override void Stop()
{
m_Hunters.Stop(); m_ObjectiveDefence.Stop();
foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode);
m_SpecialSkills.Clear();
foreach (ERR_HQUnit detector : m_Units) if (detector.Detection) detector.Detection.GetOnEnemyDetected().Remove(Detected);
m_Airfield.Stop(); m_Recovery.Stop();
m_AirAssault.Stop();
m_Recon.Stop();
m_Scouts.Stop();
m_ConvoyStartup.Stop();
if (m_SupplyConvoys) m_SupplyConvoys.Stop();
m_Resupply.Stop();
m_Convoys.Stop();
m_Equipment.Stop();
@@ -45,7 +45,7 @@ class ERR_HQAdminProvider : ERR_AdminProvider
protected void ConvoyAutoSetting(ERR_GameMode mode, array<string> parts, array<string> replies)
{
string key = "automaticConvoyEnabled";
string effect = "Applies next round only; current startup and manual convoy start unchanged. At most one finite initial convoy; losses are not replaced.";
string effect = "Applies next round only; current startup and manual convoy start unchanged. The saved convoy count determines the finite initial fleet; losses are not replaced.";
string range = "0 disabled, 1 enabled (default)";
if (parts.Count() == 4 && parts[3] == "show")
{ replies.Insert(key + "=" + mode.Settings().Get(key).ToString() + " saved for next round. " + effect); return; }
@@ -55,8 +55,38 @@ class ERR_HQAdminProvider : ERR_AdminProvider
else if (parts.Count() == 4 && parts[3] == "help") replies.Insert(range + ". " + effect);
else replies.Insert("Invalid value or settings could not be saved; previous setting retained. " + range + ". " + effect);
}
protected void CountSetting(ERR_GameMode mode, string arguments, array<string> replies)
{
array<string> countParts = {}; arguments.Split(" ", countParts, true);
int offset = 1;
if (countParts[0] == "hq") offset = 3;
if (countParts.Count() == offset || (countParts.Count() == offset + 1 && countParts[offset] == "show"))
replies.Insert("Convoy count saved for next round: " + ERR_Settings.SelectionText(mode.Settings().Get("convoyCount")) + ". " + m_HQ.ConvoyStatus());
else
{
int count;
if (countParts.Count() == offset + 2 && countParts[offset] == "set" && ERR_CommandArguments.Selection("convoyCount", countParts[offset + 1], count) && mode.Settings().Set("convoyCount", count))
replies.Insert("Convoy count saved for next round: " + ERR_Settings.SelectionText(count) + "; current convoys unchanged.");
else replies.Insert("Use convoys show|set 0..20|random. Changes apply next round; lost convoys never respawn.");
}
}
override bool Execute(ERR_GameMode mode, string arguments, array<string> replies)
{
if (arguments == "hq heli show") { replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status()); return true; }
if (arguments == "hq heli return") { m_HQ.Recon().Return(); replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status()); return true; }
if (arguments.IndexOf("hq heli timer set ") == 0)
{
array<string> timerParts = {}; arguments.Split(" ", timerParts, true);
int minutes;
if (timerParts.Count() == 5 && ERR_CommandArguments.Integer(timerParts[4], minutes) && m_HQ.Recon().SetDelay(minutes)) replies.Insert(m_HQ.Recon().Status() + "; " + m_HQ.AirAssault().Status() + "; " + m_HQ.Airfield().Status() + "; " + m_HQ.Recovery().Status());
else replies.Insert("Use /esc hq heli timer set 1..1440 (minutes). Save failure preserves the current countdown.");
return true;
}
if (arguments == "convoys" || arguments.IndexOf("convoys ") == 0 || arguments == "hq convoy count" || arguments.IndexOf("hq convoy count ") == 0)
{
CountSetting(mode, arguments, replies);
return true;
}
if (arguments == "hq convoy auto" || arguments.IndexOf("hq convoy auto ") == 0)
{
if (!m_HQ) { replies.Insert("HQ unavailable."); return true; }
@@ -91,10 +121,11 @@ class ERR_HQAdminProvider : ERR_AdminProvider
override bool Help(string topic, array<string> replies)
{
if (topic != "hq") return false;
replies.Insert("/esc convoys show|set 0..20|random | hq convoy count show|set N (default three; next round)");
replies.Insert("/esc hq show | hq memory | hq log show|next|set on|off | hq convoy show|start");
replies.Insert("/esc hq interval|squads|radius|stinger show|set N | hq convoy auto show|set 0|1|help");
replies.Insert("Stinger: 0 off, 1 every eligible soldier, 2..10000 one in N; applies only before each actor rolls.");
replies.Insert("Convoy auto defaults to 1; saves apply next round only. Auto and manual start share one finite initial deployment; losses are not replaced.");
replies.Insert("Convoy auto: default 1, next round, finite fleet. /esc hq heli show | hq heli timer set 1..1440 | hq heli return");
return true;
}
}
@@ -79,7 +79,7 @@ class ERR_HQBodyObserver
foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) known = true;
if (!known)
{
if (m_Groups.Count() >= 128) { Cap(); return; }
if (m_Groups.Count() >= 256) { Cap(); return; }
ERR_HQBodyScan created = new ERR_HQBodyScan(); created.Group = group;
m_Groups.Insert(created);
}
@@ -91,7 +91,7 @@ class ERR_HQBodyObserver
bool found;
foreach (ERR_HQBodyCandidate entry : m_Roster) if (entry.Corpse == actor) found = true;
if (found) continue;
if (m_Roster.Count() >= 128) { Cap(); return; }
if (m_Roster.Count() >= 4096) { Cap(); return; }
ERR_HQBodyCandidate candidate = new ERR_HQBodyCandidate(); candidate.Corpse = actor;
candidate.Roster = sampler; candidate.SourceId = sampler.InitialSourceId();
candidate.Slot = member; candidate.Strength = sampler.InitialStrength();
@@ -79,8 +79,8 @@ class ERR_HQCoverage
report.BeliefRevision, report.SweepCompleted, report.ContactFound, report.ObservedAt, now)) return false;
if (m_Messages.Contains(report.SourceId) && report.MessageId <= m_Messages.Get(report.SourceId)) return false;
// One uplink per source gives monotonic message order. Never evict replay
// watermarks; the mission already caps its total physical formations at 128.
if (!m_Messages.Contains(report.SourceId) && m_Messages.Count() >= 128) return false;
// watermarks; the mission already caps its total physical formations at 256.
if (!m_Messages.Contains(report.SourceId) && m_Messages.Count() >= 256) return false;
m_Messages.Set(report.SourceId, report.MessageId);
int key = ERR_HQCoverageRules.Key(report.Position);
if (m_Cells.Contains(key) && m_Cells.Get(key) >= report.ObservedAt) return false;
@@ -7,6 +7,7 @@ class ERR_HQVisualContact
bool Escapist;
string Travel = "unknown";
string Weapon = "unknown";
string RoadSurface = "road unknown";
}
class ERR_HQObservation
@@ -52,6 +53,7 @@ class ERR_HQObservation
seen.Entity = entity;
seen.Position = target.GetLastSeenPosition();
seen.ObservedAt = now - target.GetTimeSinceSeen() * 1000;
seen.RoadSurface = ERR_ScoutRoad.Describe(seen.Position);
SCR_ChimeraCharacter character = SCR_ChimeraCharacter.Cast(entity);
if (character)
{
@@ -117,6 +119,7 @@ class ERR_HQObservation
existing.ObservedAt = seen.ObservedAt;
existing.Travel = seen.Travel;
existing.Weapon = seen.Weapon;
existing.RoadSurface = seen.RoadSurface;
}
}
}
@@ -126,6 +129,7 @@ class ERR_HQObservation
// New observations must not silently inherit old equipment/travel details.
report.SeenEscapists = -1;
report.TravelMode = "unknown";
report.RoadStatus = "road unknown";
report.SeenWeapons = "unknown";
report.DetailsObservedAt = -1;
if (report.Distress)
@@ -143,6 +147,9 @@ class ERR_HQObservation
return;
report.DetailsObservedAt = anchor.ObservedAt;
report.TravelMode = anchor.Travel;
report.RoadStatus = anchor.RoadSurface;
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.Scouts()) hq.Scouts().DescribeMotion(report);
report.SeenWeapons = anchor.Weapon;
array<IEntity> counted = {};
array<string> weapons = {};
@@ -51,6 +51,7 @@ class ERR_HQReport
bool Distress;
int SeenEscapists = -1;
string TravelMode = "unknown";
string RoadStatus = "road unknown";
// Original formation identity captured by the local observer, only used after radio delivery.
string CasualtySourceId;
int CasualtySlot = -1;
@@ -72,6 +73,7 @@ class ERR_HQReport
copy.HasVelocity = HasVelocity;
copy.SeenEscapists = SeenEscapists;
copy.TravelMode = TravelMode;
copy.RoadStatus = RoadStatus;
copy.CasualtySourceId = CasualtySourceId;
copy.CasualtySlot = CasualtySlot;
copy.CasualtyStrength = CasualtyStrength;
@@ -118,7 +120,7 @@ class ERR_HQReport
int heading = Math.Round(azimuth);
motion = string.Format("%1 km/h, azimuth %2 degrees (%3)", Math.Round(speed * 3.6), heading % 360, TravelDirection());
}
string details = count + "; " + TravelMode + "; " + motion + "; seen weapons: " + SeenWeapons;
string details = count + "; " + TravelMode + "; " + RoadStatus + "; " + motion + "; seen weapons: " + SeenWeapons;
if (brief)
return details;
string velocity = "unknown";
@@ -1,6 +1,7 @@
// A radio order snapshots an inference. It never refreshes the source sighting.
class ERR_HQTask
{
string SecuritySite;
bool ReleaseOnly;
bool RoadIntercept;
ref ERR_HQTask ReplacesTask;
@@ -19,6 +20,7 @@ class ERR_HQTask
int ApproachBearing = -1;
string Name()
{
if (!SecuritySite.IsEmpty()) return "secure compromised site " + SecuritySite;
if (Investigation()) return "investigate missing patrol";
if (RoadIntercept) return "temporary infantry road interception";
if (Role == ERR_HQPursuitRole.INTERCEPT) return "intercept";
@@ -17,6 +17,8 @@ class ERR_HQStatusReport
class ERR_HQWatchRules
{
static int Aggression(int identifiedBodies) { return Math.Clamp(identifiedBodies / 3, 0, 30); }
static int Hunters(int aggression) { return (Math.Clamp(aggression, 0, 30) + 1) / 2; }
static bool Valid(ERR_HQStatusReport report, float now)
{
if (!report || report.SourceId.IsEmpty() || report.SourceId.Length() > 96 || report.MessageId <= 0)
@@ -66,6 +68,7 @@ class ERR_HQWatchEntry
int ConfirmedLosses;
ref ERR_HQAmmoSnapshot AmmoState;
ref ERR_HQDeliveredCasualties Casualties = new ERR_HQDeliveredCasualties();
ref array<int> IdentifiedSlots = {};
float LastBodyDeliveredAt = -1;
float ObservedAt = -1;
float LastDeliveredAt = -1;
@@ -80,6 +83,10 @@ class ERR_HQWatch
protected ref array<ref ERR_HQWatchEntry> m_Entries = {};
protected ref map<string, ERR_HQWatchEntry> m_BySource = new map<string, ERR_HQWatchEntry>();
protected int m_ConfirmedLosses;
protected int m_IdentifiedBodies;
int IdentifiedBodies() { return m_IdentifiedBodies; }
int Aggression() { return ERR_HQWatchRules.Aggression(m_IdentifiedBodies); }
int HunterTarget() { return ERR_HQWatchRules.Hunters(Aggression()); }
protected bool m_CapLogged;
int Count() { return m_Entries.Count(); }
ERR_HQWatchEntry Find(string sourceId) { return m_BySource.Get(sourceId); }
@@ -102,9 +109,9 @@ class ERR_HQWatch
protected ERR_HQWatchEntry Create(string sourceId, int strength)
{
// Closed identities remain tombstones: replays cannot recreate a formation.
if (m_Entries.Count() >= 128)
if (m_Entries.Count() >= 256)
{
if (!m_CapLogged) Print("Reintroduced [watch]: 128-formation check-in cap reached.");
if (!m_CapLogged) Print("Reintroduced [watch]: 256-formation check-in cap reached.");
m_CapLogged = true;
return null;
}
@@ -164,6 +171,9 @@ class ERR_HQWatch
if (entry && (entry.Strength != report.CasualtyStrength || now < entry.LastBodyDeliveredAt)) return false;
if (!entry) entry = Create(report.CasualtySourceId, report.CasualtyStrength);
if (!entry || !entry.Casualties.AcceptBody(report.CasualtySlot)) return false;
if (entry.IdentifiedSlots.Contains(report.CasualtySlot)) return false;
entry.IdentifiedSlots.Insert(report.CasualtySlot);
m_IdentifiedBodies++;
entry.LastBodyDeliveredAt = now;
ApplyLosses(entry);
return true;
@@ -211,6 +221,6 @@ class ERR_HQWatch
}
void Clear()
{
m_Entries.Clear(); m_BySource.Clear(); m_ConfirmedLosses = 0; m_CapLogged = false;
m_Entries.Clear(); m_BySource.Clear(); m_ConfirmedLosses = 0; m_IdentifiedBodies = 0; m_CapLogged = false;
}
}
@@ -0,0 +1,173 @@
class ERR_HunterVehicle : ERR_ConvoyVehicle
{
string Source;
float Deadline;
float NextOrder;
ref ERR_HQRoute Downlink;
vector Destination;
int SourceMessage;
override void Release(ERR_GameMode mode)
{
if (Released) return;
if (!mode || !mode.Active() || ERR_ConvoyRules.Captured(Entity, Group)) { super.Release(mode); return; }
Released = true; Downlink = null; ClearOrder();
ERR_ForceRecord record = mode.Forces().FindGroup(Group);
if (record) record.Pinned = false;
if (Group)
{
Group.SetPermanentLOD(-1);
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
ERR_HQ hq = ERR_HQ.Cast(mode.Command());
if (hq) hq.ReleaseRecovery(Group);
}
// Surviving original crew remain registered for skill, reports, intel and
// local combat. Only the driving task ends; on-foot survivors can serve HQ.
}
}
// An escalation allowance of fifteen physical hunter UAZs, separate from supply
// convoys. Losses remain physical; aggression never replenishes killed crews.
class ERR_HunterService : ERR_ConvoyService
{
protected ref array<ref ERR_HunterVehicle> m_Hunters = {};
protected float m_NextHunter;
protected float m_NextSpawn;
protected int m_Target;
int Deployed() { return m_Hunters.Count(); }
int ActiveHunters()
{
int count;
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Released) count++;
return count;
}
override string Status() { return string.Format("aggression UAZs: %1 active, %2/%3 deployed allowance (maximum 15)", ActiveHunters(), Deployed(), m_Target); }
protected bool StartHunter(ERR_HQ hq, float now)
{
if (m_Hunters.Count() >= m_Target || m_Hunters.Count() >= 15 || m_Mode.Forces().Count() >= 250) return false;
array<IEntity> players = {}; ERR_Actors.Players(players);
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int i = 0; i < m_Mode.Sites().Count(); i++)
{
ERR_SiteRuntime site = m_Mode.Sites().At((i + m_Hunters.Count()) % m_Mode.Sites().Count());
if (!site || !site.Definition || (site.Definition.Kind != ERR_LocationKind.RADIO && site.Definition.Kind != ERR_LocationKind.DEPOT && site.Definition.Kind != ERR_LocationKind.OUTPOST)) continue;
if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) continue;
vector position, angles;
vector seed = site.Definition.Position + Vector(Math.RandomFloat(-200, 200), 0, Math.RandomFloat(-200, 200));
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles) || !placement.Clear(position, angles[0])) continue;
bool nearPlayer;
foreach (IEntity player : players) if (player && vector.DistanceXZ(player.GetOrigin(), position) < 400) nearPlayer = true;
if (nearPlayer) continue;
ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString();
unit.Deadline = now + 30000; m_Hunters.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position));
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(0), position, angles));
if (unit.Group) m_Mode.Own(unit.Group);
if (unit.Entity) m_Mode.Own(unit.Entity);
if (!unit.Group || !unit.Entity || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{ unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native vehicle/crew spawn failed; allowance consumed"); return false; }
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(45);
ERR_HQ.Event("HUNTER SPAWN", string.Format("%1 reason=identified bodies %2 aggression=%3/30 allowance=%4/15 base=%5 position=%6 crew=%7", unit.Source, hq.Watch().IdentifiedBodies(), hq.Watch().Aggression(), m_Target, site.Definition.Id, position, unit.Members.Count()));
return true;
}
ERR_HQ.Event("HUNTER WAIT", "deployment deferred: friendly road obstructed, occupied, budget full or players within 400m");
return false;
}
protected bool AdmitHunter(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
if (!unit.CrewValid(m_Mode)) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != unit.Members.Count()) return false;
foreach (AIAgent agent : agents) if (!agent || !unit.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (!utility || !utility.m_VehicleMgr || !usage || !utility.m_VehicleMgr.TryAddVehicle(usage)) return false;
ERR_ForceRecord record = m_Mode.Forces().Register(unit.Source, ERR_ForceKind.CONVOY, unit.Entity.GetOrigin(), unit.Members.Count(), null, unit.Group, now);
if (!record) return false;
record.Pinned = true; unit.Registered = true;
m_Mode.RegisterGroup(unit.Group); hq.ReserveGroup(unit.Group);
utility.AddUsableVehicle(usage); unit.Group.SetPermanentLOD(0); unit.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); }
ERR_HQ.Event("HUNTER READY", unit.Source + " driver and gunner seated; HQ radio registration active");
return true;
}
protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
if (now < unit.NextOrder || unit.Downlink) return;
unit.NextOrder = now + 30000;
array<ref ERR_HQReport> reports = {}; hq.SupportContacts(reports, now);
ERR_HQBelief best;
foreach (ERR_HQReport report : reports)
{
ERR_HQBelief belief = hq.Memory().Get(report.Target);
if (belief && belief.Actionable() && (!best || belief.ObservedAt > best.ObservedAt)) best = belief;
}
vector destination, angles;
string reason = "road patrol between friendly objectives";
unit.SourceMessage = 0;
if (best)
{
if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 500)) return;
unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString();
}
else
{
if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return;
bool found;
for (int i = 0; i < m_Mode.Sites().Count(); i++)
{
ERR_SiteRuntime site = m_Mode.Sites().At(Math.RandomInt(0, m_Mode.Sites().Count()));
if (!site || !site.Definition || !ERR_ObjectiveDefence.Objective(site.Definition.Kind) || (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction())) continue;
if (ERR_SupplyConvoySystem.ApproachDirected(site.Definition.Position, destination, angles) && vector.DistanceXZ(destination, unit.Entity.GetOrigin()) > 200) { found = true; break; }
}
if (!found) return;
}
unit.Destination = destination;
unit.Downlink = hq.Downlink(unit.Group, "HUNTER", unit.Source + " " + reason + " destination=" + destination.ToString(), unit.SourceMessage);
}
override void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode || !m_Mode.Active() || m_Mode != ERR_GameMode.Get() || m_Mode.Phase() <= ERR_Phase.PRISON || now < m_NextHunter) return;
m_NextHunter = now + 1000;
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command()); if (!hq) return;
m_Target = hq.Watch().HunterTarget();
if (now >= m_NextSpawn && Deployed() < m_Target) { m_NextSpawn = now + 30000; StartHunter(hq, now); }
foreach (ERR_HunterVehicle unit : m_Hunters)
{
if (unit.Released) continue;
if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " player captured vehicle/crew; orders removed"); continue; }
if (!unit.Registered)
{
if (AdmitHunter(unit, hq, now)) continue;
if (now >= unit.Deadline) { InspectReadiness(unit, now); unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native readiness timed out"); }
continue;
}
if (!unit.CrewValid(m_Mode)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " vehicle or original crew no longer operational; no replacement spawn"); continue; }
if (unit.Downlink)
{
int delivered = unit.Downlink.Advance(now, hq.Journal());
if (delivered != 0)
{
if (delivered > 0)
{
unit.ClearOrder();
unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), unit.Destination));
if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); }
ERR_HQ.Event("HUNTER ORDER", unit.Source + " radio delivered; destination=" + unit.Destination.ToString(), unit.SourceMessage);
}
unit.Downlink = null;
}
}
QueueDestination(unit, hq, now);
}
}
override void Stop()
{
m_Stopped = true;
foreach (ERR_HunterVehicle unit : m_Hunters) { unit.Downlink = null; unit.Release(m_Mode); }
}
}
@@ -16,12 +16,15 @@ class ERR_InfantryAmmoAccess
ActionsManagerComponent manager = ActionsManagerComponent.Cast(donor.FindComponent(ActionsManagerComponent));
if (!manager || !manager.IsEnabled()) return false;
array<UserActionContext> contexts = {}; manager.GetContextList(contexts);
if (contexts.IsEmpty() || contexts.Count() > 64) return false;
// The stock Mi-8 alone authors 82 root contexts. Keep a finite bound
// that includes its real cargo-door action as well as ordinary trucks.
if (contexts.IsEmpty() || contexts.Count() > 128) return false;
foreach (UserActionContext context : contexts)
{
if (!context) return false;
array<BaseUserAction> actions = {}; context.GetActionsList(actions);
if (actions.Count() > 16) return false;
// Native Mi-8 cargo-door context has 38 actions (including seat switches).
if (actions.Count() > 64) return false;
bool storage;
foreach (BaseUserAction action : actions)
if (action && action.GetOwner() == donor && SCR_OpenVehicleStorageAction.Cast(action)) storage = true;
@@ -148,7 +148,8 @@ class ERR_InfantryAmmoTransfer
float speed = physics.GetVelocity().Length();
float angular = physics.GetAngularVelocity().Length();
float distance = vector.Distance(m_Donor.GetOrigin(), m_Recipient.GetOrigin());
if (!(speed >= 0 && speed <= 0.25 && angular >= 0 && angular <= 0.1 && distance >= 0 && distance <= 4)) return false;
if (!(speed >= 0 && speed <= 0.25 && angular >= 0 && angular <= 0.1 && distance >= 0 && distance <= 15)) return false;
if (distance > 4 && !m_Donor.FindComponent(VehicleHelicopterSimulation)) return false;
if (!Compatible(m_Recipient, m_Magazine)) return false;
m_Driver = driver; m_DonorRecord = donorRecord; m_RecipientRecord = recipientRecord;
return true;
@@ -81,7 +81,7 @@ class ERR_InfantryResupply
|| !m_Truck.CrewValid(m_Mode) || ERR_ConvoyRules.Captured(m_Truck.Entity, m_Truck.Group)) return false;
ERR_ForceRecord record = m_Mode.Forces().FindGroup(m_Truck.Group);
return record && record.Physical && !record.Eliminated && record.Kind == ERR_ForceKind.CONVOY
&& m_Convoy.Receipt.Records.Count() == 6 && record.Id == m_Convoy.Receipt.Records[1];
&& m_Convoy.Receipt.Records.Count() >= 6 && record.Id == m_Convoy.Receipt.Records[1];
}
protected bool Quiet(ERR_HQUnit unit)
{
@@ -37,6 +37,7 @@ class ERR_MortarBattery
ref ERR_MortarRefresh Refresh;
bool RefreshRejected; // latched through an already committed projectile
protected ref ERR_MortarReadiness m_Readiness;
protected ref ERR_MortarStation m_Station;
protected ref ERR_MortarSupplyTransfer m_Transfer;
protected ref array<IEntity> m_AttemptedShells = {};
protected bool m_Stopped;
@@ -316,6 +317,7 @@ class ERR_MortarBattery
m_Stopped = true;
Cancel("HQ stopped");
if (m_Readiness) m_Readiness.Stop();
if (m_Station) m_Station.Stop();
}
ERR_MortarSupplyState SupplyState(float now)
@@ -515,6 +517,7 @@ class ERR_MortarBattery
void Update(float now)
{
if (m_Stopped) return;
if (m_Station) m_Station.Update(now);
if (!StartupActivated)
{
if (!SpawnFailure.IsEmpty()) return;
@@ -522,6 +525,8 @@ class ERR_MortarBattery
ERR_MortarSupplyState startup = m_Readiness.Poll(now);
if (startup != ERR_MortarSupplyState.READY) return;
StartupActivated = true;
m_Station = new ERR_MortarStation(this);
m_Station.Update(now);
}
if (m_Transfer) m_Transfer.Poll(now);
if (!Operational())
@@ -15,7 +15,7 @@ class ERR_MortarLogisticsRules
static bool Near(float distance) { return distance >= 0 && distance <= 12; }
static bool ActiveTrip(bool server, bool stopped, int trip, int current, bool fresh, bool ownershipValid) { return server && !stopped && trip > 0 && trip == current && fresh && ownershipValid; }
}
// One finite western-cache courier, owned and ticked exclusively by HQ.
// One finite depot courier shared by the authored batteries, owned and ticked by HQ.
class ERR_MortarLogistics
{
protected ERR_HQ m_HQ;
@@ -94,11 +94,14 @@ class ERR_MortarLogistics
m_Started = true;
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(m_Mode.FindComponent(ERR_ScenarioComponent));
if (!scenario || !scenario.Sites()) return false;
// The retired vehicle-cache fixture retains its original courier source.
// Production uses the existing guard outpost; no replacement car/cache is created.
m_Site = scenario.Sites().Find("everon-supply-2");
if (!m_Site) m_Site = scenario.Sites().Find("everon-outpost-1");
if (!m_Site || !m_Site.Group || !m_Site.PatrolCycle || !scenario.Sites().Owns(m_Site.Container)) return false;
m_Cache = m_Site.Container; m_Group = m_Site.Group;
array<ref ERR_MortarBattery> batteries = {}; m_HQ.Mortars().Batteries(batteries);
if (batteries.Count() != 1) return false;
if (batteries.IsEmpty()) return false;
m_Battery = batteries[0]; m_OriginalGun = m_Battery.Gun; m_OriginalBatteryGroup = m_Battery.Group;
InventoryStorageManagerComponent inventory = Inventory(m_Cache);
if (!inventory || !inventory.CanInsertResource(ERR_MortarBattery.SHELL, EStoragePurpose.PURPOSE_DEPOSIT)) return false;
@@ -125,6 +128,27 @@ class ERR_MortarLogistics
}
return true;
}
// Select only between trips. A pending radio report or native item transfer
// keeps its original battery identity and cannot be retargeted in flight.
protected void SelectBattery()
{
if (m_State != 1 || m_Report || m_Move || m_Order) return;
if (m_Battery && TargetAvailable() && ERR_MortarLogisticsRules.Need(m_Battery.CountShells())) return;
array<ref ERR_MortarBattery> batteries = {}; m_HQ.Mortars().Batteries(batteries);
ERR_MortarBattery selected;
float nearest = float.MAX;
foreach (ERR_MortarBattery candidate : batteries)
{
if (!candidate.StartupActivated || candidate.Stopped() || !candidate.Operational() || !ERR_MortarLogisticsRules.Need(candidate.CountShells())) continue;
float distance = vector.DistanceXZ(m_Cache.GetOrigin(), candidate.Position);
if (distance >= nearest) continue;
nearest = distance; selected = candidate;
}
if (!selected || selected == m_Battery) return;
m_Battery = selected; m_OriginalGun = selected.Gun; m_OriginalBatteryGroup = selected.Group;
m_BatteryMembers.Clear();
m_NextReport = 0;
}
protected bool OriginalGroup()
{
if (!m_Site || m_Site.Group != m_Group || m_Site.Container != m_Cache || !m_Group || !m_Cache || m_Group.GetPlayerCount(true) > 0 || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false;
@@ -294,11 +318,12 @@ class ERR_MortarLogistics
foreach (IEntity originalShell : m_Stock) if (!originalShell || !shells.Contains(originalShell)) failed = true;
if (m_Stock.Count() != INITIAL_SHELLS) failed = true;
if (failed) { Audit("STARTUP FAILED", "one-time native stock allocation incomplete; no refill"); Stop(); return; }
m_State = 1; Audit("READY", "12 distinct original HE shells physically confirmed in existing western cache");
m_State = 1; Audit("READY", "12 distinct original HE shells physically confirmed in existing depot cache");
}
if (m_State == 1)
{
if (m_Move) { m_Move.Poll(now); if (m_Move.Unresolved()) return; m_Move = null; }
SelectBattery();
if (!OriginalGroup() || !BindBatteryMembers() || !TargetAvailable()) return;
bool available; foreach (IEntity stocked : m_Stock) if (Contains(m_Cache, stocked) && !m_AttemptedPickup.Contains(stocked)) available = true;
if (!available) return;
@@ -307,7 +332,7 @@ class ERR_MortarLogistics
int nextTrip = m_Trip + 1;
if (!m_HQ.ReserveLogistics(m_Group, nextTrip)) return;
m_Trip = nextTrip; m_ConsumedMessage = m_Report.MessageId;
m_Order = m_HQ.Downlink(m_Group, "MORTAR SUPPLY", "collect finite western-cache HE; walk to reported battery", m_Report.MessageId);
m_Order = m_HQ.Downlink(m_Group, "MORTAR SUPPLY", "collect finite depot HE; walk to reported battery", m_Report.MessageId);
m_State = 2;
if (!m_Order) { Finish("no native courier downlink"); return; }
Audit("QUEUED", "original patrol reserved pending physical radio command delivery");
@@ -2,7 +2,7 @@
class ERR_MortarSalvo
{
static const int ROUNDS = 8;
static const float RADIUS_METRES = 40;
static const float RADIUS_METRES = 80;
static const float TIMEOUT_MS = 120000;
static vector Offset(int site, int order, int shot)
@@ -0,0 +1,126 @@
// A permanent assignment to the original physical gun, independent of fire orders.
class ERR_MortarStation
{
protected ERR_MortarBattery m_Battery;
protected ref array<SCR_CharacterControllerComponent> m_Controllers = {};
protected ref SCR_AIGetInVehicle m_Mount;
protected IEntity m_Gunner;
protected float m_NextMount;
protected bool m_Stopped;
void ERR_MortarStation(ERR_MortarBattery battery) { m_Battery = battery; }
IEntity Gunner() { return m_Gunner; }
bool GunIntact()
{
if (!m_Battery || !m_Battery.Gun) return false;
DamageManagerComponent damage = DamageManagerComponent.Cast(m_Battery.Gun.FindComponent(DamageManagerComponent));
return damage && !damage.IsDestroyed();
}
bool Holds(IEntity actor)
{
return Replication.IsServer() && !m_Stopped && GunIntact() && ERR_HQ.MissionActive() && actor && !ERR_Actors.Player(actor) && ERR_Actors.Group(actor) == m_Battery.Group;
}
bool HoldsSeat(IEntity actor, IEntity vehicle)
{
return Holds(actor) && vehicle == m_Battery.Gun && CompartmentAccessComponent.GetVehicleIn(actor) == vehicle;
}
bool HoldsPosition(IEntity actor) { return Holds(actor) && actor != m_Gunner && ERR_Actors.CanFight(actor); }
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Mount) m_Mount.Complete();
m_Mount = null;
foreach (SCR_CharacterControllerComponent controller : m_Controllers)
if (controller) controller.ERR_ClearMortarStation(this);
m_Controllers.Clear(); m_Gunner = null;
}
void Update(float now)
{
if (m_Stopped || !Replication.IsServer()) return;
if (!GunIntact())
{
Stop();
ERR_HQ hq = ERR_HQ.Get();
if (hq) hq.ReleaseMortarCrew(m_Battery.Group);
m_Battery.Audit("CREW RELEASED", "original mortar destroyed or removed; surviving crew may leave");
return;
}
if (!m_Battery.Group || !ERR_HQ.MissionActive()) return;
array<AIAgent> agents = {}; m_Battery.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
if (!agent || !agent.GetControlledEntity() || ERR_Actors.Player(agent.GetControlledEntity())) continue;
SCR_CharacterControllerComponent controller = SCR_CharacterControllerComponent.Cast(agent.GetControlledEntity().FindComponent(SCR_CharacterControllerComponent));
if (!controller || m_Controllers.Contains(controller)) continue;
controller.ERR_SetMortarStation(this); m_Controllers.Insert(controller);
}
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Battery.Gun.FindComponent(SCR_AIVehicleUsageComponent));
if (!usage || !usage.GetTurretCompartmentSlot()) return;
TurretCompartmentSlot seat = usage.GetTurretCompartmentSlot();
IEntity occupant = seat.GetOccupant();
if (occupant)
{
if (Holds(occupant) && ERR_Actors.CanFight(occupant)) m_Gunner = occupant;
return; // never evict a player, incapacitated occupant or unrelated crew
}
if (!Holds(m_Gunner) || !ERR_Actors.CanFight(m_Gunner))
{
if (m_Mount) m_Mount.Complete();
m_Mount = null; m_Gunner = null;
foreach (AIAgent candidate : agents)
{
if (!candidate) continue;
IEntity actor = candidate.GetControlledEntity();
if (Holds(actor) && actor != m_Battery.RadioOperator && ERR_Actors.CanFight(actor)) { m_Gunner = actor; break; }
}
}
if (!m_Gunner || m_Battery.Activity || now < m_NextMount) return;
SCR_ChimeraAIAgent gunnerAgent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_Gunner));
if (!gunnerAgent || !gunnerAgent.m_UtilityComponent) return;
// Native deselection can reset terminal action state to EVALUATED. Queue
// membership, rather than that transient state, determines whether it is pending.
array<ref AIActionBase> actions = {}; gunnerAgent.m_UtilityComponent.GetActions(actions);
if (m_Mount && actions.Contains(m_Mount)) return;
// Native walking/boarding only; no teleport, respawn, or ammunition creation.
m_Mount = new SCR_AIGetInVehicle(gunnerAgent.m_UtilityComponent, null, m_Battery.Gun, seat, EAICompartmentType.Turret, 1000);
gunnerAgent.m_UtilityComponent.AddAction(m_Mount);
m_NextMount = now + 10000;
}
}
modded class SCR_CharacterControllerComponent
{
protected ERR_MortarStation m_ERRMortarStation;
protected bool m_ERRMortarSpeedHeld;
void ERR_SetMortarStation(ERR_MortarStation station) { m_ERRMortarStation = station; }
void ERR_ClearMortarStation(ERR_MortarStation station)
{
if (m_ERRMortarStation != station) return;
m_ERRMortarStation = null;
if (m_ERRMortarSpeedHeld) OverrideMaxSpeed(-1);
m_ERRMortarSpeedHeld = false;
}
ERR_MortarStation ERR_MortarStationDuty() { return m_ERRMortarStation; }
protected override void OnApplyControls(IEntity owner, float timeSlice)
{
super.OnApplyControls(owner, timeSlice);
// Keep support crew in their positions; aiming, firing, radio and damage remain active.
bool held = m_ERRMortarStation && m_ERRMortarStation.HoldsPosition(owner);
if (held) OverrideMaxSpeed(0);
else if (m_ERRMortarSpeedHeld) OverrideMaxSpeed(-1);
m_ERRMortarSpeedHeld = held;
}
}
modded class SCR_AIGetOutVehicle
{
override float CustomEvaluate()
{
if (m_Utility && m_Utility.m_OwnerEntity)
{
SCR_CharacterControllerComponent controller = SCR_CharacterControllerComponent.Cast(m_Utility.m_OwnerEntity.FindComponent(SCR_CharacterControllerComponent));
if (controller && controller.ERR_MortarStationDuty() && controller.ERR_MortarStationDuty().HoldsSeat(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0;
}
return super.CustomEvaluate();
}
}
@@ -30,12 +30,32 @@ class ERR_MortarSystem
{
if (!Replication.IsServer() || m_Prepared || m_Stopped) return false;
m_Prepared = true;
vector position = ERR_Actors.Ground("6000 0 7000");
if (position[1] < 1) return false;
ERR_SiteService sites = ERR_GameMode.Get().Sites();
if (!sites) { Print("Reintroduced [mortars]: site service unavailable", LogLevel.ERROR); return false; }
for (int index = 0; index < sites.Count(); index++)
{
ERR_SiteRuntime site = sites.At(index);
if (site.Definition.Kind != ERR_LocationKind.MORTAR) continue;
PrintFormat("Reintroduced [mortars]: preparing %1 location=%2 authored=%3", site.Definition.Id, site.Location.Position(), site.Definition.Position);
if (!CreateBattery(site.Location.Position(), m_Batteries.Count() + 1)) return false;
}
#ifdef WORKBENCH
// Old geometry-dependent fixtures explicitly load LegacySites.conf.
string fixture;
if (m_Batteries.IsEmpty() && System.GetCLIParam("errSmokeOutcome", fixture) && !fixture.IsEmpty())
return CreateBattery("6000 0 7000", 1);
#endif
return !m_Batteries.IsEmpty();
}
protected bool CreateBattery(vector authoredPosition, int id)
{
vector position = ERR_Actors.Ground(authoredPosition);
if (position[1] < 1) { PrintFormat("Reintroduced [mortars]: below land at %1", position); return false; }
IEntity gun = ERR_Actors.Spawn(ERR_MortarBattery.GUN, position);
SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn(MortarGroup(), ERR_Actors.Ground(position + "0 0 4")));
if (!gun || !group)
{
PrintFormat("Reintroduced [mortars]: creation failed gun=%1 group=%2 at %3", gun != null, group != null, position);
ERR_Actors.DeleteSpawned(gun);
ERR_MortarReadiness.RetireGroup(group);
ERR_Actors.DeleteSpawned(group);
@@ -43,6 +63,7 @@ class ERR_MortarSystem
}
if (group.GetFaction() != ERR_GameMode.Get().EnemyFaction())
{
Print("Reintroduced [mortars]: crew faction mismatch", LogLevel.ERROR);
ERR_Actors.DeleteSpawned(gun);
ERR_MortarReadiness.RetireGroup(group);
ERR_Actors.DeleteSpawned(group);
@@ -51,7 +72,7 @@ class ERR_MortarSystem
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(gun.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(ERR_GameMode.Get().EnemyFaction());
ERR_MortarBattery battery = new ERR_MortarBattery();
battery.Bind(1, group, gun, GetGame().GetWorld().GetWorldTime());
battery.Bind(id, group, gun, GetGame().GetWorld().GetWorldTime());
m_Batteries.Insert(battery);
ERR_GameMode mode = ERR_GameMode.Get();
mode.Own(gun);
@@ -0,0 +1,114 @@
#ifdef WORKBENCH
modded class ERR_AirAssaultLoad
{
int ERR_TestGrounded()
{
int count;
foreach (ERR_AirPassenger passenger : m_Passengers)
if (passenger.Jumped && ERR_Actors.CanFight(passenger.Actor) && !CompartmentAccessComponent.GetVehicleIn(passenger.Actor) && passenger.Actor.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(passenger.Actor.GetOrigin()) < 3) count++;
return count;
}
int ERR_TestReleased()
{
int count;
foreach (ERR_AirAssaultTeam team : m_Teams) { if (team.Released) count++;
}
return count;
}
void ERR_TestCoverEnd(float now) { m_CoverUntil = now;
}
}
modded class ERR_GameMode
{
protected int m_ERRAirStage;
protected float m_ERRAirDeadline;
protected ERR_AirAssaultLoad m_ERRAirLoad;
protected Vehicle m_ERRAirframe;
protected bool m_ERRAirStopped;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string fixture;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-assault") return;
m_ERRAirDeadline = GetWorld().GetWorldTime() + 650000;
GetGame().GetCallqueue().CallLater(ERR_AirTestTick, 500, true);
OnStop().Insert(ERR_AirTestStop);
}
protected void ERR_AirTestStop() { m_ERRAirStopped = true;
GetGame().GetCallqueue().Remove(ERR_AirTestTick);
}
protected bool ERR_AirCheck(bool result, string detail)
{
if (result) Print("ERR SMOKE PASS: air assault " + detail);
else { Print("ERR SMOKE FAIL: air assault " + detail, LogLevel.ERROR);
ERR_AirTestStop();
}
return result;
}
protected void ERR_AirTestTick()
{
float now = GetWorld().GetWorldTime();
if (now > m_ERRAirDeadline) { ERR_AirCheck(false, "deadline stage=" + m_ERRAirStage.ToString() + " " + ERR_HQ.Get().Recon().Status());
return;
}
if (m_ERRAirStopped || !Active() || Phase() == ERR_Phase.PREPARING) return;
ERR_HQ hq = ERR_HQ.Get();
if (m_ERRAirStage == 0)
{
array<IEntity> players = {};
ERR_Actors.Players(players, true);
if (players.IsEmpty()) return;
if (!UnlockPrison(players[0])) return;
if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop();
hq.Recon().ERR_TestDeparture(now + 3000);
m_ERRAirStage = 1;
return;
}
ERR_ReconFlightComponent flight = hq.Recon().Current();
if (!flight) { if (hq.Recon().ERR_TestWaves() > 0) ERR_AirCheck(false, "aircraft lost: " + hq.Recon().Status());
return;
}
if (m_ERRAirStage == 1)
{
if (flight.Phase() == ERR_ReconPhase.CREW) return;
m_ERRAirLoad = flight.Assault();
m_ERRAirframe = flight.Aircraft();
if (!ERR_AirCheck(m_ERRAirLoad && m_ERRAirLoad.Count() == 8 && m_ERRAirLoad.Aboard() == 8, "eight original passengers physically seated before takeoff")) return;
m_ERRAirStage = 2;
}
if (m_ERRAirStage == 2)
{
if (!m_ERRAirLoad.Ready()) return;
ERR_AirDropOrder order = new ERR_AirDropOrder();
order.ExpiresAt = now + 180000;
order.Target = flight.Home();
order.Report = new ERR_HQReport();
order.Report.Position = order.Target;
order.Report.ObservedAt = now;
// Controlled geometry/transfer fixture; policy and physical radio authorization
// are verified independently. This does not fabricate a live sighting result.
if (!ERR_AirCheck(m_ERRAirLoad.Begin(order, now), "controlled order finds real clear landing corridor")) return;
m_ERRAirStage = 3;
return;
}
if (m_ERRAirStage == 3)
{
if (m_ERRAirLoad.ERR_TestGrounded() < 8 || m_ERRAirLoad.ERR_TestReleased() < 2) return;
if (!ERR_AirCheck(flight.Aircraft() == m_ERRAirframe && flight.ERR_TestCrew() >= 2, "same eight actors landed and two original groups released; original aircrew retained")) return;
vector center;
if (!ERR_AirCheck(m_ERRAirLoad.Cover(now, center) && vector.DistanceXZ(center, flight.Home()) < 1, "post-drop cover active")) return;
m_ERRAirLoad.ERR_TestCoverEnd(now);
m_ERRAirStage = 4;
return;
}
if (m_ERRAirStage == 4)
{
if (flight.Assault() == m_ERRAirLoad || flight.Phase() != ERR_ReconPhase.TAKEOFF) return;
if (!ERR_AirCheck(flight.Aircraft() == m_ERRAirframe && flight.Assault().Aboard() == 8, "same helicopter returned, serviced and departed with another native squad")) return;
if (!ERR_AirCheck(hq.Airfield().Cargo() && hq.Airfield().Cargo().AtAirport(), "separate cargo helicopter waiting at airport")) return;
ERR_AirTestStop();
Print("ERR SMOKE COMPLETE: native airborne cycle completed");
}
}
}
#endif
@@ -0,0 +1,174 @@
#ifdef WORKBENCH
modded class ERR_AirfieldSupport
{
bool ERR_TestFailed() { return m_Mission && m_Mission.Finished() && m_Mission.ERR_TestDelivered() == 0; }
int ERR_TestDelivered() { if (m_Mission) return m_Mission.ERR_TestDelivered();
return 0; }
}
modded class ERR_AirAmmoMission
{
int ERR_TestDelivered() { return m_Delivered; }
}
modded class ERR_HQ
{
void ERR_TestCheckin(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) unit.NextStatus = 0;
}
}
modded class ERR_GameMode
{
protected int m_ERRLogisticsStage;
protected float m_ERRLogisticsDeadline, m_ERRLogisticsRadioDeadline, m_ERRLogisticsDiagnostic;
protected SCR_AIGroup m_ERRLogisticsRecipient;
protected ref ERR_PrisonPatrol m_ERRLogisticsRadio = new ERR_PrisonPatrol();
protected IEntity m_ERRForensicBody;
protected bool m_ERRForensicPassed, m_ERRDeliveryPassed;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string fixture;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-logistics") return;
m_ERRLogisticsDeadline = GetWorld().GetWorldTime() + 720000;
GetGame().GetCallqueue().CallLater(ERR_LogisticsTestTick, 1000, true);
OnStop().Insert(ERR_LogisticsTestStop);
}
protected void ERR_LogisticsTestStop() { GetGame().GetCallqueue().Remove(ERR_LogisticsTestTick); }
protected void ERR_LogisticsFail(string reason)
{
Print("ERR SMOKE FAIL: air logistics " + reason, LogLevel.ERROR);
ERR_LogisticsTestStop();
}
protected void ERR_LogisticsBody()
{
array<ERR_LocationComponent> locations = {};
Locations(locations);
foreach (ERR_LocationComponent location : locations)
{
if (location.Kind() != ERR_LocationKind.RADIO) continue;
ERR_SiteRuntime site = Sites().Find(location.Id());
if (!site || !site.Group) continue;
array<AIAgent> agents = {};
site.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
IEntity actor = agent.GetControlledEntity();
ERR_BodyEvidence record = ERR_GameMode.Get().Evidence().Find(actor);
if (!record || record.Issued.IsEmpty() || !ERR_Actors.CanFight(actor)) continue;
m_ERRForensicBody = actor;
// Test setup removes an actually issued folder. Production inspection only
// sees it missing; it cannot consult a player's inventory or discovery map.
foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder) SCR_EntityHelper.DeleteEntityAndChildren(issued.Folder);
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
damage.SetHealthScaled(0);
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(actor);
if (!garbage || garbage.Insert(actor, 1)) { ERR_LogisticsFail("unsearched military body was not retained"); return; }
Print("ERR SMOKE PASS: air logistics native corpse retention; actual issued document now absent");
return;
}
}
}
protected void ERR_LogisticsTestTick()
{
float now = GetWorld().GetWorldTime();
if (now > m_ERRLogisticsDeadline) { ERR_LogisticsFail("deadline stage=" + m_ERRLogisticsStage.ToString() + "; " + ERR_HQ.Get().Airfield().Status() + "; " + ERR_HQ.Get().Recovery().Status()); return; }
if (!Active() || Phase() == ERR_Phase.PREPARING) return;
ERR_HQ hq = ERR_HQ.Get();
if (m_ERRLogisticsStage == 0)
{
array<IEntity> players = {};
ERR_Actors.Players(players, true);
if (players.IsEmpty() || !UnlockPrison(players[0])) return;
if (hq.SupplyConvoys()) hq.SupplyConvoys().Stop();
m_ERRLogisticsStage = 1;
}
if (!m_ERRForensicBody) ERR_LogisticsBody();
if (m_ERRForensicBody && !m_ERRForensicPassed)
{
ERR_BodyEvidence evidence = ERR_GameMode.Get().Evidence().Find(m_ERRForensicBody);
if (evidence && evidence.Searched && evidence.Reported)
{
Print("ERR SMOKE PASS: air logistics special native recovery team searched body and delivered missing-intel report");
m_ERRForensicPassed = true;
}
}
if (now >= m_ERRLogisticsDiagnostic)
{
m_ERRLogisticsDiagnostic = now + 30000;
Print("ERR AIR LOGISTICS STATUS: " + hq.Airfield().Status() + "; " + hq.Recovery().Status());
}
ERR_ReconFlightComponent cargo = hq.Airfield().Cargo();
if (!cargo || !cargo.AtAirport()) { if (m_ERRLogisticsStage < 3) return; }
if (m_ERRLogisticsStage == 1)
{
ActionsManagerComponent accessManager = ActionsManagerComponent.Cast(cargo.Aircraft().FindComponent(ActionsManagerComponent));
if (!accessManager) { ERR_LogisticsFail("no aircraft action manager"); return; }
array<UserActionContext> accessContexts = {};
accessManager.GetContextList(accessContexts);
PrintFormat("ERR AIR CARGO ACCESS: enabled=%1 contexts=%2", accessManager.IsEnabled(), accessContexts.Count());
foreach (UserActionContext accessContext : accessContexts)
{
if (!accessContext) { Print("ERR AIR CARGO ACCESS: null context"); continue; }
array<BaseUserAction> accessActions = {};
accessContext.GetActionsList(accessActions);
if (accessActions.Count() > 64) PrintFormat("ERR AIR CARGO ACCESS: dense context=%1 actions=%2", accessContext.GetContextName(), accessActions.Count());
foreach (BaseUserAction accessAction : accessActions)
{
if (!SCR_OpenVehicleStorageAction.Cast(accessAction)) continue;
vector accessTransform[4];
bool transformed = accessContext.GetTransformationWorld(accessTransform);
PrintFormat("ERR AIR CARGO ACCESS: storage context=%1 rootOwner=%2 transformed=%3 point=%4", accessContext.GetContextName(), accessAction.GetOwner() == cargo.Aircraft(), transformed, accessTransform[3]);
}
}
array<vector> initialAccess = {};
if (!ERR_InfantryAmmoAccess.Points(cargo.Aircraft(), initialAccess)) { ERR_LogisticsFail("airport cargo-access preflight refused; see context diagnostics"); return; }
vector position;
if (!ERR_ReconAirports.FindClearPad(cargo.Home() + "250 0 0", position)) return;
m_ERRLogisticsRecipient = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", position));
if (!m_ERRLogisticsRecipient || !ERR_PrisonPatrol.Configure(m_ERRLogisticsRecipient, FactionSelection().Choice("enemy").Group, EnemyFaction())) { ERR_LogisticsFail("recipient creation failed"); return; }
Own(m_ERRLogisticsRecipient);
m_ERRLogisticsRecipient.ActivateAI();
m_ERRLogisticsRadioDeadline = now + 60000;
m_ERRLogisticsStage = 2;
return;
}
if (m_ERRLogisticsStage == 2)
{
if (now > m_ERRLogisticsRadioDeadline) { ERR_LogisticsFail("recipient loadout/radio timeout"); return; }
if (!m_ERRLogisticsRecipient.IsExpandComplete()) return;
if (m_ERRLogisticsRadio.Radio(this, m_ERRLogisticsRecipient, m_ERRLogisticsRadioDeadline) != ERR_PrepareResult.READY) return;
array<AIAgent> agents = {};
m_ERRLogisticsRecipient.GetAgents(agents);
foreach (AIAgent agent : agents)
{
agent.SetPermanentLOD(0); agent.ActivateAI();
IEntity actor = agent.GetControlledEntity();
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
foreach (IEntity item : items)
{
BaseMagazineComponent magazine = BaseMagazineComponent.Cast(item.FindComponent(BaseMagazineComponent));
if (magazine) magazine.SetAmmoCount(0);
}
}
RegisterGroup(m_ERRLogisticsRecipient);
hq.ERR_TestCheckin(m_ERRLogisticsRecipient);
Print("ERR SMOKE PASS: air logistics native infantry low-ammunition request setup; waiting for real radio/HQ dispatch");
m_ERRLogisticsStage = 3;
}
if (m_ERRLogisticsStage == 3 && hq.Airfield().ERR_TestFailed()) { ERR_LogisticsFail("first delivery ended without a native transfer; see AIR AMMUNITION journal reason"); return; }
if (m_ERRLogisticsStage == 3 && hq.Airfield().ERR_TestDelivered() > 0)
{
Print("ERR SMOKE PASS: air logistics aircraft transported airport-loaded magazine and native inventory transfer confirmed");
m_ERRDeliveryPassed = true;
m_ERRLogisticsStage = 4;
}
if (m_ERRDeliveryPassed && m_ERRForensicPassed)
{
Print("ERR SMOKE COMPLETE: native radio-requested air ammunition delivery and physical forensic recovery");
ERR_LogisticsTestStop();
}
}
}
#endif
@@ -0,0 +1,169 @@
#ifdef WORKBENCH
modded class ERR_AirReconSystem
{
void ERR_TestDeparture(float due) { m_Schedule.Started = true; m_Schedule.Deadline = due; m_PadAttempts = 0; }
float ERR_TestDeadline() { return m_Schedule.Deadline; }
int ERR_TestWaves() { return m_Serial; }
bool ERR_TestAirport(vector pad)
{
foreach (vector airport : m_Airports.m_Positions) if (vector.DistanceXZ(airport, pad) < 250) return true;
return false;
}
}
modded class ERR_ReconFlightComponent
{
int ERR_TestCrew() { return m_OriginalCrew.Count(); }
bool ERR_TestPilot() { return LivingPilot(); }
bool ERR_TestSeen(IEntity actor) { return m_Observation && m_Observation.Target == actor; }
protected override void Search(float now)
{
super.Search(now);
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture == "air-recon")
m_Center = m_Home + Vector(0, 0, 800); // Controlled runway-sector encounter, native perception unchanged.
}
}
modded class ERR_GameMode
{
protected int m_ERRReconStage;
protected float m_ERRReconDeadline, m_ERRReconDue, m_ERRReconWait;
protected Vehicle m_ERRReconFirst;
protected IEntity m_ERRReconPlayer;
protected bool m_ERRReconDamage, m_ERRReconTakeoverOnly;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string fixture;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", fixture) || fixture != "air-recon") return;
string takeoverOnly; m_ERRReconTakeoverOnly = System.GetCLIParam("errReconTakeoverOnly", takeoverOnly);
m_ERRReconDeadline = GetWorld().GetWorldTime() + 540000;
GetGame().GetCallqueue().CallLater(ERR_ReconTestTick, 500, true);
OnStop().Insert(ERR_ReconTestStop);
}
protected void ERR_ReconTestStop()
{
GetGame().GetCallqueue().Remove(ERR_ReconTestTick);
if (m_ERRReconPlayer)
{
DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRReconPlayer.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(m_ERRReconDamage);
}
}
protected bool ERR_ReconCheck(bool result, string text)
{
if (result) { Print("ERR SMOKE PASS: air recon " + text); return true; }
Print("ERR SMOKE FAIL: air recon " + text, LogLevel.ERROR); ERR_ReconTestStop(); return false;
}
protected void ERR_ReconTestTick()
{
float now = GetWorld().GetWorldTime();
if (now > m_ERRReconDeadline) { ERR_ReconCheck(false, "deadline stage=" + m_ERRReconStage.ToString() + "; " + ERR_HQ.Get().Recon().Status()); return; }
if (!Active() || Phase() == ERR_Phase.PREPARING) return;
ERR_AirReconSystem system = ERR_HQ.Get().Recon();
if (m_ERRReconStage == 0)
{
array<IEntity> players = {}; ERR_Actors.Players(players, true);
if (players.IsEmpty()) return;
m_ERRReconPlayer = players[0];
if (!ERR_ReconCheck(UnlockPrison(m_ERRReconPlayer), "normal prison escape enables departure countdown")) return;
if (ERR_HQ.Get().SupplyConvoys()) ERR_HQ.Get().SupplyConvoys().Stop();
DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRReconPlayer.FindComponent(DamageManagerComponent));
m_ERRReconDamage = damage.IsDamageHandlingEnabled(); damage.EnableDamageHandling(false);
ChimeraCharacter actor = ChimeraCharacter.Cast(m_ERRReconPlayer);
vector transform[4]; actor.GetWorldTransform(transform); transform[3] = ERR_Actors.Ground("4550 0 11080"); actor.Teleport(transform);
m_ERRReconDue = now + 3000; system.ERR_TestDeparture(m_ERRReconDue);
m_ERRReconStage = 1; return;
}
ERR_ReconFlightComponent flight = system.Current();
if (m_ERRReconStage == 1)
{
if (now < m_ERRReconDue) { if (!ERR_ReconCheck(!flight && system.ERR_TestWaves() == 0, "no aircraft before scheduled deadline")) return; return; }
if (!flight || flight.Phase() == ERR_ReconPhase.CREW) return;
if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.TAKEOFF && flight.ERR_TestCrew() >= 2 && flight.ERR_TestPilot(), "native original pilot/gunners seated before takeoff")) return;
if (!ERR_ReconCheck(system.ERR_TestAirport(flight.Home()) && system.ERR_TestWaves() == 1 && system.ERR_TestDeadline() < 0, "one actual airfield departure disarms launch timer")) return;
m_ERRReconFirst = flight.Aircraft();
if (m_ERRReconTakeoverOnly) { m_ERRReconStage = 8; return; }
ChimeraCharacter encounter = ChimeraCharacter.Cast(m_ERRReconPlayer);
vector encounterTransform[4]; encounter.GetWorldTransform(encounterTransform);
encounterTransform[3] = ERR_Actors.Ground(flight.Home() + Vector(0, 0, 400)); encounter.Teleport(encounterTransform);
if (!ERR_ReconCheck(ERR_Actors.CanFight(m_ERRReconPlayer), "visible encounter player is alive and conscious")) return;
m_ERRReconStage = 2; return;
}
if (m_ERRReconStage == 2)
{
if (!flight || flight.Phase() == ERR_ReconPhase.RELEASED) { ERR_ReconCheck(false, system.Status()); return; }
float distance = vector.DistanceXZ(flight.Aircraft().GetOrigin(), flight.Home());
if (distance < 300 || flight.Phase() < ERR_ReconPhase.SEARCH || !flight.ERR_TestSeen(m_ERRReconPlayer)) return;
array<ref ERR_HQReport> contacts = {}; ERR_HQ.Get().SupportContacts(contacts, now);
bool delivered;
foreach (ERR_HQReport contact : contacts) if (contact.Source == flight.Crew() && contact.Target == m_ERRReconPlayer) delivered = true;
if (!delivered) return;
if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.SHADOW, "real native player sighting enters shadow orbit and reaches HQ through crew radio")) return;
if (!ERR_ReconCheck(ERR_Actors.Alive(flight.Aircraft()) && flight.ERR_TestPilot() && flight.Aircraft().GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(flight.Aircraft().GetOrigin()) > 80, "airport takeoff and more than 300 metres of terrain-cleared search flight")) return;
system.Return();
if (!ERR_ReconCheck(flight.Phase() == ERR_ReconPhase.RETURNING, "production return-home request accepted")) return;
m_ERRReconStage = 3; return;
}
if (m_ERRReconStage == 3)
{
if (!flight || flight.Phase() != ERR_ReconPhase.PARKED) return;
if (!ERR_ReconCheck(ERR_Actors.Alive(flight.Aircraft()) && vector.DistanceXZ(flight.Aircraft().GetOrigin(), flight.Home()) < 4 && flight.ERR_TestPilot(), "same intact helicopter and crew returned and landed on airfield")) return;
m_ERRReconWait = now + 4000; m_ERRReconStage = 4; return;
}
if (m_ERRReconStage == 4)
{
if (now < m_ERRReconWait) return;
if (!ERR_ReconCheck(flight && flight.Phase() == ERR_ReconPhase.PARKED && system.ERR_TestWaves() == 1, "parking does not create a replacement")) return;
DamageManagerComponent aircraftDamage = DamageManagerComponent.Cast(m_ERRReconFirst.FindComponent(DamageManagerComponent));
aircraftDamage.SetHealthScaled(0);
m_ERRReconStage = 5; return;
}
if (m_ERRReconStage == 5)
{
if (flight) return;
if (!ERR_ReconCheck(system.ERR_TestDeadline() - now > 899000 && system.ERR_TestWaves() == 1, "actual destruction starts full 15-minute replacement delay")) return;
system.ERR_TestDeparture(now + 3000); m_ERRReconStage = 6; return;
}
if (m_ERRReconStage == 6)
{
if (!flight || flight.Phase() != ERR_ReconPhase.TAKEOFF) return;
if (!ERR_ReconCheck(system.ERR_TestWaves() == 2 && flight.Aircraft() != m_ERRReconFirst && !ERR_Actors.Alive(m_ERRReconFirst), "delayed replacement is a new aircraft and wreck is retained")) return;
m_ERRReconStage = 8; m_ERRReconWait = now + 500; return;
}
if (m_ERRReconStage == 8)
{
if (now < m_ERRReconWait) return;
if (!ERR_ReconCheck(ERR_Actors.SetFaction(flight.Aircraft(), PlayerFaction()), "native aircraft faction transferred to escapists")) return;
m_ERRReconWait = now + 15000;
m_ERRReconStage = 7; return;
}
if (m_ERRReconStage == 7)
{
if (!flight || flight.Phase() != ERR_ReconPhase.RELEASED)
{
if (now >= m_ERRReconWait) ERR_ReconCheck(false, "native faction handover did not release flight");
return;
}
if (!ERR_ReconCheck(SCR_FactionManager.SGetFaction(flight.Aircraft()) == PlayerFaction(), "aircraft retains transferred faction")) return;
int expectedWaves = 2; if (m_ERRReconTakeoverOnly) expectedWaves = 1;
if (!ERR_ReconCheck(system.ERR_TestWaves() == expectedWaves && system.ERR_TestDeadline() < 0 && !flight.Lost(), "faction takeover releases scripted flight without creating replacement")) return;
if (m_ERRReconTakeoverOnly)
{
m_ERRReconStage = 9; m_ERRReconWait = now + 1000; return;
}
Print("ERR SMOKE COMPLETE: air recon scheduled airport launch, native search flight, return/landing, loss delay and takeover");
ERR_ReconTestStop();
}
if (m_ERRReconStage == 9)
{
if (now < m_ERRReconWait) return;
SCR_EntityHelper.DeleteEntityAndChildren(flight.Aircraft()); m_ERRReconStage = 10; return;
}
if (m_ERRReconStage == 10)
{
if (!ERR_ReconCheck(!flight && system.ERR_TestWaves() == 1 && system.ERR_TestDeadline() - now >= 898000, "native entity deletion schedules full delay without duplicate aircraft")) return;
Print("ERR SMOKE COMPLETE: air recon native faction takeover and explicit deletion loss timer"); ERR_ReconTestStop();
}
}
}
#endif
@@ -0,0 +1,103 @@
#ifdef WORKBENCH
modded class ERR_MortarBattery
{
ERR_MortarStation ERR_StationProbe() { return m_Station; }
}
modded class ERR_GameMode
{
protected int m_ERRStationStage, m_ERRStationDeadline;
protected float m_ERRStationWait;
protected ERR_MortarBattery m_ERRStationBattery;
protected IEntity m_ERRStationGunner;
protected ref array<IEntity> m_ERRStationSupport = {};
protected ref array<vector> m_ERRStationOrigins = {};
protected ref SCR_AIGetOutVehicle m_ERRStationExit;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "mortar-station") return;
m_ERRStationDeadline = System.GetTickCount() + 180000;
GetGame().GetCallqueue().CallLater(ERR_StationProbeTick, 500, true);
}
protected bool ERR_StationCheck(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: mortar station " + detail); return true; }
Print("ERR SMOKE FAIL: mortar station " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_StationProbeTick); return false;
}
protected void ERR_StationProbeTick()
{
if (System.GetTickCount() > m_ERRStationDeadline || !PreparationError().IsEmpty()) { ERR_StationCheck(false, "deadline/preparation " + PreparationError()); return; }
if (!Active() || GetWorld().GetWorldTime() < m_ERRStationWait) return;
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller || !controller.GetControlledEntity()) return;
float now = GetWorld().GetWorldTime();
if (m_ERRStationStage == 0)
{
array<ref ERR_MortarBattery> batteries = {}; ERR_MortarSystem.Get().Batteries(batteries);
if (batteries.IsEmpty()) return;
m_ERRStationBattery = batteries[0];
DamageManagerComponent.Cast(controller.GetControlledEntity().FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!ERR_StationCheck(UnlockPrison(controller.GetControlledEntity()), "normal prison release starts crew AI")) return;
m_ERRStationStage = 1; return;
}
ERR_MortarStation station = m_ERRStationBattery.ERR_StationProbe();
if (m_ERRStationStage == 1)
{
if (!m_ERRStationBattery.StartupActivated || !station) return;
m_ERRStationGunner = station.Gunner();
if (!m_ERRStationGunner || CompartmentAccessComponent.GetVehicleIn(m_ERRStationGunner) != m_ERRStationBattery.Gun) return;
if (!ERR_StationCheck(!m_ERRStationBattery.CommandOpen && m_ERRStationBattery.TotalShots == 0, "gunner boards original gun while idle without manufacturing fire orders")) return;
array<AIAgent> agents = {}; m_ERRStationBattery.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
IEntity actor = agent.GetControlledEntity();
DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (actor == m_ERRStationGunner) continue;
m_ERRStationSupport.Insert(actor); m_ERRStationOrigins.Insert(actor.GetOrigin());
SCR_ChimeraAIAgent ai = SCR_ChimeraAIAgent.Cast(agent);
ai.m_UtilityComponent.AddAction(new SCR_AIMoveIndividuallyBehavior(ai.m_UtilityComponent, null, ERR_Actors.Ground(actor.GetOrigin() + "40 0 0"), 1000));
}
SCR_ChimeraAIAgent gunner = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_ERRStationGunner));
m_ERRStationExit = new SCR_AIGetOutVehicle(gunner.m_UtilityComponent, null, m_ERRStationBattery.Gun, priority: 2000);
if (!ERR_StationCheck(m_ERRStationExit.CustomEvaluate() == 0, "even high-priority AI dismount is refused for intact gun")) return;
gunner.m_UtilityComponent.AddAction(m_ERRStationExit);
m_ERRStationBattery.Cancel("native station retention test");
m_ERRStationWait = now + 15000; m_ERRStationStage = 2; return;
}
if (m_ERRStationStage == 2)
{
if (!ERR_StationCheck(CompartmentAccessComponent.GetVehicleIn(m_ERRStationGunner) == m_ERRStationBattery.Gun, "gunner remains seated after cancellation and competing dismount")) return;
bool stayed = m_ERRStationSupport.Count() == 3;
foreach (int i, IEntity support : m_ERRStationSupport)
if (vector.DistanceXZ(support.GetOrigin(), m_ERRStationOrigins[i]) > 1.5) stayed = false;
if (!ERR_StationCheck(stayed, "all three support crew remain on station despite competing movement")) return;
DamageManagerComponent damage = DamageManagerComponent.Cast(m_ERRStationBattery.Gun.FindComponent(DamageManagerComponent));
vector hit[3]; hit[0] = m_ERRStationBattery.Gun.GetOrigin(); hit[1] = "0 0 1"; hit[2] = "0 0 -1";
damage.HandleDamage(new SCR_DamageContext(EDamageType.TRUE, 100000, hit, m_ERRStationBattery.Gun, damage.GetDefaultHitZone(), Instigator.CreateInstigator(controller.GetControlledEntity()), null, -1, -1));
m_ERRStationWait = now + 3000; m_ERRStationStage = 3; return;
}
if (m_ERRStationStage == 3)
{
if (!ERR_StationCheck(!station.GunIntact() && !station.Holds(m_ERRStationGunner) && m_ERRStationExit.CustomEvaluate() > 0, "destroying the gun removes station and dismount restrictions")) return;
foreach (IEntity survivor : m_ERRStationSupport)
{
SCR_ChimeraAIAgent ai = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(survivor));
ai.m_UtilityComponent.AddAction(new SCR_AIMoveIndividuallyBehavior(ai.m_UtilityComponent, null, ERR_Actors.Ground(survivor.GetOrigin() + "30 0 0"), 2000));
}
m_ERRStationWait = now + 15000; m_ERRStationStage = 4; return;
}
if (m_ERRStationStage == 4)
{
bool moved;
foreach (int i, IEntity survivor : m_ERRStationSupport)
if (vector.DistanceXZ(survivor.GetOrigin(), m_ERRStationOrigins[i]) > 3) moved = true;
if (!ERR_StationCheck(moved, "surviving support crew physically leave after destruction")) return;
GetGame().GetCallqueue().Remove(ERR_StationProbeTick);
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: permanent mortar station retention and release; scripted local fixture");
}
}
}
#endif
@@ -0,0 +1,54 @@
#ifdef WORKBENCH
modded class ERR_HQ
{
bool ERR_ProbeScoutReservation(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) return unit.ReservedForSupport && !unit.Eligible(m_Mode) && !ReserveLogistics(group, 888);
return false;
}
}
class ERR_NativeScoutProbe
{
static bool Run()
{
ERR_HQ hq = ERR_HQ.Get();
ERR_ScoutSystem scouts = hq.Scouts();
if (scouts.Count() != 10) return false;
for (int index = 0; index < scouts.Count(); index++)
{
ERR_ScoutPost post = scouts.At(index);
if (!post.Ready || !ERR_Actors.CanFight(post.Actor) || post.Group.GetAgentsCount() != 1 || !hq.ERR_ProbeScoutReservation(post.Group)) return false;
if (post.Group.GetGroupUtilityComponent().GetCombatModeExternal() != EAIGroupCombatMode.RETURN_FIRE || ERR_HQRadio.Capability(post.Actor) != 2) return false;
if (SCR_FactionManager.SGetFaction(post.Actor) != ERR_GameMode.Get().EnemyFaction()) return false;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(post.Actor.FindComponent(InventoryStorageManagerComponent));
if (!inventory || !post.Pack || inventory.CanInsertResourceInStorage(post.Magazine, post.Pack)) return false;
array<IEntity> magazines = {}; post.Pack.GetAll(magazines);
int matches;
foreach (IEntity item : magazines)
{
if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != post.Magazine) return false;
MagazineComponent magazine = MagazineComponent.Cast(item.FindComponent(MagazineComponent));
if (!magazine || magazine.GetAmmoCount() != magazine.GetMaxAmmoCount()) return false;
matches++;
}
if (matches < 1 || matches != post.Magazines) return false;
for (int other = 0; other < index; other++)
if (vector.DistanceXZ(post.Position, scouts.At(other).Position) < 900) return false;
PrintFormat("Escapists [scout probe]: PASS post=%1 height=%2 full magazines=%3 physical manpack, return fire, reserved", index + 1, post.Position[1], matches);
}
ERR_HQReport report = new ERR_HQReport(); report.RoadStatus = "on road";
ERR_HQReport snapshot = report.Snapshot(); report.RoadStatus = "off road";
if (snapshot.RoadStatus != "on road") return false;
ERR_HQReport before = new ERR_HQReport(); before.Target = scouts.At(0).Actor; before.Source = scouts.At(0).Group;
before.Position = "100 20 100"; before.ObservedAt = 1000; before.TravelMode = "on foot";
ERR_HQReport after = before.Snapshot(); after.Position = "110 22 100"; after.ObservedAt = 11000;
ERR_ScoutMotion.Estimate(after, before);
if (!after.HasVelocity || after.Velocity != "1 0 0" || after.TravelDirection() != "E") return false;
after.ObservedAt = 42000; ERR_ScoutMotion.Estimate(after, before);
if (after.HasVelocity) return false;
Print("Escapists [scout probe]: PASS observed motion, direction, stale-sighting refusal and immutable road snapshot");
return true;
}
}
#endif
@@ -0,0 +1,105 @@
class ERR_ObjectiveDefenceEntry
{
ERR_SiteRuntime Site;
SCR_AIGroup Group;
AIWaypoint Hold;
bool Activated;
bool Spawned;
bool Admitted;
float Deadline;
Faction GuardFaction;
ResourceName GuardProfile;
}
// One permanent garrison per objective, with one finite relief crew if its
// original garrison is absent. Player proximity activates defence, not HQ intel.
class ERR_ObjectiveDefence
{
protected ERR_GameMode m_Mode;
protected ERR_HQ m_HQ;
protected ref array<ref ERR_ObjectiveDefenceEntry> m_Entries = {};
protected float m_Next;
protected bool m_Stopped;
void ERR_ObjectiveDefence(ERR_GameMode mode, ERR_HQ hq) { m_Mode = mode; m_HQ = hq; }
static bool Objective(int kind)
{
return kind == ERR_LocationKind.RADIO || kind == ERR_LocationKind.DEPOT
|| kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.EXTRACTION;
}
protected void Initialize()
{
for (int i = 0; i < m_Mode.Sites().Count(); i++)
{
ERR_SiteRuntime site = m_Mode.Sites().At(i);
if (!site || !site.Definition || !Objective(site.Definition.Kind)) continue;
ERR_ObjectiveDefenceEntry entry = new ERR_ObjectiveDefenceEntry();
entry.Site = site;
entry.GuardFaction = m_Mode.EnemyFaction(); entry.GuardProfile = m_Mode.FactionSelection().Choice("enemy").Group;
if (site.Group)
{
entry.GuardFaction = site.Group.GetFaction();
if (site.Group.GetPrefabData()) entry.GuardProfile = site.Group.GetPrefabData().GetPrefabName();
}
m_Entries.Insert(entry);
}
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || now < m_Next || m_Mode.Phase() <= ERR_Phase.PRISON) return;
m_Next = now + 5000;
if (m_Entries.IsEmpty()) Initialize();
array<IEntity> players = {}; ERR_Actors.Players(players);
foreach (ERR_ObjectiveDefenceEntry entry : m_Entries)
{
if (entry.Spawned && !entry.Admitted && entry.Group)
{
if (entry.Group.IsExpandComplete() && m_Mode.ForceLiving(entry.Group) == entry.Group.m_aUnitPrefabSlots.Count() && m_Mode.ForceLiving(entry.Group) > 0)
{
string source = "defence-" + entry.Site.Definition.Id;
if (entry.Group.GetFaction() == m_Mode.EnemyFaction())
{
ERR_ForceRecord record = m_Mode.Forces().Register(source, ERR_ForceKind.GARRISON, entry.Site.Location.Position(), m_Mode.ForceLiving(entry.Group), null, entry.Group, now);
if (!record) continue;
}
m_Mode.RegisterGroup(entry.Group); m_HQ.Dedicate(entry.Group, entry.Site.Definition.Id);
entry.Hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), entry.Site.Location.Position()));
if (entry.Hold) { m_Mode.Own(entry.Hold); entry.Hold.SetCompletionRadius(40); entry.Group.AddWaypoint(entry.Hold); }
entry.Group.ActivateAI(); entry.Admitted = true;
ERR_HQ.Event("OBJECTIVE DEFENCE", source + " relief crew admitted; permanent local defence; position=" + entry.Site.Location.Position().ToString());
}
else if (now >= entry.Deadline) { ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " relief startup expired; no further spawn"); entry.Admitted = true; }
continue;
}
if (entry.Spawned) continue;
float nearest = 1e20;
foreach (IEntity player : players) if (player && ERR_Actors.Alive(player)) nearest = Math.Min(nearest, vector.DistanceXZ(player.GetOrigin(), entry.Site.Location.Position()));
if (nearest > 600) continue;
if (entry.Site.Group && m_Mode.ForceLiving(entry.Site.Group) > 0)
{
if (!entry.Activated)
{
entry.Activated = true; m_HQ.Dedicate(entry.Site.Group, entry.Site.Definition.Id);
ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " existing garrison activated; permanent local defence; position=" + entry.Site.Location.Position().ToString());
}
continue;
}
// No surprise materialisation on top of a player, nor infinite replenishment.
if (nearest < 200 || m_Mode.Forces().Count() >= 250) continue;
vector point = ERR_Actors.Ground(entry.Site.Location.Position() + "20 0 20");
if (!ERR_HQRoadIntercept.Standable(point)) continue;
entry.Spawned = true; entry.Deadline = now + 30000;
entry.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", point));
if (!entry.Group) continue;
m_Mode.Own(entry.Group);
// Retained ownership survives deletion of the original wiped-out group.
if (!ERR_PrisonPatrol.Configure(entry.Group, entry.GuardProfile, entry.GuardFaction)) entry.Admitted = true;
ERR_HQ.Event("OBJECTIVE DEFENCE", entry.Site.Definition.Id + " relief requested; position=" + point.ToString() + "; one relief crew maximum");
}
}
void Stop()
{
m_Stopped = true;
foreach (ERR_ObjectiveDefenceEntry entry : m_Entries)
if (entry.Hold) { if (entry.Group) entry.Group.RemoveWaypoint(entry.Hold); ERR_Actors.DeleteSpawned(entry.Hold); entry.Hold = null; }
}
}
@@ -0,0 +1,118 @@
// Adapted from the owner's original search-helicopter source; bounded native runway/tower discovery.
// Airport discovery is spread across updates instead of scanning the island
// in a single frame. An explicitly named ERR_ReconAirport marker takes priority.
class ERR_ReconAirports
{
ref array<vector> m_Positions = {};
protected ref array<int> m_PositionSamples = {};
protected vector m_vMin;
protected vector m_vMax;
protected vector m_vCursor;
protected bool m_bStarted;
protected bool m_bComplete;
void Update()
{
if (m_bComplete || !GetGame().GetWorldEntity())
return;
if (!m_bStarted)
{
IEntity marker = GetGame().GetWorld().FindEntityByName("ERR_ReconAirport");
if (marker && ERR_AirfieldPlacement.Clear(ERR_Actors.Ground(marker.GetOrigin())))
{
m_Positions.Insert(marker.GetOrigin());
m_PositionSamples.Insert(1);
m_bComplete = true;
return;
}
GetGame().GetWorldEntity().GetWorldBounds(m_vMin, m_vMax);
m_vCursor = m_vMin;
m_bStarted = true;
}
vector minimum = Vector(m_vCursor[0], -1000, m_vCursor[2]);
vector maximum = Vector(Math.Min(m_vCursor[0] + 2000, m_vMax[0]), 5000, Math.Min(m_vCursor[2] + 2000, m_vMax[2]));
GetGame().GetWorld().QueryEntitiesByAABB(minimum, maximum, AddAirport, IsAirportStructure, queryFlags: EQueryEntitiesFlags.ALL);
m_vCursor[0] = m_vCursor[0] + 2000;
if (m_vCursor[0] >= m_vMax[0])
{
m_vCursor[0] = m_vMin[0];
m_vCursor[2] = m_vCursor[2] + 2000;
}
m_bComplete = m_vCursor[2] >= m_vMax[2];
if (m_bComplete)
{
PrintFormat("Escapists AI [air recon]: airport search finished; airports=%1", m_Positions.Count());
for (int airportIndex = 0; airportIndex < m_Positions.Count(); airportIndex++)
PrintFormat("Escapists AI [air recon]: airport=%1 centre=%2 runway/tower samples=%3", airportIndex, m_Positions[airportIndex], m_PositionSamples[airportIndex]);
}
}
protected bool IsAirportStructure(IEntity entity)
{
if (!entity || !entity.GetPrefabData())
return false;
string prefabPath = entity.GetPrefabData().GetPrefabName();
prefabPath.ToLower();
// Airport-labelled props, bunkers and map locations can occur anywhere.
// Use physical runway infrastructure and control towers as anchors.
if (!prefabPath.Contains("prefabs/structures/"))
return false;
return prefabPath.Contains("/runway") || prefabPath.Contains("/lightrunway_") || prefabPath.Contains("/aircontroltower") || prefabPath.Contains("/airportcontroltower") || prefabPath.Contains("/controltower");
}
protected bool AddAirport(IEntity entity)
{
for (int index = 0; index < m_Positions.Count(); index++)
{
if (vector.DistanceXZ(m_Positions[index], entity.GetOrigin()) >= 1000)
continue;
// Use the cluster centre, rather than the first light at a runway edge.
int samples = m_PositionSamples[index];
float sampleWeight = 1.0 / (samples + 1);
m_Positions[index] = vector.Lerp(m_Positions[index], entity.GetOrigin(), sampleWeight);
m_PositionSamples[index] = samples + 1;
return true;
}
m_Positions.Insert(entity.GetOrigin());
m_PositionSamples.Insert(1);
PrintFormat("Escapists AI [air recon]: airport structure=%1 position=%2", entity.GetPrefabData().GetPrefabName(), entity.GetOrigin());
return true;
}
static bool FindClearPad(vector center, out vector position)
{
for (int attempt = 0; attempt < 12; attempt++)
{
float angleCandidate = Math.RandomFloat(0, Math.PI2);
float radiusCandidate = Math.RandomFloat(35, 220);
vector candidate = center + Vector(Math.Cos(angleCandidate) * radiusCandidate, 0, Math.Sin(angleCandidate) * radiusCandidate);
if (candidate == vector.Zero || vector.DistanceXZ(center, candidate) > 250 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate))
continue;
candidate[1] = SCR_TerrainHelper.GetTerrainY(candidate);
bool flat = true;
for (int side = 0; side < 4; side++)
{
float angle = side * Math.PI / 2;
vector edge = candidate + Vector(Math.Cos(angle) * 12, 0, Math.Sin(angle) * 12);
if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(edge) - candidate[1]) > 1.5)
flat = false;
}
if (!flat)
continue;
// Keep low crates, vehicles and people out of the pad footprint too;
// the overhead sweep alone starts above some of these obstacles.
if (!SCR_WorldTools.TraceCilinderUtil(candidate + Vector(0, 2.5, 0), 14, 4, TraceFlags.ENTS))
continue;
TraceBox clearance = new TraceBox();
clearance.Start = candidate + Vector(0, 4, 0);
clearance.End = candidate + Vector(0, 150, 0);
clearance.Mins = "-14 -2 -14";
clearance.Maxs = "14 2 14";
clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(clearance, null) < 1)
continue;
position = candidate + Vector(0, 1.5, 0);
return true;
}
return false;
}
}
@@ -0,0 +1,19 @@
// Retain the original pilots/gunners in flight; release on landing/loss/takeover.
modded class SCR_AIGetOutVehicle
{
override float CustomEvaluate()
{
if (m_Utility && m_Utility.m_OwnerEntity)
{
ERR_HQ hq = ERR_HQ.Get();
if (hq && hq.AirAssault() && hq.AirAssault().HoldsSeat(m_Utility.m_OwnerEntity)) return 0;
IEntity aircraft = CompartmentAccessComponent.GetVehicleIn(m_Utility.m_OwnerEntity);
if (aircraft)
{
ERR_ReconFlightComponent flight = ERR_ReconFlightComponent.Cast(aircraft.FindComponent(ERR_ReconFlightComponent));
if (flight && flight.HoldsSeat(m_Utility.m_OwnerEntity)) return 0;
}
}
return super.CustomEvaluate();
}
}
@@ -0,0 +1,359 @@
// Flight/orbit/terrain correction adapted from the owner's original recon and
// helicopter base components. Native physics remains authoritative on server.
enum ERR_ReconPhase { CREW, TAKEOFF, SEARCH, SHADOW, RETURNING, LANDING, PARKED, RELEASED }
class ERR_ReconFlightRules
{
static vector Cruise(vector position, vector center, bool returning)
{
vector toward = center - position; toward[1] = 0;
float distance = toward.Length();
float speed = 40;
if (returning) speed = Math.Min(speed, Math.Max(8, distance * 0.2));
vector desired = toward.Normalized() * speed;
if (!returning && distance < 650)
desired = Vector(-toward[2], 0, toward[0]).Normalized() * 28 + toward.Normalized() * Math.Clamp((distance - 300) * 0.15, -20, 25);
return desired;
}
static vector StandOff(vector aircraft, vector observed, vector velocity)
{
vector away = aircraft - observed; away[1] = 0;
float distance = away.Length();
if (distance >= 180) return velocity;
if (distance < 1) away = "1 0 0";
away.Normalize();
float inward = vector.Dot(velocity, away);
if (inward < 0) velocity -= away * inward;
return velocity + away * Math.Clamp((180 - distance) * 0.5, 0, 25);
}
}
[ComponentEditorProps(category: "Reintroduced", description: "Airport reconnaissance helicopter")]
class ERR_ReconFlightComponentClass : ScriptComponentClass {}
class ERR_ReconFlightComponent : ScriptComponent
{
protected bool m_Logistics, m_HoldAtAirport, m_Delivery;
protected vector m_DeliveryPad, m_TakeoffBase;
bool Logistics() { return m_Logistics; }
bool AtAirport() { return m_Phase == ERR_ReconPhase.PARKED && vector.DistanceXZ(m_Aircraft.GetOrigin(), m_Home) < 10; }
vector LandingTarget()
{
if (m_Delivery) return m_DeliveryPad;
return m_Home;
}
void LaunchDelivery(vector pad)
{
if (!m_Logistics || !AtAirport() || Captured() || !LivingPilot()) return;
m_Delivery = true; m_DeliveryPad = pad; RestartFlight();
}
void RestartFlight()
{
if (!Replication.IsServer() || m_Phase != ERR_ReconPhase.PARKED || Captured() || !LivingPilot()) return;
m_TakeoffBase = m_Aircraft.GetOrigin(); m_Started = GetGame().GetWorld().GetWorldTime(); m_Settled = 0;
m_HoldAtAirport = false;
Locks(true);
Engines(true);
m_Phase = ERR_ReconPhase.TAKEOFF;
// A helicopter waiting at the airport may have gone to physics sleep.
// Wake it here: its simulation callback cannot wake itself while asleep.
Physics physics = m_Aircraft.GetPhysics();
if (physics) physics.SetActive(ActiveState.ACTIVE);
}
void ReloadPassengers()
{
if (!AtAirport() || m_Logistics) return;
if (m_Assault && m_Assault.Aboard() > 0) { m_Assault.ReuseRemaining(); return; }
m_Assault = ERR_HQ.Get().AirAssault().Load(this, m_Home, m_Serial);
}
protected ERR_GameMode m_Mode;
protected Vehicle m_Aircraft;
protected SCR_AIGroup m_Crew;
protected ref ERR_AirAssaultLoad m_Assault;
ERR_AirAssaultLoad Assault() { return m_Assault; }
protected ref array<BaseCompartmentSlot> m_Seats = {};
protected ref array<IEntity> m_OriginalCrew = {};
protected ref ERR_HelicopterAvailability m_Availability = new ERR_HelicopterAvailability();
protected ERR_ReconPhase m_Phase;
protected vector m_Home, m_Center, m_Velocity;
protected float m_Started, m_NextSearch, m_ObservedAt = -1, m_LandingStarted, m_Settled, m_NextLog;
protected ref ERR_HQReport m_Observation;
protected int m_Serial;
protected bool m_Lost, m_Initialized, m_CrewLocked;
protected string m_Reason = "creating crew";
Vehicle Aircraft() { return m_Aircraft; }
SCR_AIGroup Crew() { return m_Crew; }
ERR_ReconPhase Phase() { return m_Phase; }
vector Home() { return m_Home; }
vector Destination() { return m_Center; }
bool Lost() { return m_Lost; }
string Status() { return typename.EnumToString(ERR_ReconPhase, m_Phase) + ": " + m_Reason; }
override void OnPostInit(IEntity owner)
{
super.OnPostInit(owner);
if (!SCR_Global.IsEditMode() && Replication.IsServer()) SetEventMask(owner, EntityEvent.SIMULATE);
}
protected void Seats(IEntity entity)
{
SCR_BaseCompartmentManagerComponent manager = SCR_BaseCompartmentManagerComponent.Cast(entity.FindComponent(SCR_BaseCompartmentManagerComponent));
if (manager)
{
array<BaseCompartmentSlot> slots = {}; manager.GetCompartments(slots);
foreach (BaseCompartmentSlot slot : slots)
if (slot && (slot.GetType() == ECompartmentType.PILOT || slot.GetType() == ECompartmentType.TURRET) && !m_Seats.Contains(slot)) m_Seats.Insert(slot);
}
IEntity child = entity.GetChildren();
while (child) { Seats(child); child = child.GetSibling(); }
}
void Initialize(ERR_GameMode mode, vector pad, int serial, bool logistics = false)
{
if (m_Initialized || !Replication.IsServer()) return;
m_Initialized = true; m_Mode = mode; m_Aircraft = Vehicle.Cast(GetOwner()); m_Home = pad; m_Center = pad; m_Serial = serial;
m_Logistics = logistics; m_HoldAtAirport = logistics; m_TakeoffBase = pad;
m_Started = GetGame().GetWorld().GetWorldTime();
if (!m_Aircraft || !ERR_Actors.SetFaction(m_Aircraft, mode.EnemyFaction())) { Fail("aircraft faction unavailable"); return; }
m_Crew = SCR_AIGroup.Cast(ERR_Actors.Spawn("{C31F210ABE078195}Prefabs/ERR/ReconCrew.et", pad));
if (!m_Crew) { Fail("crew group unavailable"); return; }
mode.Own(m_Crew); m_Crew.SetFaction(mode.EnemyFaction()); m_Crew.SetPermanentLOD(0);
m_Crew.GetGroupUtilityComponent().SetCombatMode(EAIGroupCombatMode.FIRE_AT_WILL);
Seats(m_Aircraft);
if (m_Seats.Count() < 2 || m_Seats.Count() > 8) { Fail("unsupported pilot/gunner seat layout"); return; }
ResourceName pilot = "{C31F210ABE078197}Prefabs/ERR/ReconPilotEast.et";
string faction = mode.EnemyFaction().GetFactionKey();
if (faction == "US" || faction == "RHS_USAF") pilot = "{C31F210ABE078199}Prefabs/ERR/ReconPilotWest.et";
foreach (BaseCompartmentSlot seat : m_Seats)
{
if (seat.IsOccupied() || !seat.IsCompartmentAccessible()) { Fail("crew seat obstructed"); return; }
AIGroup nativeCrew = m_Crew;
IEntity initialCrew = seat.SpawnCharacterInCompartment(pilot, nativeCrew, "{C31F210ABE078195}Prefabs/ERR/ReconCrew.et");
if (initialCrew) m_OriginalCrew.Insert(initialCrew);
if (nativeCrew != m_Crew) { Fail("native boarding changed crew identity"); return; }
}
m_Crew.ActivateAI(); Locks(true);
if (!logistics) m_Assault = ERR_HQ.Get().AirAssault().Load(this, pad, serial);
}
protected bool Captured()
{
if (!m_Crew || m_Crew.GetPlayerCount(true) > 0 || m_Crew.GetFaction() != m_Mode.EnemyFaction() || SCR_FactionManager.SGetFaction(m_Aircraft) != m_Mode.EnemyFaction()) return true;
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
foreach (int id : players)
{
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
if (actor && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft) return true;
}
return false;
}
protected bool LivingPilot()
{
foreach (BaseCompartmentSlot seat : m_Seats)
if (seat && seat.GetType() == ECompartmentType.PILOT && m_OriginalCrew.Contains(seat.GetOccupant()) && ERR_Actors.CanFight(seat.GetOccupant()) && !ERR_Actors.Player(seat.GetOccupant())) return true;
return false;
}
protected void Engines(bool on)
{
if (!m_Aircraft) return;
VehicleHelicopterSimulation simulation = VehicleHelicopterSimulation.Cast(m_Aircraft.FindComponent(VehicleHelicopterSimulation));
if (!simulation) return;
if (on) { simulation.EngineStart(); simulation.SetThrottle(1); simulation.RotorSetForceScaleState(0, 2); simulation.RotorSetForceScaleState(1, 1); }
else { simulation.EngineStop(); simulation.SetThrottle(0); simulation.RotorSetForceScaleState(0, 0); simulation.RotorSetForceScaleState(1, 0); }
}
protected void Locks(bool locked) { m_CrewLocked = locked; }
bool HoldsSeat(IEntity actor)
{
return Replication.IsServer() && m_CrewLocked && m_Mode && m_Mode.Active() && m_Phase >= ERR_ReconPhase.CREW && m_Phase <= ERR_ReconPhase.PARKED
&& m_OriginalCrew.Contains(actor) && !ERR_Actors.Player(actor) && ERR_Actors.Group(actor) == m_Crew && ERR_Actors.Alive(m_Aircraft)
&& CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft;
}
protected void Fail(string reason) { m_Lost = true; m_Reason = reason; Release(false); ERR_HQ.Event("HELICOPTER FAILURE", reason); }
void Release(bool captured)
{
if (m_Phase == ERR_ReconPhase.RELEASED) return;
m_Phase = ERR_ReconPhase.RELEASED;
ClearEventMask(GetOwner(), EntityEvent.SIMULATE);
if (!captured && m_Aircraft && !Captured()) Engines(false);
Locks(false);
}
protected bool FinishCrew(float now)
{
m_Crew.ActivateAI();
array<AIAgent> boardingAgents = {}; m_Crew.GetAgents(boardingAgents);
foreach (AIAgent boardingAgent : boardingAgents) { boardingAgent.SetPermanentLOD(0); boardingAgent.ActivateAI(); }
if (m_Assault && m_Assault.Preparing()) return false;
if (now - m_Started > 45000) { Fail("native crew boarding timed out; partial spawn retained"); return false; }
foreach (BaseCompartmentSlot seat : m_Seats)
if (!seat || !seat.GetOccupant() || !ERR_Actors.CanFight(seat.GetOccupant()) || ERR_Actors.Group(seat.GetOccupant()) != m_Crew) return false;
// Native loadouts can complete after seating; keep the original boarding deadline.
if (ERR_HQRadio.Capability(m_Seats[0].GetOccupant()) != 2) return false;
foreach (BaseCompartmentSlot occupied : m_Seats)
{
IEntity actor = occupied.GetOccupant();
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction())) { Fail("crew faction unavailable"); return false; }
if (!m_OriginalCrew.Contains(actor)) m_OriginalCrew.Insert(actor);
}
ERR_ForceRecord record = m_Mode.Forces().Register("air-recon-" + m_Serial.ToString(), ERR_ForceKind.PATROL, m_Home, m_OriginalCrew.Count(), null, m_Crew, now);
if (!record) { Fail("finite force ledger refused crew"); return false; }
record.Pinned = true; m_Mode.RegisterGroup(m_Crew); m_Crew.ActivateAI();
array<AIAgent> agents = {}; m_Crew.GetAgents(agents);
foreach (AIAgent agent : agents) { agent.SetPermanentLOD(0); agent.ActivateAI(); }
Locks(true);
m_Aircraft.GetPhysics().SetActive(ActiveState.ACTIVE);
if (m_HoldAtAirport) { m_Phase = ERR_ReconPhase.PARKED; m_Reason = "ammunition helicopter awaiting HQ order"; }
else { Engines(true); m_Phase = ERR_ReconPhase.TAKEOFF; m_Reason = "vertical airport departure"; }
return true;
}
void ReturnHome()
{
if (!Replication.IsServer() || m_Lost || Captured() || m_Phase == ERR_ReconPhase.RELEASED) return;
if (m_Logistics && m_Delivery)
{
m_Delivery = false;
if (m_Phase == ERR_ReconPhase.PARKED) { RestartFlight(); return; }
m_Center = m_Home;
if (m_Phase == ERR_ReconPhase.TAKEOFF) return;
m_Phase = ERR_ReconPhase.RETURNING;
m_Reason = "cargo mission finished; returning to airport";
return;
}
if (m_Phase < ERR_ReconPhase.SEARCH || m_Phase > ERR_ReconPhase.SHADOW) return;
if (m_Assault) m_Assault.Abort();
m_Phase = ERR_ReconPhase.RETURNING;
m_Center = m_Home;
m_Reason = "returning to departure airfield";
m_LandingStarted = GetGame().GetWorld().GetWorldTime();
ERR_HQ.Event("HELICOPTER RETURN", m_Reason);
}
protected void Search(float now)
{
if (m_Logistics) { m_Phase = ERR_ReconPhase.RETURNING; m_Center = LandingTarget(); m_Reason = "HQ ammunition transport"; return; }
m_Phase = ERR_ReconPhase.SEARCH; m_NextSearch = now + 90000;
// Only delivered HQ snapshots are available as off-aircraft search hints.
array<ref ERR_HQReport> delivered = {}; ERR_HQ.Get().SupportContacts(delivered, now);
if (!delivered.IsEmpty()) { m_Center = delivered[delivered.Count() - 1].Position; m_Reason = "searching delivered HQ sighting"; return; }
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
for (int attempt = 0; attempt < 16; attempt++)
{
vector point = ERR_Actors.Ground(Vector(Math.RandomFloat(low[0], high[0]), 0, Math.RandomFloat(low[2], high[2])));
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue;
m_Center = point; m_Reason = "searching island sector"; return;
}
m_Center = m_Home;
}
protected bool IsEscapistTarget(IEntity target)
{
array<IEntity> players = {}; ERR_Actors.Players(players, true);
foreach (IEntity player : players)
if (target == player || CompartmentAccessComponent.GetVehicleIn(player) == target) return true;
return false;
}
protected void Observe(float now)
{
// The same real native-perception adapter used by ground scouts. HQ's own
// registered-crew observation tick sends these contacts through actual radios.
array<ref ERR_HQVisualContact> visible = {}; ERR_HQObservation.Gather(m_Crew.GetGroupUtilityComponent(), now, visible);
ERR_HQVisualContact selected;
foreach (ERR_HQVisualContact contact : visible)
if (IsEscapistTarget(contact.Entity) && vector.DistanceXZ(m_Aircraft.GetOrigin(), contact.Position) <= 1200
&& (!selected || (m_Observation && contact.Entity == m_Observation.Target))) selected = contact;
if (!selected || (m_Observation && selected.Entity == m_Observation.Target && selected.ObservedAt - m_ObservedAt < 2000)) return;
ERR_HQReport sighting = new ERR_HQReport(); sighting.Target = selected.Entity; sighting.Source = m_Crew;
sighting.Position = selected.Position; sighting.ObservedAt = selected.ObservedAt; sighting.TravelMode = selected.Travel;
ERR_ScoutMotion.Estimate(sighting, m_Observation); m_Observation = sighting; m_ObservedAt = sighting.ObservedAt;
}
void Update(float now)
{
if (!m_Initialized || m_Lost || !m_Mode || !m_Mode.Active()) return;
if (!m_Aircraft || !ERR_Actors.Alive(m_Aircraft)) { Fail("aircraft destroyed or removed"); return; }
if (m_Phase == ERR_ReconPhase.CREW) { FinishCrew(now); return; }
SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(m_Aircraft.FindComponent(SCR_VehicleDamageManagerComponent));
bool captured = Captured();
bool disabled = !damage || !damage.GetEngineFunctional() || damage.GetMovementDamage() >= 1 || damage.IsOnFire();
Physics physics = m_Aircraft.GetPhysics();
if (!physics) { Fail("aircraft physics unavailable"); return; }
if (m_Phase == ERR_ReconPhase.TAKEOFF) physics.SetActive(ActiveState.ACTIVE);
string loss = m_Availability.Check(now, m_Started, false, disabled, LivingPilot(), captured, m_Phase == ERR_ReconPhase.PARKED,
m_Aircraft.GetOrigin()[1] - SCR_TerrainHelper.GetTerrainY(m_Aircraft.GetOrigin()), physics.GetVelocity().Length());
if (!loss.IsEmpty()) { Fail(loss); return; }
if (captured) { m_Reason = "player takeover; flight controls released"; Release(true); return; }
if (m_Phase == ERR_ReconPhase.RELEASED || m_Phase == ERR_ReconPhase.PARKED) return;
if (now >= m_NextLog) { m_NextLog = now + 15000; ERR_HQ.Event("HELICOPTER STATUS", Status() + "; position=" + m_Aircraft.GetOrigin().ToString()); }
if (m_Phase == ERR_ReconPhase.RETURNING && vector.DistanceXZ(m_Aircraft.GetOrigin(), LandingTarget()) < 100)
{ m_Phase = ERR_ReconPhase.LANDING; m_LandingStarted = now; m_Reason = "landing at airfield"; }
if (m_Phase == ERR_ReconPhase.LANDING && now - m_LandingStarted > 180000) { m_Phase = ERR_ReconPhase.RETURNING; m_Reason = "landing retry"; return; }
if (m_Phase != ERR_ReconPhase.SEARCH && m_Phase != ERR_ReconPhase.SHADOW) return;
vector cover; if (m_Assault && m_Assault.Cover(now, cover)) { Observe(now); m_Center = cover; m_Reason = "covering landed airborne squad"; return; }
FuelManagerComponent fuel = FuelManagerComponent.Cast(m_Aircraft.FindComponent(FuelManagerComponent));
if (fuel && fuel.GetTotalFuel() < 50) { ReturnHome(); return; }
Observe(now);
if (m_Observation && now - m_ObservedAt <= 15000)
{
m_Phase = ERR_ReconPhase.SHADOW;
m_Center = ERR_HQReport.Predict(m_Observation.Position, m_Observation.Velocity, Math.Min(8, (now - m_ObservedAt) / 1000));
m_Reason = "orbiting last visible contact";
}
else if (m_Observation && now - m_ObservedAt <= 120000)
{
m_Phase = ERR_ReconPhase.SEARCH;
if (now >= m_NextSearch)
{
float angle = Math.RandomFloat(0, Math.PI2); float radius = Math.Min(600, 150 + (now - m_ObservedAt) / 200);
m_Center = m_Observation.Position + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius);
m_NextSearch = now + 30000; m_Reason = "searching around lost sighting";
}
}
else if (now >= m_NextSearch) Search(now);
}
override void EOnSimulate(IEntity owner, float timeSlice)
{
if (!Replication.IsServer() || !m_Initialized || m_Phase == ERR_ReconPhase.CREW || m_Phase >= ERR_ReconPhase.PARKED) return;
if (!m_Mode || !m_Mode.Active() || Captured()) { Release(true); return; }
if (!LivingPilot() || !ERR_Actors.Alive(m_Aircraft)) { Fail("pilot or airframe lost"); return; }
Physics physics = owner.GetPhysics(); if (!physics) { Fail("physics lost"); return; }
vector position = owner.GetOrigin(); float now = GetGame().GetWorld().GetWorldTime();
if (m_Phase == ERR_ReconPhase.TAKEOFF)
{
float climb; if (now - m_Started >= 10000) climb = 5;
physics.SetVelocity(Vector(0, climb, 0)); physics.SetAngularVelocity(vector.Zero);
if (position[1] - m_TakeoffBase[1] >= 120) Search(now);
return;
}
if (m_Phase == ERR_ReconPhase.LANDING)
{
vector toward = LandingTarget() - position; float vertical = toward[1]; toward[1] = 0;
vector angles = owner.GetYawPitchRoll(); owner.SetYawPitchRoll(Vector(angles[0], Math.Lerp(angles[1], 0, Math.Clamp(timeSlice * 2, 0, 1)), Math.Lerp(angles[2], 0, Math.Clamp(timeSlice * 2, 0, 1))));
vector landing = toward.Normalized() * Math.Min(8, toward.Length() * 0.5);
landing[1] = Math.Clamp(vertical * 0.3, -3, 3); if (toward.Length() > 15) landing[1] = Math.Max(0, landing[1]);
physics.SetVelocity(landing); physics.SetAngularVelocity(vector.Zero);
if (toward.Length() < 3 && Math.AbsFloat(vertical) < 1 && physics.GetVelocity().Length() < 1) m_Settled += timeSlice; else m_Settled = 0;
if (m_Settled >= 2) { m_Phase = ERR_ReconPhase.PARKED; m_Reason = "landed; original helicopter retained"; Engines(false); physics.SetVelocity(vector.Zero); }
return;
}
vector destination = m_Center; bool dropRun;
vector dropDestination;
if (m_Assault && m_Assault.Approach(dropDestination)) { dropRun = true; destination = dropDestination; }
vector desired = ERR_ReconFlightRules.Cruise(position, destination, dropRun || m_Phase == ERR_ReconPhase.RETURNING);
if (dropRun && desired.Length() > 24) desired = desired.Normalized() * 24;
m_Velocity = vector.Lerp(m_Velocity, desired, Math.Clamp(timeSlice * 0.6, 0, 1)); m_Velocity[1] = 0;
if (!dropRun && m_Phase != ERR_ReconPhase.RETURNING && m_Observation && now - m_ObservedAt <= 120000) m_Velocity = ERR_ReconFlightRules.StandOff(position, m_Observation.Position, m_Velocity);
vector ahead = position + m_Velocity.Normalized() * 100;
float terrain = Math.Max(SCR_TerrainHelper.GetTerrainY(ahead, GetGame().GetWorld(), noUnderwater: true), SCR_TerrainHelper.GetTerrainY(position, GetGame().GetWorld(), noUnderwater: true));
physics.SetVelocity(Vector(m_Velocity[0], Math.Clamp((terrain + 120 - position[1]) * 2, -2, 24), m_Velocity[2]));
if (m_Velocity.Length() > 1) NoseDown();
}
protected void NoseDown()
{
vector direction[4]; Math3D.DirectionAndUpMatrix(m_Velocity.Normalized(), vector.Up, direction);
float pitch[4], base[4], rotation[4];
SCR_Math3D.QuatAngleAxis(Math.Lerp(0, 20, Math.Clamp(m_Velocity.Length() / 70, 0, 1)) * Math.DEG2RAD, direction[0], pitch);
Math3D.MatrixToQuat(direction, base); Math3D.QuatMultiply(rotation, pitch, base);
vector axes[3]; Math3D.QuatToMatrix(rotation, axes);
vector transform[4]; transform[0] = axes[0]; transform[1] = axes[1]; transform[2] = axes[2]; transform[3] = m_Aircraft.GetOrigin();
m_Aircraft.SetTransform(transform);
}
override void OnDelete(IEntity owner)
{
if (Replication.IsServer())
{
Release(false);
ERR_HQ hq = ERR_HQ.Get();
if (m_Mode && m_Mode.Active() && hq && hq.Recon()) hq.Recon().Removed(this);
}
super.OnDelete(owner);
}
}
@@ -0,0 +1,305 @@
// Ten finite observation posts on Everon. Survey and stock finish before player
// admission. No replacements, periodic ammunition grants or target-position polling.
class ERR_ScoutPost
{
vector Position;
float Heading;
SCR_AIGroup Group;
IEntity Actor;
BaseInventoryStorageComponent Pack;
ResourceName Magazine;
int Magazines;
bool Ready;
ref ERR_EnemyEquipmentCallback Operation;
ref array<ref ERR_HQReport> MotionSamples = {};
}
class ERR_ScoutTerrain
{
// Reject low/flat terrain; prefer local crests with several downhill views.
static bool Hill(float height, float islandHigh, float meanDrop, int lowerSamples)
{
return height >= Math.Max(35, islandHigh * 0.18) && meanDrop >= 6 && lowerSamples >= 4;
}
static bool Separated(vector point, array<ref ERR_ScoutPost> posts, ERR_ScenarioDefinition definition)
{
foreach (ERR_ScoutPost post : posts) if (vector.DistanceXZ(point, post.Position) < 900) return false;
foreach (ERR_SiteDefinition site : definition.Sites)
{
float minimum = 150;
if (site.Kind == ERR_LocationKind.PRISON) minimum = 600;
if (vector.DistanceXZ(point, site.Position) < minimum) return false;
}
return true;
}
static bool Survey(vector point, float islandHigh, out float heading)
{
float drop; int lower;
for (int sample = 0; sample < 8; sample++)
{
float angle = sample * Math.PI / 4;
vector edge = ERR_Actors.Ground(point + Vector(Math.Sin(angle) * 150, 0, Math.Cos(angle) * 150));
float delta = point[1] - edge[1]; drop += delta;
if (delta >= 5) lower++;
}
if (!Hill(point[1], islandHigh, drop / 8, lower) || !ERR_HQRoadIntercept.Standable(point)) return false;
int views; float best = -1;
for (int ray = 0; ray < 8; ray++)
{
float bearing = ray * Math.PI / 4;
vector end = ERR_Actors.Ground(point + Vector(Math.Sin(bearing) * 350, 0, Math.Cos(bearing) * 350));
TraceParam sight = new TraceParam();
sight.Start = point + "0 1.7 0"; sight.End = end + "0 1.7 0";
sight.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
float fraction = GetGame().GetWorld().TraceMove(sight, null);
if (fraction >= 0.95) views++;
if (fraction > best) { best = fraction; heading = ray * 45; }
}
return views >= 2;
}
}
class ERR_ScoutSystem
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_ScoutPost> m_Posts = {};
protected ERR_ScenarioDefinition m_Definition;
protected vector m_Low, m_High;
protected float m_IslandHigh;
protected int m_Started, m_HeightSamples, m_Attempts;
protected float m_Deadline;
protected bool m_Begun, m_Surveyed, m_Ready, m_Stopped;
protected string m_Error;
void ERR_ScoutSystem(ERR_GameMode mode) { m_Mode = mode; }
string Error() { return m_Error; }
int Count() { return m_Posts.Count(); }
ERR_ScoutPost At(int index) { return m_Posts[index]; }
bool Contains(SCR_AIGroup group)
{
foreach (ERR_ScoutPost post : m_Posts) if (post.Group == group) return true;
return false;
}
// The source estimates motion from two clear sightings of the same identity.
// HQ receives this frozen estimate through the normal physical radio route.
void DescribeMotion(ERR_HQReport report)
{
if (!report || !report.Target || report.Distress || report.DetailsObservedAt < 0 || m_Stopped) return;
foreach (ERR_ScoutPost post : m_Posts)
{
if (!post.Ready || post.Group != report.Source) continue;
for (int index = post.MotionSamples.Count() - 1; index >= 0; index--)
{
ERR_HQReport previous = post.MotionSamples[index];
if (!previous.Target || report.ObservedAt - previous.ObservedAt > 30000) { post.MotionSamples.Remove(index); continue; }
if (previous.Target != report.Target) continue;
if (report.ObservedAt <= previous.ObservedAt) return;
ERR_ScoutMotion.Estimate(report, previous);
post.MotionSamples[index] = report.Snapshot();
return;
}
if (post.MotionSamples.Count() >= 64) post.MotionSamples.Remove(0);
post.MotionSamples.Insert(report.Snapshot());
return;
}
}
protected ERR_PrepareResult Fail(string reason)
{
m_Error = "Hill scout preparation: " + reason;
Print(m_Error, LogLevel.ERROR);
Stop();
return ERR_PrepareResult.FAILED;
}
protected vector RandomPoint()
{
return ERR_Actors.Ground(Vector(Math.RandomFloat(m_Low[0] + 50, m_High[0] - 50), 0, Math.RandomFloat(m_Low[2] + 50, m_High[2] - 50)));
}
protected ERR_PrepareResult Survey()
{
int tick = System.GetTickCount();
while (m_HeightSamples < 500)
{
vector heightSample = RandomPoint(); m_HeightSamples++;
m_IslandHigh = Math.Max(m_IslandHigh, heightSample[1]);
if (System.GetTickCount() - tick >= 6) return ERR_PrepareResult.PENDING;
}
while (m_Posts.Count() < m_Definition.ScoutCount)
{
if (++m_Attempts > 100000 || System.GetTickCount() - m_Started > 45000)
return Fail(string.Format("only %1/%2 safe, separated hilltops found; adjust ScoutCount or terrain criteria", m_Posts.Count(), m_Definition.ScoutCount));
vector point = RandomPoint(); float heading;
if (ERR_ScoutTerrain.Separated(point, m_Posts, m_Definition) && ERR_ScoutTerrain.Survey(point, m_IslandHigh, heading))
{
ERR_ScoutPost post = new ERR_ScoutPost(); post.Position = point; post.Heading = heading; m_Posts.Insert(post);
PrintFormat("Escapists [scout survey]: post=%1 position=%2 heading=%3", m_Posts.Count(), point, heading);
}
if (System.GetTickCount() - tick >= 6) return ERR_PrepareResult.PENDING;
}
m_Surveyed = true;
m_Deadline = GetGame().GetWorld().GetWorldTime() + 60000;
return ERR_PrepareResult.READY;
}
protected bool Spawn(ERR_ScoutPost post)
{
post.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{DF862ED195094E21}Prefabs/ERR/ScoutGroup.et", post.Position, Vector(post.Heading, 0, 0)));
if (!post.Group) return false;
m_Mode.Own(post.Group);
post.Group.SetFaction(m_Mode.EnemyFaction());
ResourceName soldier = "{CF862ED195094E28}Prefabs/ERR/ScoutEast.et";
post.Magazine = "{9CCB46C6EE632C1A}Prefabs/Weapons/Magazines/Magazine_762x54_SVD_10rnd_Sniper.et";
string faction = m_Mode.EnemyFaction().GetFactionKey();
if (faction == "US" || faction == "RHS_USAF")
{
soldier = "{CF862ED195094E2A}Prefabs/ERR/ScoutWest.et";
post.Magazine = "{627255315038152A}Prefabs/Weapons/Magazines/Magazine_762x51_M14_20rnd_SpecialBall.et";
}
post.Group.m_aUnitPrefabSlots = {soldier};
SCR_AIGroupUtilityComponent utility = post.Group.GetGroupUtilityComponent();
if (!utility) return false;
utility.SetCombatMode(EAIGroupCombatMode.RETURN_FIRE);
AIWaypoint hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), post.Position));
if (!hold) return false;
m_Mode.Own(hold); hold.SetCompletionRadius(8); post.Group.AddWaypoint(hold);
post.Group.SetSpawnImmediately(true); post.Group.RequestSpawn(1);
return true;
}
protected ERR_PrepareResult Stock(ERR_ScoutPost post)
{
if (!post.Group.IsExpandComplete()) return ERR_PrepareResult.PENDING;
array<AIAgent> agents = {}; post.Group.GetAgents(agents);
if (agents.Count() != 1 || !agents[0]) return ERR_PrepareResult.PENDING;
if (!post.Actor)
{
post.Actor = agents[0].GetControlledEntity();
if (!post.Actor) return ERR_PrepareResult.PENDING;
if (!ERR_Actors.SetFaction(post.Actor, m_Mode.EnemyFaction())) return Fail("scout faction affiliation unavailable");
// Native group spawn may choose an offset. Place the original body on the
// surveyed standing point, facing its longest checked view, before release.
ChimeraCharacter actor = ChimeraCharacter.Cast(post.Actor);
if (!actor) return Fail("scout character unavailable");
vector transform[4]; Math3D.AnglesToMatrix(Vector(post.Heading, 0, 0), transform); transform[3] = post.Position; actor.Teleport(transform);
SCR_ChimeraAIAgent nativeAgent = SCR_ChimeraAIAgent.Cast(agents[0]);
if (nativeAgent) nativeAgent.ERR_HoldForPrison();
}
if (!ERR_Actors.CanFight(post.Actor)) return Fail("scout died before admission; no replacement");
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(post.Actor.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return ERR_PrepareResult.PENDING;
if (!post.Pack)
{
array<BaseInventoryStorageComponent> storages = {}; inventory.GetStorages(storages);
foreach (BaseInventoryStorageComponent storage : storages)
if (storage && storage.GetOwner().GetPrefabData() && storage.GetOwner().GetPrefabData().GetPrefabName().Contains("/ScoutRadioPack.et")) { post.Pack = storage; break; }
if (!post.Pack) return ERR_PrepareResult.PENDING;
}
if (post.Operation)
{
if (!post.Operation.Finished) return ERR_PrepareResult.PENDING;
if (!post.Operation.Succeeded) return Fail("native magazine insertion failed");
post.Magazines++; post.Operation = null;
}
if (inventory.CanInsertResourceInStorage(post.Magazine, post.Pack))
{
if (post.Magazines >= 128) return Fail("backpack capacity exceeds bounded magazine allowance");
post.Operation = new ERR_EnemyEquipmentCallback();
if (!inventory.TrySpawnPrefabToStorage(post.Magazine, post.Pack, -1, EStoragePurpose.PURPOSE_ANY, post.Operation)) return Fail("native magazine insertion refused");
return ERR_PrepareResult.PENDING;
}
if (post.Magazines < 1 || ERR_HQRadio.Capability(post.Actor) != 2) return Fail("full backpack or physical long-range radio missing");
array<IEntity> items = {}; inventory.GetItems(items);
bool binoculars, rifle;
foreach (IEntity item : items)
{
if (item.FindComponent(SCR_BinocularsComponent)) binoculars = true;
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(item.FindComponent(BaseWeaponComponent));
if (weapon && weapon.GetWeaponType() == EWeaponType.WT_SNIPERRIFLE) rifle = true;
}
if (!binoculars || !rifle) return Fail("binoculars or scoped sniper rifle missing");
ERR_ForceRecord record = m_Mode.Forces().Register("hill-scout-" + (m_Posts.Find(post) + 1).ToString(), ERR_ForceKind.GARRISON, post.Position, 1, null, post.Group, GetGame().GetWorld().GetWorldTime());
if (!record) return Fail("finite force ledger admission refused");
record.Pinned = true;
m_Mode.RegisterGroup(post.Group);
post.Ready = true;
PrintFormat("Escapists [scout ready]: position=%1 backpack magazines=%2", post.Position, post.Magazines);
return ERR_PrepareResult.READY;
}
ERR_PrepareResult Prepare(ERR_ScenarioDefinition definition)
{
if (m_Stopped || !Replication.IsServer() || !definition) return ERR_PrepareResult.FAILED;
if (m_Ready) return ERR_PrepareResult.READY;
if (!m_Begun)
{
m_Begun = true; m_Definition = definition; m_Started = System.GetTickCount();
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
}
if (!m_Surveyed)
{
ERR_PrepareResult survey = Survey();
if (survey != ERR_PrepareResult.READY) return survey;
}
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) return Fail("native scout spawn/equipment deadline exceeded");
bool pending;
foreach (ERR_ScoutPost post : m_Posts)
{
if (post.Ready) continue;
if (!post.Group)
{
if (!Spawn(post)) return Fail("scout group spawn failed");
return ERR_PrepareResult.PENDING; // One group per preparation frame.
}
ERR_PrepareResult stock = Stock(post);
if (stock == ERR_PrepareResult.FAILED) return stock;
if (stock == ERR_PrepareResult.PENDING) pending = true;
}
if (pending) return ERR_PrepareResult.PENDING;
m_Ready = true;
return ERR_PrepareResult.READY;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
// GameMode owns bodies, groups, equipment and waypoints. No new work is queued.
}
}
// Classify only an already visible, captured coordinate against real road
// geometry. Vehicles in the air and nearby verges are not reported as on-road.
class ERR_ScoutRoad
{
static bool OnSegment(vector point, vector start, vector end, float width)
{
vector delta = end - start; delta[1] = 0;
float lengthSquared = vector.Dot(delta, delta);
if (lengthSquared < 0.01 || width <= 0) return false;
vector offset = point - start; offset[1] = 0;
float fraction = Math.Clamp(vector.Dot(offset, delta) / lengthSquared, 0, 1);
vector nearest = vector.Lerp(start, end, fraction);
return vector.DistanceXZ(point, nearest) <= width * 0.5 && Math.AbsFloat(point[1] - nearest[1]) <= 3;
}
static string Describe(vector observed)
{
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager()) return "road unknown";
BaseRoad road; float distance;
world.GetRoadNetworkManager().GetClosestRoad(observed, road, distance, skipNavlinks: true);
if (!road) return "off road";
array<vector> points = {}; road.GetPoints(points);
if (points.Count() < 2 || points.Count() > 1024) return "road unknown";
for (int i = 1; i < points.Count(); i++) if (OnSegment(observed, points[i - 1], points[i], road.GetWidth())) return "on road";
return "off road";
}
}
// Average observed ground motion, never a read of the target's current physics.
class ERR_ScoutMotion
{
static void Estimate(ERR_HQReport report, ERR_HQReport previous)
{
report.HasVelocity = false; report.Velocity = vector.Zero;
if (!previous || !report.Target || report.Target != previous.Target || report.Source != previous.Source || report.Distress || previous.Distress) return;
float seconds = (report.ObservedAt - previous.ObservedAt) / 1000;
if (seconds > 30 || !ERR_HQMemoryRules.MotionCredible(seconds, vector.DistanceXZ(previous.Position, report.Position), previous.TravelMode, report.TravelMode)) return;
report.Velocity = (report.Position - previous.Position) / seconds;
report.Velocity[1] = 0;
report.HasVelocity = true;
}
}
@@ -0,0 +1,227 @@
// Full supply convoys reuse finite vehicle creation and cargo ownership. The
// legacy finite-route service remains available for isolated migration fixtures.
class ERR_SupplyConvoyService : ERR_ConvoyService
{
void ERR_SupplyConvoyService(ERR_GameMode mode, ERR_ConvoyPool pool) {}
protected ref ERR_SupplyConvoyTask m_Creating;
protected ref array<vector> m_Bases = {};
void SetBases(array<vector> bases) { m_Bases.Copy(bases); }
protected override ERR_ConvoyTask NewTask()
{
m_Creating = new ERR_SupplyConvoyTask(); m_Creating.Bases.Copy(m_Bases);
return m_Creating;
}
protected override ERR_ConvoyVehicle NewVehicle() { return new ERR_SupplyConvoyVehicle(); }
protected override bool FillCrew(ERR_ConvoyVehicle unit)
{
if (m_Creating) m_Creating.Starts.Insert(unit.Entity.GetOrigin());
if (unit.Index != 2) return super.FillCrew(unit);
array<BaseCompartmentSlot> all = {}, drivers = {}, cargo = {};
if (!m_Creating || !ERR_ConvoyRules.CollectSeats(unit.Entity, all)) return false;
foreach (BaseCompartmentSlot seat : all)
{
if (!seat || seat.IsOccupied() || !seat.IsCompartmentAccessible()) continue;
if (seat.GetType() == ECompartmentType.PILOT) drivers.Insert(seat);
if (seat.GetType() == ECompartmentType.CARGO) cargo.Insert(seat);
}
if (drivers.Count() != 1 || cargo.Count() != 14) return false;
AIGroup nativeGroup = unit.Group;
IEntity driver = drivers[0].SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab());
if (driver) { m_Mode.Own(driver); unit.Members.Insert(driver); unit.Seats.Insert(drivers[0]); unit.Driver = driver; }
if (!driver || nativeGroup != unit.Group) return false;
for (int index = 0; index < 2; index++)
{
ERR_SupplyTeam team = new ERR_SupplyTeam(); m_Creating.Teams.Insert(team);
team.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), unit.Entity.GetOrigin()));
if (team.Group) m_Mode.Own(team.Group);
if (!ERR_ConvoyRules.EmptyCrewTemplate(team.Group)) return false;
AIFormationComponent formation = AIFormationComponent.Cast(team.Group.FindComponent(AIFormationComponent));
if (formation) formation.SetFormation("Line");
nativeGroup = team.Group;
for (int slot = index; slot < 14; slot += 2)
{
IEntity actor = cargo[slot].SpawnCharacterInCompartment(ERR_ConvoyResources.Soldier(), nativeGroup, ERR_ConvoyResources.GroupPrefab());
if (actor) { m_Mode.Own(actor); team.Members.Insert(actor); team.Seats.Insert(cargo[slot]); }
if (!actor || nativeGroup != team.Group) return false;
}
}
return true;
}
protected void Wake(SCR_AIGroup group)
{
if (!group) return;
group.SetPermanentLOD(0); group.ActivateAI();
array<AIAgent> agents = {}; group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) { agent.SetPermanentLOD(0); agent.ActivateAI(); }
}
protected override bool Admit(ERR_ConvoyTask task, float now)
{
ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
if (!supply || supply.Teams.Count() != 2) return false;
// Distant native agents must wake before compartment membership can settle.
foreach (ERR_ConvoyVehicle vehicle : supply.Vehicles)
{
Wake(vehicle.Group);
foreach (IEntity member : vehicle.Members) ERR_ConvoyMountedCrew.Hold(member, vehicle.Entity);
}
foreach (ERR_SupplyTeam infantry : supply.Teams) Wake(infantry.Group);
if (!super.Admit(supply, now)) return false;
SCR_AIVehicleUsageComponent truck = SCR_AIVehicleUsageComponent.Cast(supply.Vehicles[2].Entity.FindComponent(SCR_AIVehicleUsageComponent));
for (int index = 0; index < supply.Teams.Count(); index++)
{
ERR_SupplyTeam team = supply.Teams[index];
if (team.Registered) continue;
if (!team.Group || team.Group.GetFaction() != m_Mode.EnemyFaction() || team.Members.Count() != 7) return false;
array<AIAgent> agents = {}; team.Group.GetAgents(agents);
if (agents.Count() != 7) return false;
foreach (AIAgent agent : agents)
if (!agent || !team.Members.Contains(agent.GetControlledEntity()) || !ERR_Actors.CanFight(agent.GetControlledEntity()) || ERR_Actors.Player(agent.GetControlledEntity())) return false;
for (int slot = 0; slot < 7; slot++)
if (team.Seats[slot].GetOccupant() != team.Members[slot] || ERR_Actors.Group(team.Members[slot]) != team.Group) return false;
SCR_AIGroupUtilityComponent utility = team.Group.GetGroupUtilityComponent();
if (!utility || !truck) return false;
utility.AddUsableVehicle(truck);
ERR_ForceRecord record = m_Mode.Forces().Register(supply.Receipt.SourceId + "-vehicle-" + (index + 6).ToString(), ERR_ForceKind.CONVOY, team.Group.GetOrigin(), 7, null, team.Group, now);
if (!record || !m_Pool.BindRecord(supply.Receipt, index + 6, record)) { supply.Stop(m_Mode, "infantry ledger admission failed; no replacement"); return false; }
record.Pinned = true; team.Registered = true;
m_Mode.RegisterGroup(team.Group);
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
if (hq) hq.ReserveGroup(team.Group);
team.Group.SetPermanentLOD(0); team.Group.ActivateAI();
foreach (AIAgent active : agents) { active.SetPermanentLOD(0); active.ActivateAI(); }
}
supply.Tactics = new ERR_SupplyConvoyTactics(m_Mode, supply);
supply.TravelDeadline = now + 600000;
return true;
}
protected override void IssueCargo(ERR_ConvoyTask task)
{
ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
if (!supply || supply.CargoIssued || supply.Stopped) return;
supply.CargoIssued = true;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(supply.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent));
if (!inventory) { supply.MagazinesIssued = true; return; }
ERR_Inventory.Items(inventory, supply.CargoBefore);
SCR_EntityCatalogManagerComponent catalog = SCR_EntityCatalogManagerComponent.GetInstance();
array<SCR_ArsenalItem> weapons = {};
if (catalog) catalog.GetFactionArsenalItems(weapons, SCR_Faction.Cast(m_Mode.EnemyFaction()), -1, SCR_EArsenalItemMode.WEAPON);
for (int i = 0; i < 8; i++)
{
ResourceName prefab = ERR_ConvoyResources.Rifle();
if (!weapons.IsEmpty())
{
SCR_ArsenalItem choice = weapons[Math.RandomInt(0, weapons.Count())];
if (choice) prefab = choice.GetItemResourceName();
}
ERR_ConvoyCargoCallback operation = new ERR_ConvoyCargoCallback(); operation.Prefab = prefab; supply.Cargo.Insert(operation);
if (!inventory.TrySpawnPrefabToStorage(prefab, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, operation)) operation.Finished = true;
}
}
protected void IssueMagazines(ERR_SupplyConvoyTask task, float now)
{
if (task.MagazinesIssued || !task.CargoIssued || task.Stopped) return;
foreach (ERR_ConvoyCargoCallback operation : task.Cargo)
if (!operation.Finished && now < task.Deadline + 30000) return;
task.MagazinesIssued = true;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(task.Vehicles[1].Entity.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return;
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
array<ResourceName> expected = {};
foreach (ERR_ConvoyCargoCallback weaponOperation : task.Cargo)
if (weaponOperation.Finished && weaponOperation.Succeeded) expected.Insert(weaponOperation.Prefab);
int issued;
foreach (IEntity item : items)
{
if (!item || task.CargoBefore.Contains(item) || !item.GetPrefabData()) continue;
int match = expected.Find(item.GetPrefabData().GetPrefabName());
if (match < 0) continue;
expected.Remove(match);
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(item.FindComponent(BaseWeaponComponent));
if (!weapon) continue;
array<BaseMuzzleComponent> muzzles = {}; weapon.GetMuzzlesList(muzzles);
foreach (BaseMuzzleComponent muzzle : muzzles)
{
ResourceName magazine = muzzle.GetDefaultMagazineOrProjectileName();
if (magazine.IsEmpty()) continue;
for (int mag = 0; mag < 6 && issued < 96; mag++)
{
ERR_ConvoyCargoCallback load = new ERR_ConvoyCargoCallback(); load.Prefab = magazine; task.Cargo.Insert(load); issued++;
if (!inventory.TrySpawnPrefabToStorage(magazine, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, load)) load.Finished = true;
}
}
}
task.CargoBefore.Clear();
ERR_HQ.Event("CONVOY CARGO", task.Receipt.SourceId + ": eight weapon requests and matching finite ammunition batch; no refill");
}
protected bool Captured(ERR_SupplyConvoyTask task)
{
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
if (ERR_ConvoyRules.Captured(vehicle.Entity, vehicle.Group)) return true;
foreach (ERR_SupplyTeam team : task.Teams)
{
if (team.Group && team.Group.GetPlayerCount(true) > 0) return true;
foreach (IEntity member : team.Members) if (ERR_Actors.Player(member)) return true;
}
return false;
}
protected void Circuit(ERR_SupplyConvoyTask task, float now)
{
bool surviving, pending;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
if (!vehicle.Released && now >= task.TravelDeadline && task.TravelDeadline > 0) { vehicle.ClearOrder(); vehicle.Arrived = true; }
if (!vehicle.Released && task.Legs > 0 && vector.Distance(vehicle.Entity.GetOrigin(), task.Bases[task.LastBase]) <= 250)
{ vehicle.ClearOrder(); vehicle.Arrived = true; }
vehicle.Tick(m_Mode, task.Route, now);
if (!vehicle.Released) { surviving = true; if (!vehicle.Arrived) pending = true; }
}
if (!surviving) { task.Stop(m_Mode, "no surviving drivable convoy vehicles"); return; }
if (pending) return;
if (!task.Waiting) { task.Waiting = true; task.Departure = now + Math.RandomFloat(45000, 90000); return; }
if (now < task.Departure || task.Bases.Count() < 2) return;
int next = ERR_SupplyConvoyRules.NextBase(task.Bases.Count(), task.LastBase, Math.RandomInt(0, task.Bases.Count() - 1));
if (task.LastBase < 0) next = Math.RandomInt(0, task.Bases.Count());
if (next < 0) return;
ERR_ForceRoute leg = new ERR_ForceRoute(); leg.Add(task.Bases[next]); leg.Seal();
task.Route = leg; task.LastBase = next; task.Legs++; task.Waiting = false; task.TravelDeadline = now + 600000;
foreach (ERR_ConvoyVehicle departure : task.Vehicles)
{
if (departure.Released) continue;
departure.ClearOrder(); departure.RouteIndex = 0; departure.Arrived = false;
}
ERR_HQ.Event("CONVOY DEPARTURE", string.Format("%1 leg=%2 base=%3; native road navigation", task.Receipt.SourceId, task.Legs, task.Bases[next]));
}
override void Tick(float now)
{
if (!Current() || !ERR_ForceRules.Finite(now)) return;
foreach (ERR_ConvoyTask baseTask : m_Tasks)
{
ERR_SupplyConvoyTask task = ERR_SupplyConvoyTask.Cast(baseTask);
if (!task || task.Stopped || now < task.NextCheck) continue;
task.NextCheck = now + 1000;
if (Captured(task)) { task.Stop(m_Mode, "player captured convoy vehicle or crew"); continue; }
if (!task.Ready)
{
if (now >= task.Deadline) { task.Stop(m_Mode, "native crew readiness expired; partial convoy retained"); continue; }
if (!Admit(task, now)) continue;
task.Ready = true; IssueCargo(task);
}
IssueMagazines(task, now);
foreach (ERR_ConvoyVehicle checked : task.Vehicles)
if (!checked.Released && !checked.CrewValid(m_Mode)) checked.Release(m_Mode);
if (task.Tactics && task.Tactics.Tick(now)) continue;
Circuit(task, now);
}
}
override bool ApplyCombatOrder(ERR_ConvoyTask task, ERR_ConvoyCombatOrder order, float now)
{
ERR_SupplyConvoyTask supply = ERR_SupplyConvoyTask.Cast(task);
if (!supply || !supply.Tactics || !order) return false;
// Fresh delivered sightings may extend combat or interrupt reboarding. Only
// this controller may resume while the two infantry teams are still out.
if (!order.Hold && supply.Tactics.State() != 0) return false;
bool accepted = super.ApplyCombatOrder(task, order, now);
if (accepted && order.Hold) supply.Tactics.Report(order.Position, order.ObservedAt, now);
return accepted;
}
}
@@ -0,0 +1,133 @@
class ERR_SupplyConvoyRules
{
static int NextBase(int count, int last, int roll)
{
if (count < 2 || last < -1 || last >= count || roll < 0 || roll >= count - 1) return -1;
if (last >= 0 && roll >= last) return roll + 1;
return roll;
}
static bool Separated(vector position, array<vector> starts, float distance)
{
foreach (vector start : starts) if (vector.DistanceXZ(start, position) < distance) return false;
return true;
}
}
// Original finite startup target, eight bounded road candidates per attempt,
// separated initial positions and base-to-base circulation; never replacement spawns.
class ERR_SupplyConvoySystem
{
protected ERR_GameMode m_Mode;
protected ERR_ConvoyPool m_Pool;
protected ERR_SupplyConvoyService m_Service;
protected ref array<vector> m_Bases = {};
protected ref array<vector> m_Starts = {};
protected int m_Target;
protected int m_Attempts;
protected bool m_Automatic;
protected bool m_Stopped;
protected float m_Began = -1;
protected float m_Next;
protected string m_Reason;
void ERR_SupplyConvoySystem(ERR_GameMode mode, ERR_ConvoyPool pool, ERR_SupplyConvoyService service, int target, bool automatic)
{
m_Mode = mode; m_Pool = pool; m_Service = service;
m_Target = Math.Clamp(target, 0, 20); m_Automatic = automatic;
}
int Target() { return m_Target; }
static bool Approach(vector center, out vector position, float radius = 500)
{
vector angles;
return ApproachDirected(center, position, angles, radius);
}
static bool ApproachDirected(vector center, out vector position, out vector angles, float radius = 500)
{
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (!world || !world.GetRoadNetworkManager()) return false;
array<BaseRoad> roads = {};
world.GetRoadNetworkManager().GetRoadsInAABB(center - Vector(radius, 1000, radius), center + Vector(radius, 1000, radius), roads);
if (roads.Count() > 128) return false;
float nearest = radius; bool found;
foreach (BaseRoad road : roads)
{
if (!road || road.GetWidth() < 4) continue;
array<vector> points = {}; road.GetPoints(points);
if (points.Count() > 1024) continue;
for (int i = 1; i < points.Count(); i++)
{
vector delta = points[i] - points[i - 1]; delta[1] = 0;
float length = vector.Dot(delta, delta);
if (length < 0.01) continue;
vector offset = center - points[i - 1]; offset[1] = 0;
float fraction = Math.Clamp(vector.Dot(offset, delta) / length, 0, 1);
vector candidate = ERR_Actors.Ground(vector.Lerp(points[i - 1], points[i], fraction));
float distance = vector.DistanceXZ(center, candidate);
if (distance >= nearest || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) continue;
nearest = distance; position = candidate; angles = Vector(Math.Atan2(delta[0], delta[2]) * Math.RAD2DEG, 0, 0); found = true;
}
}
return found;
}
protected void FindBases()
{
m_Bases.Clear();
if (!m_Mode.Sites()) return;
for (int i = 0; i < m_Mode.Sites().Count(); i++)
{
ERR_SiteRuntime site = m_Mode.Sites().At(i);
if (!site || !site.Definition) continue;
ERR_LocationKind kind = site.Definition.Kind;
if (kind != ERR_LocationKind.OUTPOST && kind != ERR_LocationKind.DEPOT && kind != ERR_LocationKind.RADIO) continue;
if (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction()) continue;
vector approach;
if (Approach(site.Definition.Position, approach) && ERR_SupplyConvoyRules.Separated(approach, m_Bases, 300)) m_Bases.Insert(approach);
}
m_Service.SetBases(m_Bases);
}
bool StartOne()
{
if (m_Stopped || !Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || m_Service.Count() >= m_Target) return false;
if (m_Bases.Count() < 2) FindBases();
if (m_Bases.Count() < 2) { m_Reason = "waiting for two friendly military road approaches"; return false; }
if (!GetGame().GetWorldEntity()) return false;
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
float separation = Math.Min(1000, Math.Max(high[0] - low[0], high[2] - low[2]) * 0.1);
for (int attempt = 0; attempt < 8; attempt++)
{
vector seed = Vector(Math.RandomFloat(low[0], high[0]), 0, Math.RandomFloat(low[2], high[2]));
// A nearby base is the first candidate; remaining attempts cover the island.
if (attempt == 0) seed = m_Bases[(m_Attempts + m_Service.Count()) % m_Bases.Count()];
seed = ERR_Actors.Ground(seed);
array<vector> positions, angles; ERR_ForceRoute route;
if (!ERR_ConvoyRoadPlan.Build(seed, positions, angles, route) || !ERR_SupplyConvoyRules.Separated(positions[0], m_Starts, separation)) continue;
string source = "supply-convoy-" + (m_Service.Count() + 1).ToString();
ERR_ConvoyReceipt receipt = m_Pool.Reserve(source);
if (!receipt || receipt.Consumed) { m_Reason = "configured convoy capacity or shared force budget unavailable"; return false; }
ERR_ConvoyTask task = m_Service.Dispatch(receipt, positions, angles, route, GetGame().GetWorld().GetWorldTime());
if (!task) { m_Reason = "road preflight changed; no entities created"; return false; }
m_Starts.Insert(positions[0]);
m_Reason = "finite convoy slot deployed";
if (task.Stopped) m_Reason = "partial startup retained; this slot cannot respawn";
ERR_HQ.Event("CONVOY STARTUP", string.Format("%1/%2 at %3: %4", m_Service.Count(), m_Target, positions[0], m_Reason));
return !task.Stopped;
}
m_Reason = "no clear separated road formation in this bounded survey";
return false;
}
void Tick(float now, bool admitted)
{
if (m_Stopped || !m_Automatic || m_Target == 0 || m_Attempts >= 120 || m_Service.Count() >= m_Target || !ERR_ForceRules.Finite(now) || now < 0) return;
if (m_Began < 0) { m_Began = now; m_Next = now + 30000; }
if (now < m_Next) return;
m_Next = now + 5000;
if (!admitted) { m_Reason = "waiting for existing force admission"; return; }
m_Attempts++;
StartOne();
if (m_Attempts == 120 && m_Service.Count() < m_Target) ERR_HQ.Event("CONVOY STARTUP", "bounded survey exhausted; deployed slots retained without replacement");
}
string Status()
{
return string.Format("This round: %1/%2 convoy slots; automatic=%3, startup attempts=%4/120; %5", m_Service.Count(), m_Target, m_Automatic, m_Attempts, m_Reason);
}
void Stop() { m_Stopped = true; }
}
@@ -0,0 +1,404 @@
// Original convoy tactics adapted to native AI waypoints. No old framework hooks.
class ERR_SupplyTeam
{
SCR_AIGroup Group;
ref array<IEntity> Members = {};
ref array<BaseCompartmentSlot> Seats = {};
ref array<AIWaypoint> Orders = {};
bool Registered;
bool Released;
vector OrderedContact;
void Clear()
{
foreach (AIWaypoint waypoint : Orders)
{
if (Group && waypoint) Group.RemoveWaypoint(waypoint);
if (waypoint) SCR_EntityHelper.DeleteEntityAndChildren(waypoint);
}
Orders.Clear();
}
void Add(AIWaypoint waypoint)
{
if (!waypoint) return;
if (!Group || Released) { SCR_EntityHelper.DeleteEntityAndChildren(waypoint); return; }
Orders.Insert(waypoint); Group.AddWaypoint(waypoint);
}
void Release(ERR_GameMode mode)
{
if (Released) return;
Released = true; Clear();
if (!Group) return;
if (mode && mode.Command()) mode.Command().UnregisterGroup(Group);
ERR_ForceRecord record;
if (mode && mode.Forces()) record = mode.Forces().FindGroup(Group);
if (record) record.Pinned = false;
Group.SetPermanentLOD(-1);
array<AIAgent> agents = {}; Group.GetAgents(agents);
foreach (AIAgent agent : agents) if (agent) agent.SetPermanentLOD(-1);
}
bool Living()
{
foreach (IEntity member : Members)
if (ERR_Actors.CanFight(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == Group) return true;
return false;
}
bool Aboard(Vehicle truck, bool required)
{
foreach (IEntity member : Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Group(member) != Group) continue;
if ((CompartmentAccessComponent.GetVehicleIn(member) == truck) != required) return false;
}
return true;
}
}
// Only the exact original vehicle crews are retained. Infantry never enters this map.
class ERR_ConvoyMountedCrew
{
protected static ref map<IEntity, Vehicle> s_Crews = new map<IEntity, Vehicle>();
static void Hold(IEntity actor, Vehicle vehicle) { if (actor && vehicle) s_Crews.Set(actor, vehicle); }
static void Release(IEntity actor) { if (actor) s_Crews.Remove(actor); }
static bool Retain(IEntity actor, IEntity vehicle)
{
if (!actor || !vehicle || !s_Crews.Contains(actor) || s_Crews.Get(actor) != vehicle || ERR_Actors.Player(actor) || !ERR_Actors.CanFight(actor)) return false;
if (CompartmentAccessComponent.GetVehicleIn(actor) != vehicle) return false;
SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(vehicle.FindComponent(SCR_VehicleDamageManagerComponent));
// Let native emergency abandonment take over on critical damage or drivetrain failure.
return damage && damage.GetState() != EDamageState.DESTROYED && damage.GetHealthScaled() > 0.25 && damage.GetEngineFunctional() && damage.GetGearboxFunctional();
}
static bool RetainGroup(SCR_AIGroup group, IEntity vehicle)
{
if (!group || group.GetPlayerCount(true) > 0) return false;
IEntity leader = group.GetLeaderEntity();
if (!leader) return false;
if (!vehicle) vehicle = CompartmentAccessComponent.GetVehicleIn(leader);
return Retain(leader, vehicle);
}
}
modded class SCR_AIGetOutVehicle
{
override float CustomEvaluate()
{
if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0;
return super.CustomEvaluate();
}
override float EvaluatePriorityLevel()
{
if (m_Utility && ERR_ConvoyMountedCrew.Retain(m_Utility.m_OwnerEntity, m_Vehicle.m_Value)) return 0;
return super.EvaluatePriorityLevel();
}
}
modded class SCR_AIGetOutActivity
{
override float CustomEvaluate()
{
if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0;
return super.CustomEvaluate();
}
override float EvaluatePriorityLevel()
{
if (m_Utility && ERR_ConvoyMountedCrew.RetainGroup(m_Utility.m_Owner, m_Vehicle.m_Value)) return 0;
return super.EvaluatePriorityLevel();
}
}
class ERR_SupplyConvoyVehicle : ERR_ConvoyVehicle
{
override bool CrewValid(ERR_GameMode mode)
{
if (!Group || !Entity || !ERR_Actors.Alive(Entity) || Group.GetFaction() != mode.EnemyFaction() || !ERR_Actors.CanFight(Driver)) return false;
if (ERR_Actors.Player(Driver) || ERR_Actors.Group(Driver) != Group || !ERR_ConvoyRules.PartOf(CompartmentAccessComponent.GetVehicleIn(Driver), Entity)) return false;
foreach (IEntity member : Members)
if (ERR_Actors.Alive(member) && (ERR_Actors.Player(member) || ERR_Actors.Group(member) != Group)) return false;
return true;
}
override void Release(ERR_GameMode mode)
{
foreach (IEntity member : Members) ERR_ConvoyMountedCrew.Release(member);
super.Release(mode);
}
}
class ERR_SupplyConvoyTask : ERR_ConvoyTask
{
ref array<ref ERR_SupplyTeam> Teams = {};
ref ERR_SupplyConvoyTactics Tactics;
ref array<vector> Bases = {};
ref array<vector> Starts = {};
int LastBase = -1;
int Legs;
float Departure;
float TravelDeadline;
bool Waiting;
bool MagazinesIssued;
ref array<IEntity> CargoBefore = {};
override void Stop(ERR_GameMode mode, string reason)
{
if (Stopped) return;
if (Tactics) Tactics.Stop();
foreach (ERR_SupplyTeam team : Teams) team.Release(mode);
super.Stop(mode, reason);
}
}
class ERR_SupplyConvoyTactics
{
protected ERR_GameMode m_Mode;
protected ERR_SupplyConvoyTask m_Task;
protected ref array<ref ERR_SupplyTeam> m_Support = {};
protected int m_State; // mounted, fighting, reboarding
protected vector m_Contact;
protected vector m_Forward;
protected vector m_Left;
protected float m_LastContact = -1;
protected float m_NextOrders;
protected float m_BoardDeadline;
protected bool m_TruckWithdrawn;
protected float m_NextDismount;
protected int m_DismountAttempts;
protected bool m_Stopped;
void ERR_SupplyConvoyTactics(ERR_GameMode mode, ERR_SupplyConvoyTask task)
{
m_Mode = mode; m_Task = task;
foreach (ERR_ConvoyVehicle vehicle : task.Vehicles)
{
ERR_SupplyTeam order = new ERR_SupplyTeam(); order.Group = vehicle.Group;
m_Support.Insert(order);
foreach (IEntity member : vehicle.Members) ERR_ConvoyMountedCrew.Hold(member, vehicle.Entity);
}
}
int State() { return m_State; }
static AIWaypoint Move(vector position, float radius = 20)
{
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), position));
if (waypoint) waypoint.SetCompletionRadius(radius);
return waypoint;
}
static AIWaypoint Board(Vehicle truck, bool enter)
{
if (!truck) return null;
ResourceName prefab = "{C40316EE26846CAB}Prefabs/AI/Waypoints/AIWaypoint_GetOut.et";
if (enter) prefab = "{712F4795CF8B91C7}Prefabs/AI/Waypoints/AIWaypoint_GetIn.et";
SCR_BoardingWaypoint waypoint = SCR_BoardingWaypoint.Cast(ERR_Actors.Spawn(prefab, truck.GetOrigin()));
if (!waypoint) return null;
waypoint.SetAllowance(false, false, true);
// Native defaults may complete when only the leader has left/boarded.
waypoint.SetCompletionType(EAIWaypointCompletionType.All);
// The waypoint approach applies to the whole team; leave space around the truck.
waypoint.SetCompletionRadius(100);
SCR_BoardingEntityWaypoint specific = SCR_BoardingEntityWaypoint.Cast(waypoint);
if (specific) specific.SetEntity(truck);
return waypoint;
}
protected bool Usable(int index)
{
return index >= 0 && index < m_Task.Vehicles.Count() && !m_Task.Vehicles[index].Released && m_Task.Vehicles[index].CrewValid(m_Mode);
}
protected bool InfantryAboard(bool required)
{
foreach (ERR_SupplyTeam team : m_Task.Teams)
if (!team.Released && !team.Aboard(m_Task.Vehicles[2].Entity, required)) return false;
return true;
}
protected float DistanceToConvoy(vector position)
{
float distance = 1e20;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
if (!vehicle.Released && vehicle.Entity) distance = Math.Min(distance, vector.DistanceXZ(vehicle.Entity.GetOrigin(), position));
return distance;
}
protected bool Observe(SCR_AIGroup group, out vector position, inout float nearest)
{
if (!group || !group.GetGroupUtilityComponent()) return false;
SCR_AIGroupPerception perception = group.GetGroupUtilityComponent().m_Perception;
PerceptionManager manager = GetGame().GetPerceptionManager();
if (!perception || !manager) return false;
bool found;
foreach (SCR_AITargetInfo target : perception.m_aTargets)
{
if (!target || !target.m_Entity || !target.m_Faction || !group.GetFaction().IsFactionEnemy(target.m_Faction)) continue;
float age = manager.GetTime() - target.m_fTimestamp;
if (target.m_eCategory != EAITargetInfoCategory.IDENTIFIED || age < 0 || age > 5) continue;
float distance = DistanceToConvoy(target.m_vWorldPos);
if (distance > nearest || !group.GetGroupUtilityComponent().IsPositionAllowed(target.m_vWorldPos)) continue;
position = target.m_vWorldPos; nearest = distance; found = true;
}
return found;
}
// Only production native observations or a delivered HQ snapshot may call this.
void Report(vector position, float observed, float now)
{
if (m_Stopped || !ERR_ForceRules.Finite(now) || !ERR_ForceRules.Finite(observed) || !ERR_ForceRules.FiniteVector(position) || observed < 0 || observed > now || now - observed > 60000 || observed < m_LastContact) return;
m_LastContact = observed;
if (m_State != 1) Start(position, now);
else if (vector.DistanceXZ(position, m_Contact) > 40 && now >= m_NextOrders) { m_Contact = position; Orders(false, now); }
}
protected bool FootPosition(ERR_SupplyTeam team, vector desired, out vector position)
{
if (!team.Group || !team.Group.GetGroupUtilityComponent()) return false;
for (int i = 0; i < 8; i++)
{
vector point = desired;
if (i > 0) point += Vector(Math.RandomFloat(-12, 12), 0, Math.RandomFloat(-12, 12));
point = ERR_Actors.Ground(point);
if (!ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) && team.Group.GetGroupUtilityComponent().IsPositionAllowed(point) && SCR_WorldTools.TraceCilinderUtil(point + "0 1 0", 1, 2)) { position = point; return true; }
}
return false;
}
protected void Flank(int index, bool dismount)
{
ERR_SupplyTeam team = m_Task.Teams[index];
if (team.Released || !team.Living()) return;
// Preserve an in-progress native all-member dismount across contact refreshes.
if (!team.Aboard(m_Task.Vehicles[2].Entity, false) && SCR_BoardingWaypoint.Cast(team.Group.GetCurrentWaypoint())) return;
team.Clear(); team.OrderedContact = m_Contact;
if (dismount && !team.Aboard(m_Task.Vehicles[2].Entity, false)) team.Add(Board(m_Task.Vehicles[2].Entity, false));
float side = 1; if (index == 1) side = -1;
vector approach, sector;
if (!FootPosition(team, m_Contact - m_Forward * 80 + m_Left * (110 * side), approach) || !FootPosition(team, m_Contact + m_Left * (70 * side), sector)) return;
team.Add(Move(approach, 15));
AIWaypoint search = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), sector));
if (search) search.SetCompletionRadius(30);
team.Add(search);
}
protected bool SupportPosition(int index, vector desired, out vector position)
{
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
SCR_AIGroupUtilityComponent utility = vehicle.Group.GetGroupUtilityComponent();
if (!utility) return false;
vector road;
if (ERR_SupplyConvoySystem.Approach(desired, road, 70) && vector.Dot(road - m_Contact, m_Forward) <= -100 && utility.IsPositionAllowed(road)) { position = road; return true; }
if (index == 1 || index == 2) return false;
// Original armed escorts can seek a nearby open firing position off the road.
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int attempt = 0; attempt < 8; attempt++)
{
vector candidate = desired;
if (attempt > 0) candidate += Vector(Math.RandomFloat(-15, 15), 0, Math.RandomFloat(-15, 15));
candidate = ERR_Actors.Ground(candidate);
if (vector.Dot(candidate - m_Contact, m_Forward) > -100 || !utility.IsPositionAllowed(candidate)) continue;
if (placement.Clear(candidate, ERR_ForceRules.Heading(candidate, m_Contact, 0))) { position = candidate; return true; }
}
return false;
}
protected void Support(int index, bool holdTruck)
{
ERR_SupplyTeam orders = m_Support[index]; orders.Clear();
if (!Usable(index)) return;
ERR_ConvoyVehicle vehicle = m_Task.Vehicles[index];
// An idle mounted driver stops without a Defend order asking the crew to exit.
if (index == 2 && holdTruck) return;
float rear = 180, side;
if (index == 0) side = 150;
if (index == 3) side = -150;
if (index == 5) rear = 250;
if (index == 1) { rear = 350; side = 40; }
if (index == 2) { rear = 310; side = -40; }
vector support = vehicle.Entity.GetOrigin();
vector desired = m_Contact - m_Forward * rear + m_Left * side;
vector chosen;
if (SupportPosition(index, desired, chosen)) support = chosen;
if (vector.DistanceXZ(vehicle.Entity.GetOrigin(), support) > 20) orders.Add(Move(support));
if (index == 1 || index == 2) return;
AIWaypoint defend = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), support));
if (defend) defend.SetCompletionRadius(20);
orders.Add(defend);
}
protected void Clear()
{
foreach (ERR_SupplyTeam orders : m_Support) orders.Clear();
foreach (ERR_SupplyTeam team : m_Task.Teams) team.Clear();
}
protected void Orders(bool dismount, float now)
{
for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, !m_TruckWithdrawn);
for (int i = 0; i < m_Task.Teams.Count(); i++) Flank(i, dismount);
m_NextOrders = now + 30000;
}
protected void Start(vector contact, float now)
{
Clear(); m_State = 1; m_Contact = contact; m_TruckWithdrawn = false;
vector origin;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
vehicle.ClearOrder();
if (!vehicle.Released) vehicle.Combat.Held = true;
if (!vehicle.Released) origin = vehicle.Entity.GetOrigin();
}
m_Forward = contact - origin; m_Forward[1] = 0;
if (m_Forward.Length() < 1) m_Forward = "0 0 1";
m_Forward.Normalize(); m_Left = Vector(-m_Forward[2], 0, m_Forward[0]);
Orders(true, now);
m_DismountAttempts = 0; m_NextDismount = now + 10000;
ERR_HQ.Event("CONVOY COMBAT", m_Task.Receipt.SourceId + ": infantry flank left/right; escorts cover; troop truck holds for dismount");
}
protected void Finish(float now)
{
Clear(); m_State = 0;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
{
vehicle.Combat.Held = false;
// Combat time does not consume the driving deadline.
vehicle.LegDeadline = now + 600000;
}
m_Task.TravelDeadline = now + 600000;
ERR_HQ.Event("CONVOY REGROUP", m_Task.Receipt.SourceId + ": surviving convoy resumes; no replacements");
}
bool Tick(float now)
{
if (m_Stopped) return false;
for (int released = 0; released < m_Task.Vehicles.Count(); released++)
if (m_Task.Vehicles[released].Released) m_Support[released].Clear();
foreach (ERR_SupplyTeam infantryTeam : m_Task.Teams)
{
if (infantryTeam.Released) continue;
bool foreign = !infantryTeam.Group || infantryTeam.Group.GetFaction() != m_Mode.EnemyFaction();
foreach (IEntity member : infantryTeam.Members)
if (ERR_Actors.Alive(member) && ERR_Actors.Group(member) != infantryTeam.Group) foreign = true;
if (foreign || !infantryTeam.Living()) infantryTeam.Release(m_Mode);
}
vector observed; float nearest = 400; bool found;
foreach (ERR_ConvoyVehicle vehicle : m_Task.Vehicles)
if (!vehicle.Released && Observe(vehicle.Group, observed, nearest)) found = true;
foreach (ERR_SupplyTeam team : m_Task.Teams)
if (!team.Released && Observe(team.Group, observed, nearest)) found = true;
if (found) Report(observed, now, now);
if (m_State == 1)
{
if (!m_TruckWithdrawn && InfantryAboard(false)) { m_TruckWithdrawn = true; Support(2, false); }
// Native exit attempts can fail while the truck brakes or a door is busy.
// Retry only still-mounted teams, never teleport actors or reset quiet time.
if (!m_TruckWithdrawn && Usable(2) && now >= m_NextDismount && m_DismountAttempts < 6)
{
m_DismountAttempts++; m_NextDismount = now + 10000;
for (int retry = 0; retry < m_Task.Teams.Count(); retry++)
{
ERR_SupplyTeam delayed = m_Task.Teams[retry];
if (delayed.Released || delayed.Aboard(m_Task.Vehicles[2].Entity, false)) continue;
delayed.Clear(); Flank(retry, true);
}
}
if (found && now >= m_NextOrders) Orders(!InfantryAboard(false), now);
if (now - m_LastContact >= 60000)
{
Clear();
if (!Usable(2)) { foreach (ERR_SupplyTeam lost : m_Task.Teams) lost.Release(m_Mode); Finish(now); return false; }
for (int i = 0; i < m_Task.Vehicles.Count(); i++) Support(i, true);
foreach (ERR_SupplyTeam boarding : m_Task.Teams)
if (!boarding.Released && boarding.Living()) boarding.Add(Board(m_Task.Vehicles[2].Entity, true));
m_State = 2; m_BoardDeadline = now + 120000;
ERR_HQ.Event("CONVOY BOARDING", m_Task.Receipt.SourceId + ": both teams return under escort cover");
}
}
if (m_State == 2 && (!Usable(2) || InfantryAboard(true) || now >= m_BoardDeadline))
{
foreach (ERR_SupplyTeam stranded : m_Task.Teams)
if (!stranded.Aboard(m_Task.Vehicles[2].Entity, true)) stranded.Release(m_Mode);
Finish(now);
}
return m_State != 0;
}
void Stop() { if (m_Stopped) return; m_Stopped = true; Clear(); }
}
@@ -1,5 +1,11 @@
modded class ERR_ScenarioComponent
{
protected override ERR_PrepareResult PrepareAdditionalPopulation()
{
ERR_HQ hq = ERR_HQ.Get();
if (!hq) return ERR_PrepareResult.FAILED;
return hq.Scouts().Prepare(m_RuntimeDefinition);
}
protected override bool PrepareAdditionalForces()
{
if (!super.PrepareAdditionalForces()) return false;
@@ -36,22 +36,16 @@ modded class ERR_GameMode
protected void ERR_FlushDiscoveryFeedback(float now)
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || m_ERRIntelPending.IsEmpty() || now < m_ERRIntelMessageAt) return;
int identified;
int rough;
string label;
string message;
foreach (ERR_LocationComponent location, int knowledge : m_ERRIntelPending)
{
if (!location) continue;
if (knowledge == 2)
{
identified++;
label = location.Label();
}
else rough++;
string entry = ERR_IntelDiscoveryText.Message(knowledge == 2, knowledge == 1, ERR_IntelDiscoveryText.Type(location.Kind(), location.Id()));
if (!message.IsEmpty()) message += "\n";
message += entry;
}
m_ERRIntelPending.Clear();
m_ERRIntelMessageAt = now + 5000;
string message = ERR_IntelDiscoveryText.Message(identified, rough, label);
if (message.IsEmpty()) return;
// The listen host and remote recipients share the same per-round sequence.
// Local delivery is explicit and cannot duplicate a transport callback.
@@ -66,6 +60,15 @@ modded class ERR_GameMode
m_ERRIntelReceivedSequence = sequence;
SCR_ChatComponent.RadioProtocolMessage(message);
}
void ERR_IntelExhausted(bool duplicate = false)
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active()) return;
string message = "Every known location has already been identified. This intel doesn't help us.";
if (duplicate) message = "This document concerns a location we have already identified.";
m_ERRIntelMessageSequence++;
ERR_ReceiveDiscoveryFeedback(m_ERRIntelMessageSequence, message);
Rpc(ERR_ReceiveDiscoveryFeedback, m_ERRIntelMessageSequence, message);
}
protected void ERR_StopDiscoveryFeedback()
{
m_ERRIntelFeedbackStopped = true;
@@ -79,15 +82,22 @@ modded class ERR_GameMode
class ERR_IntelDiscoveryText
{
static string Type(int kind, string id)
{
if (kind == ERR_LocationKind.RADIO) return "radio station";
if (kind == ERR_LocationKind.OUTPOST) return "outpost";
if (kind == ERR_LocationKind.MORTAR) return "mortar site";
if (id.Contains("medical")) return "medical stash";
if (kind == ERR_LocationKind.DEPOT) return "supply cache";
return "location";
}
// Exact wording from the owner's original EI_IntelService. New site types
// supply their own noun; unknown clues never reveal the noun or site name.
static string Message(int identified, int rough, string label)
{
if (identified + rough == 0) return "";
if (identified == 0 && rough == 1) return "A new 500 m search area is marked on your field map. Recover another folder to identify the location.";
if (identified == 1 && rough == 0)
{
if (label.IsEmpty()) label = "A location";
return label + " identified. Check your field map.";
}
return string.Format("Field map updated: %1 identified locations and %2 search areas. Open a carried map to review them.", identified, rough);
if (identified == 0) return "New location marked. Type unknown.";
if (label.IsEmpty()) label = "location";
return "Hey! A " + label + " was identified in the intel!";
}
}
@@ -0,0 +1,197 @@
// Issued document identities and their sealed contents. This is local logistics
// bookkeeping, not HQ knowledge of player inventories or map discoveries.
class ERR_IssuedIntel
{
IEntity Folder;
string SiteId;
}
class ERR_BodyEvidence
{
IEntity Actor;
string SourceId;
ref array<ref ERR_IssuedIntel> Issued = {};
bool Searched;
bool Reported;
}
class ERR_IntelEvidence
{
protected ref array<ref ERR_BodyEvidence> m_Roster = {};
protected bool m_Stopped;
protected int m_Cursor;
void Retire(IEntity folder) { foreach (ERR_BodyEvidence record : m_Roster) for (int i = record.Issued.Count() - 1; i >= 0; i--) if (record.Issued[i].Folder == folder) record.Issued.Remove(i);
}
ERR_BodyEvidence Find(IEntity actor)
{
foreach (ERR_BodyEvidence record : m_Roster) { if (record.Actor == actor) return record;
}
return null;
}
void Register(SCR_AIGroup group)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (m_Stopped || !mode || !Replication.IsServer() || !group || group.GetFaction() != mode.EnemyFaction()) return;
ERR_ForceRecord force = mode.Forces().FindGroup(group);
if (!force) return;
array<AIAgent> agents = {};
group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
if (!agent || !agent.GetControlledEntity() || Find(agent.GetControlledEntity()) || m_Roster.Count() >= 4096) continue;
ERR_BodyEvidence record = new ERR_BodyEvidence();
record.Actor = agent.GetControlledEntity();
record.SourceId = force.SourceId;
m_Roster.Insert(record);
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(record.Actor);
if (garbage) garbage.UpdateBlacklist(record.Actor, true);
}
}
void Capture(IEntity actor)
{
ERR_BodyEvidence record = Find(actor);
if (!record || record.Searched || !ERR_Actors.Alive(actor) || ERR_Actors.Player(actor)) return;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return;
array<IEntity> folders = {};
ERR_DiscoveryService.Folders(inventory, folders);
array<ERR_LocationComponent> locations = {};
ERR_GameMode.Get().Locations(locations);
array<ERR_LocationComponent> eligible = {};
foreach (ERR_LocationComponent location : locations)
if (location && location.Kind() != ERR_LocationKind.PRISON && location.Kind() != ERR_LocationKind.EXTRACTION) eligible.Insert(location);
if (eligible.IsEmpty()) return;
foreach (IEntity folder : folders)
{
bool known;
foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) known = true;
if (known || record.Issued.Count() >= 16) continue;
ERR_IssuedIntel item = new ERR_IssuedIntel();
item.Folder = folder;
ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent));
if (document) item.SiteId = document.TargetId();
else item.SiteId = eligible.GetRandomElement().Id();
record.Issued.Insert(item);
}
}
string Target(IEntity folder)
{
foreach (ERR_BodyEvidence record : m_Roster)
foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) return issued.SiteId;
return "";
}
void Tick(float now)
{
if (m_Stopped) return;
// Preserve only the mission-owned cohort. Transferred/player-controlled actors
// are released from retention instead of becoming forensic targets.
for (int budget = 0; budget < Math.Min(16, m_Roster.Count()); budget++)
{
if (m_Cursor >= m_Roster.Count()) m_Cursor = 0;
ERR_BodyEvidence record = m_Roster[m_Cursor++];
if (!record.Actor || record.Reported) continue;
if (ERR_Actors.Player(record.Actor) || SCR_FactionManager.SGetFaction(record.Actor) != ERR_GameMode.Get().EnemyFaction())
{ record.Reported = true;
record.Searched = true;
Release(record);
continue;
}
if (ERR_Actors.Alive(record.Actor)) Capture(record.Actor);
}
}
bool Inspect(IEntity body, IEntity observer, out array<string> missing)
{
missing.Clear();
if (!Replication.IsServer() || m_Stopped || !ERR_GameMode.Get() || !ERR_GameMode.Get().Active()) return false;
ERR_BodyEvidence record = Find(body);
if (!record || record.Searched || ERR_Actors.Alive(body) || !ERR_Actors.CanFight(observer) || ERR_Actors.Player(observer) || CompartmentAccessComponent.GetVehicleIn(observer)) return false;
if (vector.Distance(observer.GetOrigin(), body.GetOrigin()) > 3) return false;
TraceParam trace = new TraceParam();
trace.Start = observer.GetOrigin() + "0 1.5 0";
trace.End = body.GetOrigin() + "0 0.3 0";
trace.Exclude = observer;
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(trace, null) < 1 && trace.TraceEnt != body) return false;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(body.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return false;
array<IEntity> folders = {};
ERR_DiscoveryService.Folders(inventory, folders);
foreach (ERR_IssuedIntel issued : record.Issued)
if (!folders.Contains(issued.Folder) && !missing.Contains(issued.SiteId)) missing.Insert(issued.SiteId);
record.Searched = true;
return true;
}
void Release(ERR_BodyEvidence record)
{
if (!record || !record.Actor) return;
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(record.Actor);
if (garbage) { garbage.UpdateBlacklist(record.Actor, false);
if (!ERR_Actors.Alive(record.Actor)) garbage.Insert(record.Actor, 300);
}
}
// Searching alone does not authorize cleanup: the missing-document report
// must first reach HQ. Duplicate delivery never renews the cleanup timer.
void Delivered(IEntity body)
{
if (!Replication.IsServer() || m_Stopped) return;
ERR_BodyEvidence record = Find(body);
if (!record || !record.Searched || record.Reported) return;
record.Reported = true;
Release(record);
}
bool Retained(IEntity actor) { ERR_BodyEvidence record = Find(actor);
return !m_Stopped && record && !record.Reported;
}
bool RetainedWithin(IEntity entity)
{
if (m_Stopped || !entity) return false;
foreach (ERR_BodyEvidence record : m_Roster)
{
if (!record.Actor || record.Reported || ERR_Actors.Alive(record.Actor)) continue;
IEntity parent = record.Actor;
for (int depth = 0; parent && depth < 32; depth++)
{
if (parent == entity) return true;
parent = parent.GetParent();
}
}
return false;
}
void Stop() { m_Stopped = true;
foreach (ERR_BodyEvidence record : m_Roster) Release(record);
m_Roster.Clear();
}
}
modded class ERR_GameMode
{
protected ref ERR_IntelEvidence m_Evidence = new ERR_IntelEvidence();
ERR_IntelEvidence Evidence() { return m_Evidence;
}
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (SCR_Global.IsEditMode() || !Replication.IsServer()) return;
OnGroup().Insert(m_Evidence.Register);
OnTick().Insert(m_Evidence.Tick);
OnStop().Insert(m_Evidence.Stop);
}
}
// Native vehicle garbage collection must not delete a retained corpse through
// its parent hierarchy (for example a dead occupant in a wreck).
modded class SCR_GarbageSystem
{
protected bool ERR_RetainedEvidence(IEntity entity)
{
ERR_GameMode mode = ERR_GameMode.Get();
return Replication.IsServer() && mode && mode.Evidence() && mode.Evidence().RetainedWithin(entity);
}
override protected float OnInsertRequested(IEntity entity, float lifetime)
{
if (ERR_RetainedEvidence(entity)) return 0;
return super.OnInsertRequested(entity, lifetime);
}
override protected bool OnBeforeDelete(IEntity entity)
{
if (ERR_RetainedEvidence(entity)) return false;
return super.OnBeforeDelete(entity);
}
}
@@ -127,6 +127,7 @@ class ERR_IntelReroll
// the existing phase budget without spending or owning the folder.
if (!inventory.TryRemoveItemFromInventory(folder)) { entry.WaitingFor = "removal-refused"; return; }
m_Mode.Discovery().MarkSpent(folder);
m_Mode.Evidence().Retire(folder);
m_Mode.Own(folder);
entry.Removing = folder;
entry.WaitingFor = "native-removal";

Some files were not shown because too many files have changed in this diff Show More